OAuth grants, token types, and JWT validation
What it is
OAuth 2.0 is a delegation protocol: it lets a user grant an application access to a resource without giving it their password. OIDC is a thin identity layer on top of it, adding an ID token that says who the user is.
The grants that are alive:
| Grant | For | Notes |
|---|---|---|
| Authorization Code + PKCE | Every user-facing app | The only correct answer for web, mobile and SPA |
| Client Credentials | Machine to machine | No user; the app is the principal |
| Device Authorization | TVs, CLIs, input-constrained devices | User authorises on a second device |
| Refresh Token | Renewing access without re-auth | With rotation and reuse detection |
| Token Exchange (RFC 8693) | On-behalf-of chains | A service acting for a user, downstream |
The two that are dead:
Implicit grant returned the access token in the URL fragment, so it appeared in browser
history, in Referer headers and in server logs, and it had no way to authenticate the
client. It existed because browsers could not do cross-origin POST, and CORS removed that
constraint. OAuth 2.1 removes it.
Resource Owner Password Credentials (ROPC) has the application collect the user's password directly, which defeats the entire purpose of a delegation protocol, makes MFA and federation impossible, and trains users to type their password into third-party forms. OAuth 2.1 removes it too.
The three token types, and what each is not for:
ACCESS TOKEN for calling APIs. The API validates it.
NOT for identifying the user to your own frontend.
Short-lived (5-15 min). Opaque OR a JWT.
ID TOKEN proof of authentication, for the CLIENT.
NOT for calling APIs. Ever.
Contains user claims. Audience is the CLIENT ID.
REFRESH TOKEN for getting a new access token. Sent only to the
token endpoint.
NOT sent to APIs. Long-lived, and must be rotated.
What this is confused with: sending an ID token to an API. It is the single most common OAuth implementation error. The ID token's audience is the client, it is not scoped, and an API that accepts it is accepting a token minted for a different party with no authorisation information in it.
The problem it solves
Without delegation, an application that needs your data needs your password, which means it can do anything you can, forever, and revoking it means changing your password.
The specific properties OAuth provides, and each maps to a design decision:
Scoped access: the token grants `read:profile`, not everything
Time-bounded: an access token expires in minutes
Revocable: without changing the user's password
Auditable: the authorisation server records who granted what
Delegatable: a service can act on a user's behalf downstream
The problem the token type distinction solves is subtler and is where implementations go wrong. Three tokens exist because three different parties need three different assurances:
The API needs to know: may this request do this thing? -> access token
The client needs to know: who logged in, and did they really? -> ID token
The token endpoint needs: may I mint a new access token? -> refresh token
Using one token for all three collapses those questions, and the failure is that the API ends up making authorisation decisions from an identity assertion with no scope.
Mechanics
Authorization Code with PKCE
1. Client generates:
code_verifier = 43-128 random chars
code_challenge = BASE64URL(SHA256(code_verifier))
2. Redirect to the authorization server:
GET /authorize?response_type=code
&client_id=abc
&redirect_uri=https://app.example/callback
&scope=openid profile read:orders
&state=<csrf-token>
&code_challenge=<challenge>
&code_challenge_method=S256
3. User authenticates and consents.
4. Redirect back:
https://app.example/callback?code=<auth-code>&state=<csrf-token>
-> VERIFY state matches. This is the CSRF defence.
5. Exchange the code, over the BACK CHANNEL:
POST /token
grant_type=authorization_code
code=<auth-code>
redirect_uri=https://app.example/callback
client_id=abc
code_verifier=<verifier> <- proves this is the same client
6. Response:
{ access_token, id_token, refresh_token, expires_in, token_type }
PKCE closes the authorization-code interception attack: on mobile, a malicious app registering the same custom URL scheme could receive the code, and without the verifier it cannot exchange it. PKCE is now recommended for confidential clients too, because it also protects against code injection where an attacker substitutes their own code.
state and PKCE are different defences and both are required. state prevents CSRF (an
attacker initiating a flow that completes in the victim's session); PKCE prevents code
interception. Neither substitutes for the other.
JWT validation, with every check justified
public Claims validate(String token) {
// 1. Parse WITHOUT trusting anything yet.
var parts = token.split("\\.");
if (parts.length != 3) throw new InvalidToken("not a JWS");
var header = parseHeader(parts[0]);
// 2. ALGORITHM: accept only what YOU expect. Never read alg from the token
// and use it to pick a verifier: that is the "alg: none" and the
// RS256->HS256 confusion attack, where the attacker signs with your
// PUBLIC key as an HMAC secret.
if (!ALLOWED_ALGS.contains(header.alg)) throw new InvalidToken("alg");
// 3. KEY: resolve by kid from a CACHED JWKS. Cache with a TTL, and
// refetch on an unknown kid with a RATE LIMIT, or an attacker can
// make you hammer the IdP by sending random kids.
var key = jwks.get(header.kid); // rate-limited refresh inside
// 4. SIGNATURE.
if (!verify(parts, key, header.alg)) throw new InvalidToken("signature");
var claims = parseClaims(parts[1]);
// 5. ISSUER: must be exactly your IdP. Otherwise any IdP's token
// with a matching kid could pass.
if (!EXPECTED_ISSUER.equals(claims.iss)) throw new InvalidToken("iss");
// 6. AUDIENCE: must include THIS API. Without it, a token minted for
// a DIFFERENT service in the same IdP is accepted here.
if (!claims.aud.contains(MY_AUDIENCE)) throw new InvalidToken("aud");
// 7. EXPIRY, with a small clock skew allowance.
if (claims.exp < now() - SKEW) throw new InvalidToken("expired");
if (claims.nbf != null && claims.nbf > now() + SKEW) throw new InvalidToken("nbf");
// 8. TOKEN TYPE: reject an ID token used as an access token.
// RFC 9068 access tokens carry typ: "at+jwt".
if ("JWT".equals(header.typ) && claims.containsKey("nonce"))
throw new InvalidToken("this looks like an ID token");
// 9. REVOCATION, if you need it: a denylist of jti, or short expiry.
if (revoked.contains(claims.jti)) throw new InvalidToken("revoked");
return claims;
}
Steps 2 and 6 are the ones that get skipped and they are the two that matter most.
Algorithm confusion: a library that reads alg from the token and selects a verifier
accordingly can be given {"alg":"HS256"} on a token signed with the public RSA key as an
HMAC secret. The public key is public, so the attacker can forge any token. Pin the algorithm
in your configuration, never take it from the token.
Missing audience validation: an IdP issuing tokens for ten services signs them all with the
same key. A token minted for the low-value analytics service passes signature and issuer checks
at the payments service. aud is the only thing separating them, and it is routinely
omitted because the token validates without it.
Refresh token rotation with reuse detection
1. Client presents refresh token RT1.
2. Server issues a new access token AND a new refresh token RT2,
and marks RT1 as USED, recording that RT1 -> RT2.
3. Client presents RT2 next time. Normal.
4. If RT1 is presented AGAIN:
-> RT1 was stolen, OR the legitimate client did not receive RT2.
-> Either way: REVOKE THE ENTIRE TOKEN FAMILY (RT1, RT2, and every
descendant), forcing re-authentication.
def refresh(presented_token):
rt = store.get(presented_token)
if rt is None:
raise InvalidGrant()
if rt.used:
# REUSE DETECTED. We cannot tell the thief from the victim,
# so revoke everything in the family and force re-auth.
store.revoke_family(rt.family_id)
alert("refresh token reuse", family=rt.family_id, user=rt.user_id)
raise InvalidGrant()
rt.used = True
new_rt = store.issue(user=rt.user_id, family_id=rt.family_id) # SAME family
return AccessToken(rt.user_id), new_rt
Reuse detection is what makes a long-lived refresh token safe. Without it, a stolen refresh token works until it expires, and nobody notices. With it, the moment either party uses a consumed token the family dies, so a theft produces a detectable event and a bounded window.
The false-positive case is real: a client that receives RT2 but fails to persist it (a crash, a network failure on the response) will retry with RT1 and be logged out. A short grace period (accept a used token for a few seconds, returning the same RT2) reduces that, at the cost of a small window where a stolen token still works.
DPoP and mTLS-bound tokens
A bearer token is exactly that: whoever bears it may use it. Stealing one is sufficient. Sender-constrained tokens bind the token to a key the client holds.
mTLS-bound (RFC 8705): the access token records a thumbprint of the client's TLS certificate, and the API checks that the presenting connection used that certificate.
cnf: { "x5t#S256": "<cert-thumbprint>" }
Strong, and it requires TLS client certificates end to end, which terminating proxies and CDNs make awkward. Right for service-to-service inside infrastructure you control.
DPoP (RFC 9449): the client holds a key pair and sends a signed proof per request.
DPoP: <JWS with { htm: "POST", htu: "https://api/orders", iat, jti }
signed by the client's private key>
Authorization: DPoP <access-token> <- note: DPoP, not Bearer
Access token contains: cnf: { "jkt": "<thumbprint of the client's public key>" }
API checks: the DPoP proof is signed by a key whose thumbprint matches jkt,
htm/htu match this request, iat is recent, and jti is unseen
(replay protection, requires a short-lived cache).
DPoP works over ordinary HTTPS, which is why it is the practical option for public clients
and SPAs. Its cost is a signature per request and a replay cache for jti.
Neither is a default. They matter when token theft is a realistic threat: browser-based clients with XSS exposure, tokens crossing untrusted networks, or high-value operations.
Token exchange for on-behalf-of chains
User -> Gateway -> Order Service -> Payment Service
The naive approach passes the user's access token down the chain, which means the payment service receives a token scoped for the gateway and every service in the chain holds a token that works everywhere.
POST /token
grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<the user's token>
subject_token_type=urn:ietf:params:oauth:token-type:access_token
audience=payment-service
scope=charge:create
The exchanged token has the payment service as its audience and only the scope needed, and
it carries both the user (sub) and the acting service (act), so the payment service can log
"the order service, acting for user 4471."
{ "sub": "user:4471", "aud": "payment-service", "scope": "charge:create",
"act": { "sub": "service:order-service" } }
The act claim is the audit trail, and it is why token exchange beats a service passing
its own credentials: you retain the user's identity through the chain without giving every hop
a token that works everywhere.
A worked example: an ID token used as an access token
A B2B SaaS platform. Twelve services behind a gateway, Auth0 as the IdP, tokens as JWTs.
The finding, during a routine penetration test:
The analytics service accepted the ID token as a bearer credential.
An attacker with a low-privilege account could:
1. Log in normally, receiving an ID token.
2. Present it to the ADMIN service, which also accepted ID tokens.
3. The admin service checked `email_verified` and a `role` claim that
the IdP copied into the ID token from the user profile.
4. The user profile was editable via the self-service settings page.
Editing their own profile granted them the admin role.
severity: privilege escalation to full tenant admin
requests: 2 (edit profile, call admin API)
Three compounding errors:
1. Accepting the ID token at an API. The ID token's audience is the client application, not the API. Accepting it means accepting a token minted for a different party.
2. No audience validation. The services validated the signature and the issuer and not
aud, so a token for any audience in that IdP passed everywhere.
3. Authorisation from a user-editable claim. The role claim came from the user profile,
which the user could edit.
The fixes:
// 1. Reject ID tokens at APIs, explicitly.
if (claims.containsKey("nonce") || "id_token".equals(claims.get("token_use"))) {
throw new InvalidToken("ID tokens are not accepted here");
}
// And require RFC 9068's access-token type where the IdP emits it:
if (!"at+jwt".equals(header.typ)) throw new InvalidToken("typ");
// 2. Audience validation, per service, non-optional.
private static final String MY_AUDIENCE = "https://api.example.com/payments";
if (!asList(claims.aud).contains(MY_AUDIENCE)) throw new InvalidToken("aud");
// 3. Authorisation from a source the user cannot edit.
// Roles moved out of the user profile into a separate authorisation
// store, written only by an admin API, and injected into the ACCESS
// token by a post-login action.
Step 2: the audit found more.
services validating aud: 2 of 12
services accepting ID tokens: 7 of 12
services with a hardcoded HS256
shared secret: 3 of 12
services caching JWKS: 4 of 12 (the rest fetched per request)
services validating exp: 12 of 12 (the one everyone remembers)
Everyone validated expiry and almost nobody validated audience, which is the pattern: the check that fails visibly during development gets implemented, and the check that only matters under attack does not.
The three HS256 services were the second serious finding:
HS256 with a shared secret means every service that can VALIDATE a token
can also MINT one. The analytics service's secret was in a config file
in a repository 40 engineers could read.
-> moved to RS256 with JWKS. Validation needs only the public key;
only the IdP can mint.
Step 3: refresh token rotation.
before: refresh tokens valid for 30 days, no rotation, no reuse detection
a stolen refresh token was valid for up to 30 days, undetected
after: rotation on every use, family revocation on reuse,
5-second grace window for the retry case
reuse-detection events in the first 3 months: 41
of which legitimate (client failed to
persist the new token): 38
of which genuine theft (confirmed): 3
Three confirmed thefts detected in three months, on a system where they had previously been undetectable. The 38 false positives were the cost, and the 5-second grace window took them to 6 a month without meaningfully widening the theft window.
Step 4: DPoP for the browser client, because the SPA held tokens in memory and an XSS would have exfiltrated a usable bearer token.
before: Authorization: Bearer <token> stolen token works anywhere
after: Authorization: DPoP <token>
DPoP: <proof signed by a non-extractable WebCrypto key>
// The key is generated non-extractable, so even with XSS the attacker
// can sign proofs only while executing in the page, not exfiltrate the key.
const kp = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" }, false /* NOT extractable */, ["sign"]);
Non-extractable keys are what make DPoP meaningful in a browser: an XSS can use the key while the page is open and cannot steal it for later use elsewhere. That converts permanent token theft into a session-bounded compromise, which is a large reduction and not elimination.
Final:
before after
services validating aud 2/12 12/12
services accepting ID tokens 7/12 0/12
services with symmetric secrets 3/12 0/12
refresh token lifetime 30 d 30 d (rotated, family-revoked)
detectable token theft no yes (3 confirmed in 3 months)
privilege escalation via
editable claim present closed
browser token theft impact permanent session-bounded (DPoP)
The privilege escalation needed three separate mistakes and each alone was survivable, which is the usual shape: accepting an ID token is a design error, missing audience validation is an omission, and authorising from an editable claim is a data-modelling error, and it took all three to reach tenant admin in two requests.
Production evidence
OAuth 2.1 (in draft, consolidating current best practice) removes the implicit grant and ROPC, mandates PKCE for all authorization code flows including confidential clients, and requires exact redirect-URI matching. Those changes are the specification catching up with a decade of published attacks.
RFC 9068 defines a standard JWT profile for access tokens, including typ: "at+jwt",
precisely so an API can distinguish an access token from an ID token structurally rather than
by heuristic.
The algorithm confusion attack (RS256 to HS256) has affected many JWT libraries, and the resulting guidance is uniform: never take the algorithm from the token. Several libraries changed their APIs to make the algorithm a required parameter rather than an inferred one.
Refresh token rotation with reuse detection is specified in the OAuth 2.0 Security Best Current Practice document and is implemented by Auth0, Okta and Keycloak. The BCP is explicit that rotation without reuse detection provides limited benefit.
DPoP (RFC 9449) was driven substantially by the financial-grade API work and by browser clients that cannot use mTLS. FAPI 2.0 requires sender-constrained tokens, either mTLS or DPoP, which is the clearest signal about where high-assurance deployments are going.
Token exchange (RFC 8693) and the act claim are implemented by Keycloak, Auth0 and
others, and the on-behalf-of pattern it standardises was previously done with proprietary
extensions in every IdP.
The debate
Should access tokens be JWTs or opaque? JWTs remove a network call per request and make revocation hard; opaque tokens require introspection and make revocation immediate. My position: JWTs with short expiry (5 to 15 minutes) for most APIs, opaque tokens where immediate revocation is a requirement (financial operations, admin surfaces). The middle ground of a JWT plus a revocation denylist reintroduces the network call for the case you were avoiding it for, which is fine if that case is rare.
How short should an access token be? Short enough that revocation-by-expiry is acceptable and long enough that refresh traffic is not the dominant load. Five to fifteen minutes is the usual range, and the deciding question is what happens between a revocation and the expiry: if a fired employee retaining access for fifteen minutes is unacceptable, you need a denylist or opaque tokens, not a shorter JWT.
Is refresh token rotation worth the false positives? Yes, and the false positives are manageable. In the worked example 41 reuse events in three months included 3 confirmed thefts that were previously undetectable, and a five-second grace window took the false positives from 38 to about 6 a month. Rotation without reuse detection is close to pointless, because the value is the detection rather than the rotation.
Do you need DPoP? Not by default. Bearer tokens with short expiry are adequate when the client is a confidential server-side application. DPoP earns its place for browser clients, where XSS is a realistic path to token exfiltration and a non-extractable WebCrypto key converts permanent theft into session-bounded use. mTLS is stronger and impractical wherever a proxy terminates TLS.
Is passing the user's token down the call chain acceptable? It is common and it is wrong for two reasons: every service in the chain holds a token that works at every other service, and the audience check becomes meaningless because the token's audience is the gateway. Token exchange is the correct answer and its cost is a call to the IdP per hop, which can be cached for the token's lifetime. Where that cost is unacceptable, a signed internal assertion with a narrow audience is the pragmatic version.
What is the highest-value validation check? Audience, by a wide margin, because signature and expiry fail loudly during development and audience does not. A service that validates signature, issuer and expiry but not audience accepts every token that IdP issues, including those minted for services with entirely different trust levels. In the worked example 2 of 12 services validated it.
Follow-up Q&A
"Which OAuth grants are dead and why?"
Implicit and ROPC, both removed in OAuth 2.1. Implicit returned the access token in the URL
fragment, so it leaked into browser history, Referer headers and logs, and it could not
authenticate the client; it existed only because browsers could not do cross-origin POST, and
CORS removed that constraint. ROPC has the application collect the user's password directly,
which defeats delegation entirely and makes MFA and federation impossible.
"What is the difference between an access token and an ID token?"
Audience and purpose. The access token's audience is the API and it carries authorisation
(scopes); the ID token's audience is the client application and it carries authentication
claims about the user. An API accepting an ID token is accepting a token minted for a different
party, with no scope information in it, which is the most common OAuth implementation error.
RFC 9068's typ: "at+jwt" exists so an API can reject it structurally.
"Walk me through validating a JWT."
Parse without trusting; check the algorithm against a pinned allowlist rather than reading it
from the token, because a library that infers it can be fed HS256 signed with your public RSA
key; resolve the key by kid from a cached JWKS with a rate-limited refresh, or an unknown-kid
flood becomes a DoS on your IdP; verify the signature; check iss exactly; check aud
contains this API; check exp and nbf with a small skew allowance; reject ID tokens; and
check revocation if you need it. Audience is the one that gets skipped and the one that matters
most.
"Why does audience validation matter so much?"
Because an IdP signs every service's tokens with the same key, so signature and issuer checks pass for a token minted for any of them. Audience is the only claim separating a token for the analytics service from one for the payments service. It gets omitted because the token validates without it, so nothing fails during development, and in one audit 2 of 12 services checked it while 12 of 12 checked expiry.
"How does refresh token rotation with reuse detection work?"
Each use of a refresh token issues a new one and marks the old as used, recording the family lineage. If a used token is presented again, either it was stolen or the legitimate client failed to persist its replacement, and you cannot distinguish them, so you revoke the whole family and force re-authentication. The value is the detection: without it a stolen refresh token works silently until expiry. A short grace period accepting a just-used token reduces the false positives from clients that crashed before persisting.
"When would you use DPoP?"
When token theft is realistic and mTLS is impractical, which in practice means browser clients.
The client holds a key pair and signs a proof per request binding it to the method and URI, and
the access token carries the key thumbprint. With a non-extractable WebCrypto key, an XSS can
use the key while the page is open but cannot exfiltrate it, which converts permanent token
theft into session-bounded compromise. It costs a signature per request and a replay cache for
the proof's jti.
Common misconceptions
"Send the ID token to the API." Its audience is the client. An API accepting it is accepting a token minted for a different party, carrying identity rather than authorisation.
"Validating the signature is enough." Signature plus issuer proves the IdP minted it and
says nothing about for whom. Without aud, a token for any service in that IdP passes.
"PKCE replaces state." They defend against different attacks: state against CSRF, PKCE
against code interception and injection. Both are required.
"Rotation makes refresh tokens safe." Rotation without reuse detection provides limited benefit, because a stolen token still works until it is used and nothing detects the theft. The detection is the point.
"HS256 is fine, it is simpler." A shared secret means every service that can validate a token can mint one. With RS256 and JWKS, validation needs only the public key.
Interview delivery note
Say this verbatim: "The check everyone implements is expiry and the check that matters most is audience, because an IdP signs every service's tokens with the same key, so signature and issuer pass for a token minted for any of them. In one audit 12 of 12 services validated expiry and 2 of 12 validated audience." A specific asymmetry with a measurement, and it explains itself.
The senior-versus-staff separator is knowing why audience validation gets skipped. A senior engineer lists the JWT checks correctly. A staff engineer observes that the checks which fail loudly during development get implemented and the ones that only matter under attack do not, which predicts which check will be missing before you look. That generalises well past JWTs.
The second signal is the token-type distinction stated as an audience question rather than a naming one. "The ID token's audience is the client and the access token's is the API, so an API accepting an ID token is accepting a token minted for a different party" is the version that makes the rule derivable rather than memorised.
Further reading
- OAuth 2.0 Security Best Current Practice (draft-ietf-oauth-security-topics) and the OAuth 2.1 draft, for the removed grants and the mandatory PKCE.
- RFC 9068, the JWT profile for OAuth access tokens, particularly
typ: "at+jwt". - RFC 9449 (DPoP) and RFC 8705 (mTLS-bound tokens), for the two sender-constraining mechanisms.
- RFC 8693 (Token Exchange) and the
actclaim, for on-behalf-of chains with an audit trail.