OAuth 2.1, PKCE, OIDC and JWT Validation Pitfalls: A Token Security Guide (2026)

OAuth 2.1, PKCE, OIDC and JWT Validation Pitfalls: A Token Security Guide (2026)

OAuth 2.1, PKCE, OIDC and JWT Validation Pitfalls: A Token Security Guide (2026)

Most token breaches are not cryptographic breaks. They are a library that accepted alg: none, a resource server that never checked aud, an ID token used as an API credential, a redirect URI matched by prefix, or a refresh token that leaked once and could be replayed forever. The protocol was fine; the integration was not.

OAuth 2.1 is the IETF effort to stop teaching those mistakes: it folds a decade of hard-won security guidance into one specification so that the safe path is the only path. Authorization code with PKCE becomes mandatory, implicit and password grants disappear, redirect URIs must match exactly. But the spec only governs the dance between client and authorization server. What happens to the token afterwards, in your API, is where most real failures live.

What this covers: what OAuth 2.1 consolidates and its draft status, the authorization code plus PKCE flow step by step, OpenID Connect and the ID token versus access token confusion, a JWT validation checklist with runnable PyJWT code, refresh token rotation with reuse detection, sender-constrained tokens (DPoP and mTLS), browser storage and the BFF pattern, opaque versus JWT tokens, revocation, and the attacks that still work against correct-looking code.

Context and Background

OAuth 2.0 was published as RFC 6749 in October 2012, with bearer tokens in RFC 6750. It was deliberately a framework: it left many choices to implementers and defined grants (implicit, resource owner password credentials) that made sense in 2012 and look dangerous now. Over the following years the working group patched the gaps with separate documents: PKCE (RFC 7636, 2015), OAuth for native apps (RFC 8252), the JWT best current practice (RFC 8725), mutual-TLS bound tokens (RFC 8705), DPoP (RFC 9449), and the OAuth 2.0 Security Best Current Practice, published as RFC 9700 in January 2025.

The practical problem is that a developer reading RFC 6749 alone learns the unsafe version. OAuth 2.1 is the consolidation. The Internet-Draft draft-ietf-oauth-v2-1 is edited by Dick Hardt, Aaron Parecki and Torsten Lodderstedt. As of my check on the IETF Datatracker, the latest revision is -16, dated early September 2026, still in the OAuth working group as a “WG Document” with the intended RFC status not yet set. That matters: OAuth 2.1 is not yet an RFC, so you cannot claim formal “OAuth 2.1 compliance” the way you can claim RFC conformance. Treat it as the target profile that every serious vendor already implements piecemeal. Verify the current revision on the IETF Datatracker page for draft-ietf-oauth-v2-1 before citing it, because drafts move.

The draft is intended to replace RFC 6749 and RFC 6750 and pulls in the substance of RFC 8252 (native apps), RFC 9700 and the browser-based apps guidance. Its headline changes, per the draft’s own differences section, are:

Area OAuth 2.0 (RFC 6749) OAuth 2.1 draft
Authorization code flow PKCE optional, defined elsewhere PKCE required for all clients that use the code flow
Implicit grant Defined Removed
Resource owner password credentials Defined Removed
Redirect URI matching Left loose, often prefix or wildcard Exact string match (loopback port is the one exception)
Bearer token in query string Permitted by RFC 6750 Not permitted
Refresh tokens for public clients Unspecified Must be sender-constrained or one-time-use (rotation)

I verified the PKCE, implicit, password and redirect-matching rows against the draft text. The query-string and refresh-token rows come from the draft’s section on differences and from RFC 9700, which states the same rules; confirm exact section wording against the current revision when you cite them.

If you operate high-assurance flows such as open banking, OAuth 2.1 is the floor, not the ceiling. Our analysis of the FAPI 2.0 security profile with DPoP, mTLS and pushed authorization requests shows what the stricter tier adds on top.

The Core Flow: Authorization Code with PKCE

Direct answer: In OAuth 2.1 every interactive client uses the authorization code flow with PKCE. The client invents a random code_verifier, sends its SHA-256 hash (code_challenge) with the authorization request, and later proves possession by sending the original verifier when redeeming the code. A stolen code is useless without the verifier.

OAuth 2.1 authorization code flow with PKCE showing code_challenge, code and code_verifier exchange

Figure 1: OAuth 2.1 authorization code flow with PKCE, from the authorization request carrying the code_challenge to the token response after the code_verifier is checked.

The sequence in Figure 1 has seven steps worth stating precisely. First the client generates a code_verifier: a high-entropy string of 43 to 128 characters from the unreserved set A-Z a-z 0-9 - . _ ~ (RFC 7636 section 4.1). The spec recommends 32 random octets, base64url-encoded without padding, which yields 43 characters. Second, it derives code_challenge = BASE64URL(SHA256(code_verifier)) and sends code_challenge_method=S256 on the authorization request along with state, redirect_uri, scope and client_id. Third, the authorization server authenticates the user, obtains consent, and stores the challenge bound to the code it issues. Fourth, the browser is redirected back with code and state. Fifth, the client checks state. Sixth, it POSTs to the token endpoint with grant_type=authorization_code, code, redirect_uri and code_verifier. Seventh, the server hashes the verifier, compares it to the stored challenge, and only then returns tokens.

Why PKCE exists and what it actually stops

PKCE was written for native apps, where a malicious app registered for the same custom URI scheme could intercept the redirect and steal the authorization code. Without a client secret (public clients cannot keep one), the thief could simply redeem it. PKCE makes the code useless without a secret that never left the legitimate client.

The OAuth 2.1 and RFC 9700 insight is that PKCE also covers a second threat: authorization code injection, where an attacker plants a stolen code into a victim’s session. Because the verifier is tied to the session that started the flow, an injected code fails the check. That is why PKCE is now required even for confidential clients that do have a secret; it defends a different thing than the secret does. It does not replace state for CSRF in every deployment, although a server that enforces PKCE and a client that uses the challenge as a session-bound nonce gets much of the same protection.

The plain method (verifier equals challenge) exists in RFC 7636 only for constrained clients that cannot hash. In practice, use S256 always and configure the authorization server to reject plain. A downgrade where an attacker rewrites S256 to plain in a front-channel request is a real concern when servers accept both.

Removing the grants that were unfixable

The implicit grant returned the access token directly in the URL fragment. That leaks tokens through browser history, referrers, and malicious scripts, and it offers no way to bind the token to the requester. Anything the implicit flow did in a single-page app, the code flow with PKCE does with a token request over CORS. The resource owner password credentials grant asked the client to collect the user’s password, which defeats the purpose of delegation, blocks MFA and passkeys, and trains users to type credentials into third-party forms. Neither has a safe configuration, so 2.1 deletes them rather than patching.

Exact redirect URI matching

OAuth 2.0 let servers match redirect URIs loosely. Registering https://app.example.com/ and accepting https://app.example.com/anything seemed convenient and enabled open-redirect chains: the attacker supplies a redirect on a trusted origin that bounces the code to their server. OAuth 2.1 requires a simple string comparison against a pre-registered value. The one allowance is native loopback redirects (http://127.0.0.1:PORT/cb), where the port is ephemeral and may vary (RFC 8252 section 7.3). Wildcards in registered URIs are a smell; if you need many environments, register each one.

The same discipline applies to workload credentials: short-lived, audience-bound, and verified at every hop. The Kubernetes ecosystem applies it to machine identities, as described in our guide to SPIFFE and SPIRE workload identity for zero trust.

The authorization request and response in practice

Here is a minimal, correct client-side generation of the verifier and challenge, which you can run as is:

import base64, hashlib, secrets, urllib.parse

def b64url(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).rstrip(b"=").decode()

verifier = b64url(secrets.token_bytes(32))          # 43 chars
challenge = b64url(hashlib.sha256(verifier.encode()).digest())
state = b64url(secrets.token_bytes(16))

params = {
    "response_type": "code",
    "client_id": "demo-spa",
    "redirect_uri": "https://app.example.com/callback",
    "scope": "openid profile api:read",
    "state": state,
    "code_challenge": challenge,
    "code_challenge_method": "S256",
}
print("https://idp.example.com/authorize?" + urllib.parse.urlencode(params))
# Persist (state, verifier) server-side or in a session cookie.
# Never put the verifier in the URL.

Note what is absent: no response_type=token, no wildcard redirect, no secret in the front channel. When the redirect returns, compare state in constant time to the stored value, reject if missing, and redeem the code exactly once. Authorization codes should be single-use and short-lived (RFC 6749 recommends a maximum lifetime of ten minutes, and a minute or less is common); servers should revoke tokens already issued if a code is redeemed twice.

OpenID Connect: Authentication Is Not Authorization

Direct answer: OpenID Connect (OIDC) is an identity layer on top of OAuth. The ID token tells the client who authenticated and how; the access token tells an API what the client may do. They are different tokens with different audiences, and using one in place of the other is among the most common OAuth integration bugs.

OAuth alone says nothing about who the user is. Before OIDC, apps called a /userinfo-style endpoint with an access token and inferred identity, and every provider did it differently. OpenID Connect Core 1.0 standardises it. When the scope includes openid, the token response carries an id_token, a signed JWT whose audience (aud) is the client’s own client_id. Its claims include iss, sub, aud, exp, iat, and, when you sent one, nonce; optional claims include auth_time, acr and amr, which describe how strongly the user authenticated.

ID token versus access token

Property ID token Access token
Intended reader The client application The resource server (API)
aud claim The client’s client_id The API identifier
Purpose Prove an authentication event occurred Authorize calls to an API
Format Always a JWT Opaque or JWT (RFC 9068 profile)
Should be sent to APIs No Yes
Lifetime guidance Short, consumed once at login Minutes

The mistake is a backend that accepts the ID token as a bearer credential because “it validates fine”. It does validate: same issuer, valid signature, unexpired. But it was never issued for that API, and its claims were minted for the client. Any client that can obtain an ID token for a user (including a malicious app the user logged into elsewhere through the same provider) could replay it at your API. The defence is an audience check on the API against its own identifier, plus the JWT access token profile of RFC 9068, which adds the typ header value at+jwt so a resource server can reject ID tokens by type.

Nonce, at_hash and the hybrid flows

In the code flow the ID token arrives over the back channel and is bound to the TLS session with the token endpoint, which already gives strong guarantees. The nonce parameter still matters: the client sends a random value in the authorization request, the server echoes it inside the ID token, and the client confirms it matches the session. This blocks replay of an ID token captured from another login. Where hybrid flows return an ID token via the front channel, at_hash and c_hash bind the token to the access token and code. For a new application, avoid hybrid flows entirely; the code flow with PKCE and a validated nonce covers the use cases.

The client must validate the ID token too

Clients routinely skip this because the token came over TLS from a trusted endpoint. OIDC Core still requires checking the signature (OIDC Core lets you skip it only when the token arrives directly from the token endpoint over TLS, and verifying anyway is cheap), iss equals the discovered issuer, aud contains your client_id, exp has not passed, and nonce matches. If aud contains multiple values, azp must equal your client id. Use a maintained OIDC client library rather than writing this; the discovery document at /.well-known/openid-configuration also tells you the issuer string, the JWKS URI and the supported algorithms.

Deeper Analysis: JWT Validation Pitfalls

Direct answer: Valid JWT validation means pinning the algorithm and key from your own configuration, verifying the signature before trusting any claim, and then checking iss, aud, exp, nbf and token type. Libraries decode happily without doing any of that unless you ask. RFC 8725 lists the known failure patterns.

JWT validation pitfalls checklist showing algorithm pinning, signature, issuer, audience and expiry checks in order

Figure 2: JWT validation order for a resource server: header sanity, pinned algorithm and key, signature, then registered claims and token type, with reject paths at each step.

A JWT is three base64url segments: header, payload, signature. The header is attacker-controlled input. That one fact explains most historic JWT vulnerabilities.

The algorithm confusion family

alg: none. RFC 7519 allows unsecured JWTs with "alg":"none" and an empty signature. Several libraries at one time accepted them if the caller passed no explicit algorithm list. An attacker edits the payload to "admin": true, sets alg to none, strips the signature, and the library returns the claims as verified. Mitigation: always pass an explicit allow-list of algorithms and never include none.

RS256 to HS256 key confusion. If your server verifies with the public RSA key and the library picks the algorithm from the token header, an attacker sets alg: HS256 and signs the token using the public key text as the HMAC secret. The verifier then runs HMAC with that same public key as secret and validates. The public key is public by definition, so the attacker can forge anything. Mitigation: bind the algorithm to the key type. An RSA key is only ever used with RSA algorithms. Modern PyJWT and similar libraries refuse to use a PEM-formatted public key as an HMAC secret, but only if you pass the algorithms list and keep key objects typed.

Weak HMAC secrets. HS256 is acceptable only with a random secret of at least 256 bits that never leaves the issuer and verifier. Short human-memorable secrets can be brute-forced offline from any captured token. In multi-service architectures HS256 forces every verifier to hold the signing secret, so any verifier compromise lets the attacker mint tokens. Prefer asymmetric signatures (RS256, ES256, EdDSA) for anything distributed.

Key lookup pitfalls: kid, jku, x5u and jwk

The header can carry kid (key id), jku (JWK Set URL), jwk (an embedded public key), and x5u or x5c for certificates. Each is an injection point:

  • jku and x5u SSRF and key substitution. A verifier that fetches the URL given in the token will happily load an attacker’s key set from an attacker’s server and validate the forged token against it. Ignore these headers; take the JWKS URL from your trusted issuer configuration only.
  • Embedded jwk. A token that carries its own verification key proves nothing. Never trust it.
  • kid injection. If the kid is used to build a file path or SQL query to find the key (SELECT key FROM keys WHERE id='...'), it becomes a path traversal or SQL injection vector. Treat kid as an opaque lookup key into an in-memory map built from the issuer’s JWKS, and fail closed if absent.

Registered claims: what to check and in what order

After the signature verifies, check claims. A signed token with the wrong aud is still a rejection:

  1. iss: exact string equality with the expected issuer. Do not use startswith or substring checks. A multi-tenant issuer where the tenant is part of the URL is the classic place for a loose check to let one tenant’s token pass for another (validate the full issuer, per tenant).
  2. aud: must contain your API’s identifier. Skipping this lets a token minted for service A be replayed at service B, the “confused deputy” at API scale.
  3. exp: reject if now is at or after expiry. Allow small clock skew (30 to 60 seconds is typical), not minutes.
  4. nbf and iat: reject tokens not yet valid; sanity-check iat is not far in the future.
  5. typ/token type: for access tokens expect at+jwt (RFC 9068) so ID tokens are refused.
  6. scope or scp and any authorization claims: enforce per endpoint. A valid signature authenticates the token, not the operation.

Make the required claims explicit. By default many libraries only validate exp if it is present, so a token without exp never expires. Require its presence.

Worked verification code with PyJWT

The following verifier pins the algorithm, fetches keys from a trusted JWKS URL with caching, requires the registered claims, and checks token type. It uses the PyJWT API (PyJWKClient, jwt.decode):

import jwt
from jwt import PyJWKClient, InvalidTokenError

ISSUER = "https://idp.example.com"
AUDIENCE = "https://api.example.com"
JWKS_URL = ISSUER + "/.well-known/jwks.json"   # from trusted config, not from the token
ALLOWED_ALGS = ["RS256", "ES256"]               # never include "none"; never mix HS* with RS*

jwks = PyJWKClient(JWKS_URL, cache_keys=True, lifespan=300)  # cache JWKS, 5 minutes

def verify_access_token(token: str) -> dict:
    header = jwt.get_unverified_header(token)
    if header.get("alg") not in ALLOWED_ALGS:
        raise InvalidTokenError("algorithm not allowed")
    if header.get("typ", "").lower() != "at+jwt":   # RFC 9068; rejects ID tokens
        raise InvalidTokenError("unexpected token type")
    # Looks up the key by kid in the issuer JWKS; ignores jku/jwk/x5u in the token
    signing_key = jwks.get_signing_key_from_jwt(token).key
    claims = jwt.decode(
        token,
        signing_key,
        algorithms=ALLOWED_ALGS,
        audience=AUDIENCE,
        issuer=ISSUER,
        leeway=30,
        options={"require": ["exp", "iat", "iss", "aud", "sub"],
                 "verify_signature": True, "verify_exp": True,
                 "verify_nbf": True, "verify_aud": True, "verify_iss": True},
    )
    return claims

Common mistakes this code avoids, each seen in real code reviews: calling jwt.decode(token, options={"verify_signature": False}) and then reading claims “just to route”; omitting algorithms; passing audience but forgetting issuer; trusting claims["scope"] from get_unverified_claims; swallowing InvalidTokenError and returning an empty user rather than a 401. A strict policy is to ban verify_signature: False in lint rules outside of tests. Strict typ matching is stricter than some issuers can satisfy; if your identity provider does not yet emit at+jwt, document the exception rather than silently allowing ID tokens.

JWKS caching and key rotation

Fetching the JWKS on every request adds latency and creates an availability dependency on the identity provider. Caching it indefinitely breaks when the issuer rotates keys. The workable pattern: cache the set for minutes to an hour honouring Cache-Control, and on an unknown kid perform one refresh, rate-limited (for example at most once per minute), then fail. The rate limit matters, because an attacker sending random kid values would otherwise turn your API into a DDoS amplifier against your identity provider. Issuers should publish a new key before signing with it and retire the old key only after the longest-lived token signed by it has expired; verifiers must tolerate multiple concurrent keys.

Illustrative numbers: with 15-minute access tokens and a 1-hour JWKS cache, an issuer that rotates keys needs to publish the new key at least an hour before first use, and keep the old key for at least 15 minutes after the last token signed with it. If either rule is broken, you see sporadic 401s clustered around rotation windows, usually blamed on “flaky auth”.

Refresh Token Rotation, Sender Constraint and Revocation

Direct answer: Refresh tokens are long-lived bearer credentials, so they need either sender-constraint (DPoP or mTLS) or rotation with reuse detection. With rotation, every refresh returns a new refresh token and invalidates the old one; if an old one reappears, the server revokes the whole token family because someone has a copy.

Refresh token rotation with reuse detection showing token family revocation when an old refresh token is replayed

Figure 3: Refresh token rotation and reuse detection. A legitimate refresh advances the chain; replay of a superseded token revokes the whole family and forces re-authentication.

How refresh token rotation works

Access tokens are kept short (five to fifteen minutes is common) so that theft has a small blast radius. Refresh tokens let the client get new access tokens without bothering the user, but that makes them the more valuable target. A public client such as a single-page app or mobile app cannot authenticate to the token endpoint with a secret, so a stolen refresh token is as good as the real thing.

Rotation changes the economics. At each refresh the authorization server issues a new access token and a new refresh token, and marks the presented one as used. The server stores each token’s family id (the chain that began at the original login). Suppose a thief copies refresh token R1 from a log file. Alice’s client refreshes first, receiving R2 and invalidating R1. When the thief later presents R1, the server sees a superseded token reused. It cannot tell which party is legitimate, so it revokes the entire family, R2 included. Alice is logged out; the thief is locked out too. If the thief refreshes first, then Alice’s R1 is the replay and the outcome is the same. Either way the stolen credential’s useful lifetime is bounded by the next legitimate refresh.

RFC 9700 and the OAuth 2.1 draft require one of two protections for public-client refresh tokens: sender-constraining or rotation. They are not mutually exclusive, and high-risk deployments use both.

Rotation gotchas

Rotation introduces a real failure mode: the race. A mobile app on a flaky network sends the refresh request, the server rotates and returns R2, but the response is lost. The client retries with R1 and trips reuse detection, logging out an honest user. Mitigations:

  • A short grace window (for example ten to thirty seconds) during which the previous token is accepted once more and returns the same successor. Okta and Auth0 document similar leeway settings; check your provider’s actual parameter names rather than assuming.
  • Client-side serialisation: exactly one in-flight refresh per session, using a lock across tabs (a Web Lock or a BroadcastChannel leader election in browsers).
  • Persisting the new token atomically before using the new access token.

Also set an absolute lifetime on the family (for instance 30 days) alongside an idle timeout (for instance 7 days). Without an absolute cap, a rotated chain can live forever, and a compromise that stays one step ahead of the user never expires.

Sender-constrained tokens: DPoP and mTLS

A bearer token is “whoever holds it, can use it”. Sender-constraining binds the token to a key the client holds, so a stolen token without the key is dead weight.

DPoP (RFC 9449, Demonstrating Proof of Possession) works at the application layer. The client creates a key pair, and for each request to the token endpoint or resource server it attaches a DPoP header: a JWT with typ: dpop+jwt, the public key in the header’s jwk, and claims htm (HTTP method), htu (target URI), iat, a unique jti, and, for resource requests, ath (hash of the access token). The authorization server embeds the key’s thumbprint in the access token as cnf.jkt. The resource server then checks both that the access token is valid and that the DPoP proof was signed by the key matching cnf.jkt, for this method and URI, recently, and not seen before. A stolen token replayed from another machine fails because the thief lacks the private key; a captured proof is useless against a different URL or after its short freshness window. Note the irony: the jwk embedded in a DPoP proof is correct by design, because the verifier compares it to the thumbprint it already trusts. It is the opposite of the embedded-key pitfall above.

mTLS-bound tokens (RFC 8705) use the TLS client certificate. The access token carries cnf.x5t#S256, the SHA-256 thumbprint of the client certificate, and the resource server confirms the TLS connection presented that certificate. It is strong and has no per-request signing overhead, but it requires certificate provisioning, termination-aware infrastructure (load balancers must pass the certificate information to the app), and does not suit browsers well. It is common between servers and in regulated sectors.

Property Bearer DPoP (RFC 9449) mTLS (RFC 8705)
Stolen token reusable elsewhere Yes No, needs private key No, needs certificate key
Layer None HTTP, application TLS
Browser friendly Yes Yes (WebCrypto non-extractable keys) Poor
Infra impact None Replay cache for jti, nonce handling PKI, cert-aware proxies
Typical use Low-risk APIs SPAs, mobile, public clients Server-to-server, banking

Neither mechanism helps if the attacker runs code inside the legitimate client (XSS can still ask the browser to sign proofs while the page is open). Sender-constraint limits exfiltration, not on-device abuse. If your client already authenticates people with phishing-resistant credentials, the same reasoning applies to the login step; see our deep dive on passkeys and FIDO2 for payment authentication under PSD2.

Revocation and logout

Revocation (RFC 7009) lets a client tell the authorization server to invalidate a refresh or access token via a POST to the revocation endpoint. Revoking a refresh token should revoke its associated access tokens where the server can, but a self-contained JWT access token cannot be recalled: resource servers verify it offline, so it stays valid until exp. Hence the guidance to keep JWT access tokens short. If you need immediate cut-off (employee offboarding, account takeover), choose one of: very short lifetimes plus revoking the refresh token, a deny-list keyed by jti or sid that resource servers consult, or opaque tokens with introspection.

Logout needs care. Deleting the cookie in the browser ends the local session only. For federated logout, OIDC defines RP-Initiated Logout, front-channel and back-channel logout; back-channel logout (a signed logout token POSTed to the client) is the reliable one. Whatever you build, a logged-out user should have their refresh token family revoked at the authorization server; otherwise “logout” is cosmetic.

Browsers, the BFF Pattern and Token Storage

Direct answer: For browser apps the safest design is a Backend for Frontend (BFF): a small server-side component performs the OAuth flow, keeps the tokens, and gives the browser only an HttpOnly, Secure, SameSite session cookie. JavaScript never touches an access or refresh token, so XSS cannot steal one.

Backend for Frontend pattern keeping OAuth 2.1 tokens server side with an HttpOnly session cookie to the browser

Figure 4: The BFF pattern. The browser holds only a session cookie; the BFF holds tokens, calls APIs with the access token, and refreshes it server side.

Where do you store tokens in a browser SPA? Every answer inside the browser has a flaw. localStorage and sessionStorage are readable by any script in the origin, so a single XSS (or one compromised npm dependency) exfiltrates everything. In-memory storage is better but lost on reload and still reachable from injected script. Cookies marked HttpOnly cannot be read by script and are the right container for a session reference, but cookies attract CSRF, solved with SameSite=Lax or Strict plus an anti-CSRF token or custom-header check. Service-worker tricks improve things marginally but complicate everything.

The IETF’s “OAuth 2.0 for Browser-Based Apps” best current practice, which the OAuth 2.1 draft absorbs, ranks the options. The most secure is the BFF: the browser app talks to its own origin, the BFF is a confidential client (it can hold a client secret or use private-key JWT authentication), and it proxies API calls, attaching the access token. The second option is a “token-mediating backend” that hands the browser short-lived access tokens while keeping the refresh token server side. The weakest is a browser-only client that holds tokens itself; if you must, use DPoP with a non-extractable WebCrypto key, rotate refresh tokens, and keep access tokens in memory only.

The cost of BFF is operational: a stateful (or encrypted-cookie) session layer, an extra network hop, and the requirement that APIs be reachable via the BFF origin. For most products with a first-party SPA and API, that cost is worth paying, because it replaces an entire class of client-side token theft with a conventional server session problem that teams already know how to defend.

Opaque Tokens, JWT Access Tokens and Introspection

The format of the access token is a design decision with security consequences, not a style preference. OAuth does not mandate one. Two families exist.

Opaque (reference) tokens are random strings with no structure. The resource server must ask the authorization server what they mean, through token introspection (RFC 7662): a POST of the token to the introspection endpoint, authenticated by the resource server, returning active: true|false plus metadata such as scope, client_id, sub, exp and aud. Because the authorization server is consulted, revocation is immediate and no claims leak to the client. The costs are an extra network call per request (mitigated with a short cache, which re-introduces a small revocation delay) and a hard runtime dependency on the authorization server.

JWT access tokens are self-contained and verified offline using the issuer’s public keys, so they scale to many resource servers with no central lookup. RFC 9068 defines an interoperable profile: typ: at+jwt, required claims iss, exp, aud, sub, client_id, iat, jti, and a scope claim. The costs are the validation pitfalls above, inability to revoke before expiry, and exposure of claims to anyone holding the token, because a JWT is signed, not encrypted. Putting an email address or role list in a bearer JWT that passes through browsers and logs is a privacy leak (JWE encryption exists but is rarely used for access tokens).

Dimension Opaque + introspection JWT (RFC 9068)
Revocation Immediate Only at exp, or via deny-list
Per-request latency Network call, or cache Local signature check
AS availability dependency Hard (or cache) Soft (JWKS cache only)
Claims visible to client No Yes, unless encrypted
Validation pitfalls Few Many (this article)
Best fit High-risk, few APIs, strict revocation Many services, edge validation

A common hybrid is JWT at the edge gateway for cheap first-pass verification and introspection for sensitive operations such as payments or admin actions. Another is to issue opaque tokens to external clients and have the gateway exchange them for internal JWTs (token exchange, RFC 8693) with narrower audience and scope, so that internal services never see the external token. Narrow audiences also support RFC 8707 resource indicators: the client states which API it wants the token for, and the server sets aud accordingly, so a token for the invoices API cannot be spent at the payroll API.

Attacks That Still Work Against Correct-Looking Code

Direct answer: Even with PKCE and exact redirect matching, applications are attacked through mix-up attacks on clients that talk to several authorization servers, CSRF on the callback when state is not verified, open redirects on the client’s own domain, and token leakage through logs, referrers and error pages.

Mix-up attacks

A client that supports multiple identity providers can be tricked into sending a code or token obtained from the honest provider to the attacker’s provider. The attacker, posing as one authorization server, redirects the user to the honest one, then makes the client believe the response came from the malicious one; the client then sends the code (and, without PKCE tying it to the right issuer, the verifier) to the wrong token endpoint. The fix is RFC 9207: the authorization server includes an iss parameter in the authorization response, and the client verifies that it matches the server it started the flow with. Clients should also keep per-flow state that records the issuer, the token endpoint and the redirect URI, rather than relying on global configuration. If your client talks to only one authorization server, this risk is much smaller; if it is a multi-tenant SaaS integration hub, it is a priority.

CSRF on the callback

If the client’s redirect endpoint accepts any code without checking a session-bound state, an attacker can start a flow with their own account, capture the redirect, and trick the victim into loading it. The victim’s session is now linked to the attacker’s identity (“login CSRF”), and anything the victim then saves goes to an account the attacker controls. The remedy is to generate an unguessable state (or use PKCE verifier binding) per request, store it where the attacker cannot set it (a server session or a SameSite cookie), and reject callbacks that do not match.

Open redirects and the referrer leak

Exact redirect matching only helps if the registered redirect URI itself is safe. A client page that echoes a ?next= parameter into a redirect lets the attacker route a response containing code through an additional hop. Keep the callback page minimal: exchange the code, then redirect to a fixed internal location. Add Referrer-Policy: no-referrer (or strict-origin) to callback pages so the code does not leak in the Referer header to third-party resources, and make sure analytics scripts do not load on that route. Authorization codes are single use, so a leaked but already-redeemed code is harmless, which is another reason redemption should be immediate.

Scope and audience confusion

Scopes express what the client may do, not who the user is. APIs that authorize on the basis of a user’s sub without verifying the scope or the client_id treat any valid token as a master key. Conversely, APIs that check scope but not audience accept tokens for sibling services. Both failures are authorization-layer errors that no amount of signature verification fixes. Test them: for every endpoint, write a negative test that presents a perfectly valid token for the wrong audience and the wrong scope, and assert a 401 or 403.

Trade-offs, Gotchas, and What Goes Wrong

OAuth 2.1 is a draft. Vendors implement the requirements individually, and documentation often says “OAuth 2.1 compliant” loosely. Pin your requirements to the specific RFCs (7636, 9700, 9449, 8725, 9068) and test the behaviour, rather than trusting a label. Because the draft can still change, avoid hard-coding dependencies on section numbers.

PKCE does not make a public client confidential. It prevents code interception; it does nothing about a stolen refresh token or access token. Teams sometimes tick the “uses PKCE” box and consider the client hardened. You still need rotation or sender-constraint, short lifetimes and sound storage.

Short lifetimes shift cost. A five-minute access token means roughly twelve refreshes per hour of active use per session. At 10,000 concurrent sessions that is on the order of 120,000 refresh requests per hour, or about 33 per second, illustrative arithmetic only. That is trivial for the token endpoint but non-trivial if each refresh hits a database to write a rotated token. Size the token store accordingly, and use jittered refresh timing (refresh at around 80 percent of lifetime with random spread) so that all clients do not refresh at the same instant after an outage.

Clock skew bites. Mobile devices with wrong clocks fail nbf/exp checks and DPoP iat checks. Allow bounded skew, support the server-provided DPoP-Nonce mechanism, and log skew in rejections so you can distinguish attack from drift.

Cache poisoning of JWKS and discovery. If you fetch /.well-known/openid-configuration and JWKS over plain HTTP or follow redirects to other hosts, you trust whatever the network gives you. Require HTTPS, pin the expected issuer, and refuse issuer values in the document that differ from the URL you fetched it from.

Logging tokens. Reverse proxies, APM tools and exception trackers capture Authorization headers and query strings. A token in a log file is a token in every system that ingests the logs. Redact Authorization, DPoP, cookies and any code= or access_token= strings before they leave the process.

Overloaded claims. Putting authorization state (roles, entitlements) in long-lived tokens makes permission changes slow to take effect. Keep the token small, and fetch fine-grained entitlements from a policy service on sensitive requests.

Practical Recommendations

Start from the threat you can actually name. If you build a browser product with its own API, adopt the BFF and keep tokens out of JavaScript; that single decision removes the largest class of real-world theft. For mobile and desktop apps, use the system browser with the code flow, PKCE S256, claimed HTTPS redirect URIs where the platform supports them, and secure OS storage (Keychain, Keystore). For services calling services, prefer mTLS-bound tokens or short-lived workload credentials over shared client secrets; our guides on Kubernetes pod certificates versus SPIFFE and cert-manager cover that layer.

On the resource server, write one shared validation module and forbid ad hoc jwt.decode calls. Give it an explicit algorithm allow-list, the issuer and audience from configuration, required claims, bounded leeway and a JWKS cache with a rate-limited refresh on unknown kid. Add negative tests with forged tokens: alg: none, an HS256 token signed with your public key, an expired token, a token with the wrong aud, an ID token presented as an access token, and a token with a hostile jku. These six tests catch most real defects.

Use the following checklist as a review gate:

  • [ ] Authorization code flow with PKCE S256 for every client; implicit and password grants disabled at the server.
  • [ ] Redirect URIs registered exactly; no wildcards; state and nonce verified; iss response parameter checked for multi-issuer clients.
  • [ ] Access tokens last minutes; refresh tokens rotated with reuse detection and an absolute lifetime, or sender-constrained.
  • [ ] Resource server pins algorithms, issuer and audience; requires exp; checks token type; ignores jku, jwk and x5u.
  • [ ] JWKS cached with rate-limited refresh; key rotation tested in staging.
  • [ ] Browser apps use a BFF or, at minimum, in-memory tokens plus DPoP.
  • [ ] Revocation and logout paths tested end to end, including refresh family revocation.
  • [ ] Tokens and codes redacted from logs and kept out of URLs and referrers.
  • [ ] Negative tests for audience, scope, token type and forged-header cases run in CI.

Frequently Asked Questions

Is OAuth 2.1 an official standard yet?

Not as of this writing. draft-ietf-oauth-v2-1 is an active Internet-Draft in the IETF OAuth working group, at revision 16 when I checked the Datatracker in autumn 2026, with no RFC number assigned. It is meant to replace RFC 6749 and RFC 6750 and to fold in the guidance from RFC 9700 and the native-app and browser-app best practices. Until it is published, cite the individual RFCs for compliance and treat OAuth 2.1 as the direction of travel that mainstream providers already follow.

Do I need PKCE if my client has a client secret?

Yes. A secret and PKCE defend against different things. The secret authenticates the client to the token endpoint; PKCE binds the authorization code to the particular browser session that requested it, which stops authorization code injection and interception. RFC 9700 and the OAuth 2.1 draft therefore require PKCE for all clients using the code flow, confidential or public. It costs one hash and one random string per login, so there is little reason to skip it, and servers should reject code requests that omit it.

What is the difference between an ID token and an access token?

An ID token is an OpenID Connect artifact for the client: a signed JWT stating who authenticated, when and how, with the client’s client_id as audience. An access token is for the API: it states what the client may do and has the API as its audience. Never send ID tokens to APIs and never read user identity from an access token you do not understand. Reject ID tokens at your API with an audience check and, with RFC 9068, a typ of at+jwt.

How should I validate a JWT safely?

Pin the accepted signature algorithms in your own configuration, resolve the key from a trusted issuer JWKS by kid, and verify the signature before reading any claim. Then require and check iss, aud, exp, nbf and token type, with small clock leeway, and ignore jku, jwk and x5u headers. Follow RFC 8725. Never use a decode-without-verify helper for authorization decisions, and add negative tests for alg: none, key confusion and wrong-audience tokens.

Should I store tokens in localStorage?

Avoid it. Any script running on your origin, including a compromised dependency or an XSS payload, can read localStorage and exfiltrate long-lived tokens. The strongest pattern is a Backend for Frontend that keeps tokens server side and gives the browser only an HttpOnly, Secure, SameSite cookie. If you must hold tokens in the browser, keep access tokens in memory, rotate refresh tokens, and use DPoP with a non-extractable key so a copied token cannot be replayed elsewhere.

What is refresh token rotation and does it break users?

Rotation issues a new refresh token on every use and invalidates the old one; replaying an old token signals theft, and the server revokes the whole family. It can log out honest users when a refresh response is lost and the client retries with the old token. Reduce this with a short grace window, one in-flight refresh per session across tabs, and atomic persistence of the new token. Pair it with an absolute family lifetime.

Further Reading

By Riju — about

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *