Authentication: Proving Who Is Calling Your API

The second post in the API Security series — a practical tour of API keys, session cookies, bearer tokens, OAuth 2.0, OIDC and JWTs, plus how to verify a token correctly and where authentication quietly breaks.

Authentication is the part of an API that answers one question: who is making this request? Get it wrong and everything downstream is built on sand — every access-control check, every audit log, every rate limit assumes it already knows the caller. This is why broken authentication sits near the top of the OWASP API Security Top 10 (it is API2:2023). The failures are rarely exotic: an endpoint that forgets to check a token, a token whose signature is never verified, a credential that lives forever because nobody built rotation.

This post walks the options for proving identity — from the humble API key up to OAuth 2.0 and OpenID Connect — and shows how to validate a token without stepping on the classic landmines. One theme runs through all of it: authentication proves identity; it does not grant permission. Deciding what an authenticated caller may do is authorization, and that is the subject of the next post.


The identities you might be proving

Before picking a mechanism, be clear about what you are identifying. There are two very different subjects:

API keys identify the first. Sessions, OAuth user flows and OIDC identify the second. Conflating them is a common design error: an API key in a mobile app does not tell you which user is holding the phone, only that the app build is legitimate.


API keys: identify an app, not a person

An API key is a long, opaque, high-entropy string a client sends on every request, usually in a header:

GET /v1/reports HTTP/1.1
Host: api.example.com
Authorization: Bearer ak_live_9f2c8d4a7b1e6f3c0a5d2e8b4c1f7a9d

Keys are simple and great for server-to-server calls where a full OAuth exchange is overkill. But they carry no notion of a user, no expiry, and no built-in scope. That puts the entire burden on you:

The gotcha: API keys identify an application, not a user, and they do not expire on their own. A key that leaks into a git commit or a client-side bundle stays valid until you notice and revoke it. Never ship one in front-end JavaScript or a mobile binary as if it were a user credential — scope it tightly and rotate it on a schedule rather than trusting it to age out.


Session cookies: the browser’s native answer

For a server-rendered web app talking to its own backend, session cookies are still the right tool. The server creates a session, stores state server-side (or in a signed cookie), and the browser resends the cookie automatically on every request:

Set-Cookie: session=Ux8...; HttpOnly; Secure; SameSite=Lax; Path=/

The three flags matter:

Because the browser attaches the cookie automatically, cookies are vulnerable to CSRF: a malicious page can trigger a state-changing request to your API and the browser helpfully includes the session. SameSite=Lax or Strict closes most of this, but for anything cross-origin you also want an anti-CSRF token (the synchroniser or double-submit pattern).

The gotcha: the same property that makes cookies convenient — automatic attachment — is exactly what makes them CSRF-prone. Bearer tokens in an Authorization header are not sent automatically by the browser, so they sidestep CSRF but must be stored and attached by your code (and kept out of localStorage if XSS is a concern). Pick one model per client and understand its threat; do not mix cookie auth and header auth on the same endpoint without thinking it through.


Bearer tokens and OAuth 2.0

A bearer token is any credential where possession alone grants access — “bearer” meaning whoever holds it can use it. API keys and OAuth access tokens are both bearer tokens. The name is a warning: there is no second factor binding the token to its rightful holder, so a stolen bearer token is the identity until it expires. That is why transport security (TLS everywhere) and short lifetimes are non-negotiable.

OAuth 2.0 (RFC 6749) is the framework for issuing those tokens without handing your credentials to every app. Its value is delegation: a user lets a client act on their behalf at a resource server, without the client ever seeing the user’s password. Four roles are worth memorising:

Which grant type, and when

The grant type is how a client obtains a token. Only two are recommended today:

Which grant type, and when
Grant Use it for Notes
Authorization Code + PKCE User-facing apps (web, mobile, SPA) The default for anything with a user. PKCE is now required for all clients.
Client Credentials Machine-to-machine, no user present The client authenticates as itself and gets a token for its own access.

The current OAuth Security Best Current Practice (RFC 9700) is explicit about what to avoid:

PKCE (Proof Key for Code Exchange) protects the authorization-code flow from interception. The client generates a random code_verifier, sends its hash (code_challenge) on the initial request, and later presents the raw verifier when redeeming the code. An attacker who steals the authorization code off the redirect cannot exchange it without the verifier.

GET /authorize?response_type=code
  &client_id=s6BhdRkqt3
  &redirect_uri=https://app.example.com/callback
  &scope=read:reports
  &state=xyz
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: auth.example.com

The gotcha: OAuth 2.0 is an authorization framework — it gets a client a token to call an API. It was never designed to tell the client who the user is. Bolting identity onto raw OAuth (for example, treating an access token as proof of login) is how “log in with X” implementations leak. When you need identity, use OpenID Connect, which was built for exactly that.


OpenID Connect: the identity layer

OpenID Connect (OIDC) is a thin standard layer on top of OAuth 2.0 that adds authentication. Alongside the access token, the authorization server returns an ID token — a JWT with standard claims about the authentication event: who the user is (sub), who issued it (iss), who it is for (aud), when it was issued (iat) and when it expires (exp).

The distinction is worth holding onto:

OIDC also standardises discovery (/.well-known/openid-configuration) and a JWKS endpoint that publishes the signing keys — which is exactly what you need to verify tokens, coming up next.


JWTs: structure and stateless validation

A JSON Web Token (RFC 7519) is a compact, signed (and optionally encrypted) container of claims. Both OAuth access tokens and OIDC ID tokens are frequently JWTs. The format is three Base64URL segments joined by dots:

eyJhbGciOiJSUzI1NiIsImtpZCI6ImsxIn0   ← header
.
eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJhdWQiOiJhcGk...   ← payload
.
Z3J1bXB5LWNhdC1zaWduYXR1cmU...   ← signature

The appeal is stateless validation: your API can verify a JWT with just the issuer’s public key, no database round-trip, no session store. That is also the trap — statelessness means you must check everything yourself, and a token you cannot un-issue.

A correct validation checks all of the following, and rejects on any failure:

  1. The signature, using the algorithm you expect and the key you trust.
  2. exp — not expired (with a small clock-skew leeway at most).
  3. nbf / iat — not used before it is valid.
  4. aud — the token is intended for your API.
  5. iss — the token came from the issuer you trust.

The classic JWT pitfalls

The JWT Best Current Practices (RFC 8725) exist because these mistakes are common and severe:

The gotcha: never accept the token’s own alg header blindly. alg: none and the RS256→HS256 confusion are real, exploited bypasses, not theory. Pin the algorithm and the key on the verifier side, so a token that says “verify me with none” or “verify me with HMAC” is rejected before its claims are ever read.


Verifying a JWT correctly

Here is the shape of a correct verification in Go, using a JWKS-published RSA key. The load-bearing detail is WithValidMethods — it pins the accepted algorithm so a forged alg never reaches the key.

import "github.com/golang-jwt/jwt/v5"

func verify(tokenString string, keyfunc jwt.Keyfunc) (*jwt.Token, error) {
    return jwt.Parse(
        tokenString,
        keyfunc, // returns the RSA public key for the token's kid
        jwt.WithValidMethods([]string{"RS256"}), // PIN the algorithm — no "none", no HS256
        jwt.WithExpirationRequired(),            // exp must be present and unexpired
        jwt.WithAudience("https://api.example.com"), // aud must match us
        jwt.WithIssuer("https://auth.example.com"),  // iss must be trusted
        jwt.WithLeeway(30*time.Second),          // small clock-skew tolerance only
    )
}

And in Python with PyJWT, the same pins — algorithms is a fixed allowlist, and audience / issuer are checked:

import jwt  # PyJWT
from jwt import PyJWKClient

def verify(token: str) -> dict:
    jwk_client = PyJWKClient("https://auth.example.com/.well-known/jwks.json")
    signing_key = jwk_client.get_signing_key_from_jwt(token)
    return jwt.decode(
        token,
        signing_key.key,
        algorithms=["RS256"],            # allowlist — never read alg from the token
        audience="https://api.example.com",
        issuer="https://auth.example.com",
        options={"require": ["exp", "iat", "aud", "iss"]},
    )

Note what both do: the algorithm is an explicit allowlist, expiry is required, and audience and issuer are matched. Drop any of those and you have re-created one of the pitfalls above.


An API-key middleware with constant-time comparison

For plain API-key auth, the subtle bug is comparing the presented key against the stored one with ==. A naive string comparison returns as soon as two bytes differ, and that timing difference can leak the key byte by byte to a patient attacker. Compare in constant time.

import (
    "crypto/hmac"
    "crypto/sha256"
    "net/http"
)

// storedHash is HMAC-SHA-256(serverKey, apiKey), computed once at key creation.
func apiKeyMiddleware(storedHash []byte, serverKey []byte, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        presented := r.Header.Get("X-API-Key")
        if presented == "" {
            http.Error(w, "missing API key", http.StatusUnauthorized)
            return
        }
        mac := hmac.New(sha256.New, serverKey)
        mac.Write([]byte(presented))
        // hmac.Equal is constant-time; a plain == would leak timing.
        if !hmac.Equal(mac.Sum(nil), storedHash) {
            http.Error(w, "invalid API key", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

The Python equivalent uses hmac.compare_digest, which is likewise designed to run in constant time:

import hmac, hashlib

def check_api_key(presented: str, stored_hash: bytes, server_key: bytes) -> bool:
    computed = hmac.new(server_key, presented.encode(), hashlib.sha256).digest()
    return hmac.compare_digest(computed, stored_hash)  # constant-time

The gotcha: compare secrets, keys and MACs in constant time, always. A regular == or bytes.equal short-circuits on the first differing byte, and that timing signal is enough to reconstruct a secret over many requests. Use hmac.Equal / hmac.compare_digest (or your language’s equivalent) for anything an attacker gets to guess repeatedly.


Access tokens, refresh tokens and rotation

Short-lived access tokens limit the blast radius of theft, but forcing the user to log in every few minutes is hostile. The standard answer is a pair:

The security-critical practice is refresh token rotation: each time a refresh token is redeemed, the authorization server issues a new refresh token and invalidates the old one. If a stolen refresh token is later replayed, the server sees a already-used token and can revoke the whole token family — turning theft into a detectable event rather than silent, indefinite access. RFC 9700 recommends rotation (or sender-constraining) for exactly this reason.

The gotcha: a JWT cannot be un-issued. A long expiry with no revocation list means a stolen access token stays valid until it lapses — there is no “log out” that reaches a token already in an attacker’s hands. Keep access-token lifetimes short, put revocation behind the refresh flow (rotate refresh tokens, maintain a server-side denylist for the rare emergency), and resist the temptation to issue a 24-hour access token because refresh “is annoying.”


MFA and step-up, briefly

Everything above proves you hold a credential. Multi-factor authentication raises the bar at the moment of login by requiring a second factor — something you have (a device, a passkey) or are (a biometric) — so a stolen password alone is not enough. From an API’s perspective, MFA usually happens at the authorization server and shows up as a claim in the resulting token (for example, an amr or acr claim describing how the user authenticated).

Step-up authentication is the useful extension: most endpoints accept an ordinary token, but a few high-risk ones (change bank details, delete an account) demand a fresh, stronger authentication first. Your API enforces this by inspecting the authentication-context claims and rejecting the request — typically with a challenge — when the token was not minted with a strong-enough factor recently enough. It is a clean way to keep everyday use frictionless while gating the dangerous operations.


Key takeaways

Authentication is the foundation, not the whole building. Once you know who is calling — reliably, with tokens you actually verify — you can start deciding what they are allowed to touch. That is authorization, and where the next post picks up.


Further reading