Authentication is one of those backend topics where the high-level idea is simple, but the edge cases keep showing up in production. This post is a concise refresher on the primitives, trade-offs, and small implementation details that are easy to forget.

A quick note on identity providers before we start. The patterns below are not tied to any single vendor. You can run open-source tools such as Keycloak or Zitadel yourself, or use cloud-managed services like Auth0, Okta, Microsoft Entra, AWS Cognito, or Google Identity. The concepts stay the same regardless of who hosts the server.

1. JWTs are signed payloads, not session state

A JWT is a compact, signed container for claims such as user ID, roles, and expiration time. The verifier trusts the payload only because the signature can be validated.

In the RS256 or asymmetric approach, the identity provider signs the token with a private key. The verifier fetches the corresponding public key from a JWKS endpoint and validates the signature locally. This works well when many independent services need to verify tokens without sharing secrets. The verifier never holds the signing key, so a compromised API server cannot forge tokens.

HS256, the symmetric option, uses the same secret on both sides. It is faster, but if one service leaks the secret, any party that knows it can issue valid tokens. Use it for internal, high-traffic boundaries where secret rotation is tightly controlled.

JWTs are stateless: no database lookup is needed to validate them. The trade-off is instant revocation. You can shorten expiry times or keep a blocklist in Redis for high-risk events, but a fully stateless JWT cannot be revoked immediately on its own. Opaque tokens, such as a random string stored in Redis, are the opposite: every request needs a cache lookup, but deleting the token revokes access immediately.

Here is a small Python example that verifies an RS256 JWT with a key fetched from a JWKS endpoint.

import jwt
import requests

jwks = requests.get("https://idp.example.com/.well-known/jwks.json").json()
public_key = jwt.algorithms.RSAAlgorithm.from_jwk(jwks["keys"][0])

payload = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],
    audience="my-api",
    issuer="https://idp.example.com",
)
An API key on a keyboard with a warning to rotate regularly.
An API key on a keyboard with a warning to rotate regularly.

2. API keys are long-lived credentials

An API key is a static secret that a client sends in a header, usually x-api-key. It is a convenient integration pattern, but it is also easy to misuse.

The main risks are that keys are committed to source control, they rarely expire on their own, and a leaked key grants access until it is manually revoked. The safest approach is to treat an API key like any other credential: rotate it regularly, scope it to the minimum set of endpoints it needs, and exchange it for a short-lived internal token at the edge before traffic reaches core services.

Below is a minimal FastAPI dependency that checks an API key.

from fastapi import Header, HTTPException

async def require_api_key(x_api_key: str = Header(...)):
    internal_token = exchange_api_key_for_jwt(x_api_key)
    if not internal_token:
        raise HTTPException(status_code=401, detail="Invalid API key")
    return internal_token
Two servers exchanging a short-lived access token.
Two servers exchanging a short-lived access token.

3. Machine-to-machine auth uses OAuth2 Client Credentials

When one service calls another, the OAuth2 Client Credentials grant is the standard pattern. The caller authenticates to a central identity server with a client ID and secret, then receives a short-lived access token. Services do not share long-lived secrets directly with each other, which is good, but it also introduces a dependency on a central token issuer and the network latency it adds.

A practical detail that avoids production surprises: cache the access token and refresh it a few seconds before expiry. Otherwise a burst of requests can all hit 401 the moment the previous token expires.

This Python snippet caches a token until shortly before its expiry time.

import requests
import time

_cache = {}

def get_service_token():
    now = time.time()
    expires_at = _cache.get("expires_at", 0)

    if now < expires_at - 15:
        return _cache["token"]

    response = requests.post(
        "https://idp.example.com/oauth/token",
        data={
            "grant_type": "client_credentials",
            "client_id": CLIENT_ID,
            "client_secret": CLIENT_SECRET,
            "audience": "service-b",
        },
    )
    response.raise_for_status()
    data = response.json()

    _cache["token"] = data["access_token"]
    _cache["expires_at"] = now + data["expires_in"]
    return _cache["token"]
A signed webhook envelope protected by HMAC and a timestamp check.
A signed webhook envelope protected by HMAC and a timestamp check.

4. Webhooks need signatures and timestamp checks

HTTPS confirms the identity of the server, but it does not prove that a payload was not tampered with or replayed. Webhooks should be signed.

With HMAC-SHA256, the sender hashes the payload with a shared secret and attaches the signature. The receiver recomputes the hash and compares it. A timestamp field with a small allowed skew, often five minutes, prevents replay attacks where an attacker resends a valid webhook repeatedly.

One practical gotcha is that signature validation is brittle. If the payload formatting changes by even a single byte, such as whitespace or key ordering, the signatures will not match. Normalization needs to be strict and consistent on both sides.

Here is a small Python verifier for a webhook HMAC signature.

import hmac
import hashlib

def verify_webhook(payload: bytes, signature: str, secret: bytes) -> bool:
    expected = hmac.new(secret, payload, hashlib.sha256).hexdigest()
    return hmac.compare_digest(f"sha256={expected}", signature)
Two shields authenticating each other during a mutual TLS handshake.
Two shields authenticating each other during a mutual TLS handshake.

5. mTLS is authentication at the transport layer

In standard TLS, the client validates the server certificate. In mutual TLS, both sides present certificates before the connection is fully established. An attacker without a valid client certificate cannot open a TCP connection to the service. There is no token to leak or misconfigure at the application layer.

The operational cost is real. Running an internal certificate authority, handling certificate signing requests, and rotating certificates before they expire all require automation and monitoring. If certificate lifecycle management is weak, mTLS can become a source of outages rather than a security win.

A decision tree branching into roles, attributes, and relationships.
A decision tree branching into roles, attributes, and relationships.

6. Authentication is not authorization

Authentication and authorization are different concerns. Authentication proves identity. Authorization decides what that identity is allowed to do.

Role-based access control assigns users roles such as admin, manager, or viewer, and maps each role to permissions. It is easy to audit but can become unwieldy when roles multiply. Attribute-based access control makes decisions based on dynamic attributes like time of day, department, or resource ownership. Fine-grained authorization handles relationship-based rules, such as the creator of a document being able to share it with specific people, using policy engines like OpenFGA, OPA, or Casbin.

The right model depends on the shape of your access rules. Many systems use a mix: RBAC for coarse-grained permissions and ABAC or FGA for specific edge cases.

This FastAPI dependency shows a minimal RBAC-style permission check.

from fastapi import Depends, HTTPException

def require_permission(permission: str):
    def checker(user: User = Depends(get_current_user)):
        if permission not in user.permissions:
            raise HTTPException(status_code=403, detail="Forbidden")
        return user
    return checker

@app.get("/reports", dependencies=[Depends(require_permission("reports:read"))])
def list_reports():
    ...
A castle wall showing rate limiting, secret redaction, and least privilege.
A castle wall showing rate limiting, secret redaction, and least privilege.

7. Defense in depth is not optional

Authentication alone does not keep a system safe. A few supporting controls are worth keeping in mind.

Rate limiting tracks requests per caller using a fast store such as Redis. The key should be a hashed identity, never a raw API key or token. If a script misbehaves, the system returns 429 Too Many Requests.

import redis
import hashlib

r = redis.Redis()

def is_rate_limited(token: str, max_requests: int = 100, window: int = 60) -> bool:
    key = f"rate:{hashlib.sha256(token.encode()).hexdigest()}"
    current = r.incr(key)
    if current == 1:
        r.expire(key, window)
    return current > max_requests

Secret redaction should run at the base layer of your logging framework, replacing fields like Authorization, x-api-key, password, and cvc with [REDACTED] before they reach observability tools. This prevents accidental leakage and keeps audit trails clean.

Least privilege means every service account, API key, and token carries only the permissions it actually needs. Avoid broad admin scopes for routine automation.

To summarize: JWTs are convenient but not free. Choose asymmetric or symmetric signing based on who needs to verify them. API keys are credentials and should be rotated and scoped. OAuth2 Client Credentials decouple services, but require token caching to avoid expiry races. Webhooks need HMAC signatures and timestamp checks, not just HTTPS. mTLS is powerful when certificate automation is solid. Authorization is a separate design space from authentication, with RBAC, ABAC, and FGA serving different rule shapes. Finally, rate limiting, secret redaction, and least privilege close the gaps that authentication cannot cover on its own.

For further reading, RFC 7519 covers JWTs, RFC 7517 covers JSON Web Keys, OWASP publishes the API Security Top 10, Svix has a practical webhook security guide, and the PyJWT documentation is a good reference for the code examples above.