Access and Refresh Tokens
|
This section documents OAuth 2.0 (RFC 6749) as amended by the OAuth 2.0 Security Best Current Practice (RFC 9700 / BCP 240), the OAuth 1.0 Protocol (RFC 5849) for historical context, and OpenID Connect Core 1.0, as published at the IETF Datatracker and the OpenID Foundation specifications — and, on the Spring pages, Spring Boot 4.1.x and Spring Security 7.1.x as published at the Spring Security reference documentation — which are the references these pages are written and verified against. OAuth 2.1 is still an Internet-Draft ( This content was generated with the assistance of AI and should be verified against those official specifications before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
An access token is the credential a client presents to a resource server (an API) to prove it has been granted delegated access to some scope of that API, on behalf of a resource owner or on its own behalf. This page covers what that credential is (and is not) to the client that holds it, the wire format of the token response and its error cases, and the refresh-token mechanism that lets a client obtain new access tokens without repeating the whole authorization dance.
What an access token is to the client
RFC 6749 §1.4 is explicit: "the client MUST NOT rely on the value or structure of the access token, or make any assumptions about the token’s contents." Whatever format the authorization server chooses to mint — an opaque random string, a JSON Web Token, anything else — the client’s contract is the same: it obtained the token via a grant, it sends it to the resource server exactly as issued, and it never inspects it, decodes it, or tries to extract claims from it. Even when the access token happens to be a JWT that the client could technically decode, doing so is a client-side anti-pattern: the resource server’s claim set and the client’s needs are not the same contract, and the authorization server is free to change the token’s internal format at any time without breaking a client that respects the opacity rule. A client that wants to know who is signed in reads the ID token instead — see ID Tokens vs. Access Tokens.
|
"Opaque to the client" and "opaque token" (the token type discussed below) are different things. Every access token is opaque to the client by contract, whether or not its wire format is a structured JWT or a random by-reference string. |
Bearer vs. sender-constrained
RFC 6750 defines the bearer token model: whoever holds the token can use it, full stop. There is no proof the
presenter is the party the token was issued to — if it leaks (a logged header, a compromised browser storage
area, a malicious redirect), the thief has everything the legitimate client had, for as long as the token
remains valid. This is convenient (any HTTP client can send Authorization: Bearer <token>) and is the default
almost everywhere.
A sender-constrained token binds the token to a cryptographic key the legitimate client holds, so a stolen token is useless without also stealing that key:
-
DPoP (RFC 9449) — the client proves possession of a private key with a signed JWT sent alongside the request; the access token carries a
cnf.jktthumbprint of the corresponding public key, and the resource server checks the two match. -
Mutual-TLS (RFC 8705) — the access token is bound to the client’s TLS certificate (
cnf.x5t#S256); the resource server checks the certificate presented on the current TLS connection against that thumbprint.
Both mechanisms are covered in full on
Sender-Constrained Tokens: DPoP and mTLS. RFC
9700 §4.14 recommends sender-constraining wherever the deployment can support it, and the in-progress OAuth 2.1
consolidation (draft-ietf-oauth-v2-1-16) keeps bearer tokens as the default but documents DPoP as the
recommended upgrade path for higher-risk clients.
By-value vs. by-reference
An access token’s format is independent of the bearer/sender-constrained distinction above. Two families are in production use:
| By-value (structured, typically a JWT) | By-reference (opaque) | |
|---|---|---|
What it looks like |
A JWT: base64url header, payload and signature, self-contained |
A random string with no extractable structure (e.g. a UUID or a long opaque handle) |
How the resource server validates it |
Locally: verify the signature against a cached public key (JWKS), then check the claims — no network call needed |
Remotely: call the authorization server’s introspection endpoint (RFC 7662) on every request, or on a cache-refresh interval |
Latency and availability |
No dependency on the authorization server being reachable at request time |
Adds a network hop (mitigated by short-TTL caching); resource server degrades if the AS is down |
Revocation |
Cannot be revoked before |
Revocation is immediate once the AS marks it revoked, since every validation is a live lookup |
Size and content exposure |
Carries its own claims — easy to leak PII or internal identifiers into logs, browser storage or a URL if handled carelessly |
Carries no claims at all; the AS decides what to return from introspection and to whom |
Multi-resource-server fan-out |
Any RS with the JWKS can validate independently; no coordination needed |
Every RS needs network access (and, usually, its own client credentials) to the AS’s introspection endpoint |
Typical profile |
RFC 9068 |
Random handle stored server-side by the AS, resolved via RFC 7662 |
Neither option is universally "better" — it is a deployment trade-off between latency/availability and revocation immediacy/privacy. The full decision table, including when to mix the two per resource server, is on Opaque Tokens, Introspection and Revocation. The JWT format itself — claims, algorithms, validation — is covered on JWT and the JOSE Family.
Lifetime, audience and scope
Three properties travel with (or alongside) every access token regardless of format:
-
Lifetime (
exp, or the introspection response’s ownexp) — access tokens are deliberately short-lived (minutes to roughly an hour is typical) precisely because a bearer token cannot usually be revoked before it expires. A short lifetime bounds the blast radius of a leak; the refresh token (below) is what lets the client keep working without re-running the authorization flow every few minutes. -
Audience (
aud, orresourceper RFC 8707) — the resource server(s) the token is valid for. A resource server that skips the audience check will accept a token that was minted for a different API, which is exactly the confused-deputy scenario RFC 9700 §4.9 warns about. See Scopes, Claims and Permissions forresourceindicators and multi-audience tokens. -
Scope (
scope) — the delegated capability the token carries, echoed back in the token response (it may be narrower than what was requested, per RFC 6749 §5.1). Scope is a request for capability, not proof of identity or role — see the same page above.
Where a token may be stored, per client type
Storage guidance follows directly from the client’s threat model:
| Client type | Where the access (and refresh) token may live |
|---|---|
Confidential server-side web app |
Server-side session store or an encrypted, |
Single-page application (SPA) |
Not in |
Native / mobile app |
Platform-provided secure storage (Android Keystore-backed |
CLI / daemon / service (client credentials) |
Process memory only, re-requested on expiry; never written to a dotfile or shell history in plain text |
Public client generally |
Refresh tokens for public clients should be sender-constrained and rotated on every use (see below) — a stolen refresh token is more damaging than a stolen access token because it is longer-lived |
The token response
A successful token endpoint response (RFC 6749 §5.1) is a JSON object with Cache-Control: no-store and
Pragma: no-cache (both mandatory, so intermediary caches never retain a token):
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
Pragma: no-cache
{
"access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "8xLOxBtZp8",
"scope": "orders:read orders:write"
}
-
access_token(required) — the credential itself, opaque to the client regardless of its internal format. -
token_type(required) —Bearer(RFC 6750) orDPoP(RFC 9449) when sender-constrained; the client uses this to pick the rightAuthorizationheader form. -
expires_in(recommended) — seconds until the access token expires, measured from the moment the response was generated, not from when the client receives or parses it. -
refresh_token(optional) — present only when the grant and client are configured to receive one; absent for e.g. a plainclient_credentialsgrant, since there is no "session" to refresh. -
scope(required only if different from what was requested) — best practice is to always echo it, so the client never has to assume it got everything it asked for.
Error responses
A failed token request (RFC 6749 §5.2) returns 400 Bad Request with a machine-readable error code:
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=UTF-8
Cache-Control: no-store
{
"error": "invalid_grant",
"error_description": "Refresh token expired or already used"
}
error |
Meaning |
|---|---|
|
A required parameter is missing, duplicated, malformed, or otherwise does not conform to the grant |
|
Client authentication failed (unknown client, missing or wrong credential, unsupported authentication method) |
|
The authorization grant, refresh token, or PKCE |
|
The authenticated client is not authorized to use this grant type |
|
The authorization server does not support the requested |
|
The requested scope is invalid, unknown, malformed, or exceeds what the resource owner or client is allowed |
Refresh tokens
Why they exist
Access tokens are kept short-lived to bound the damage of a leak, but re-running an entire user-facing authorization flow every few minutes would be unusable. A refresh token is a long-lived credential, issued alongside the access token, that the client exchanges at the token endpoint for a fresh access token (and, usually, a fresh refresh token) without any resource-owner interaction. It shifts the "how do we bound risk" problem from the short-lived access token onto a token that is issued more selectively, stored more carefully, and can be revoked as a unit.
Rotation and reuse detection
Refresh token rotation means every use of a refresh token issues a brand-new refresh token and invalidates
the one just used, rather than letting the same refresh token be exchanged repeatedly. RFC 9700 §4.14.2
recommends rotation for public clients (and the in-progress OAuth 2.1 consolidation,
draft-ietf-oauth-v2-1-16, makes rotation the expected behaviour for refresh tokens issued to public clients
that cannot otherwise be sender-constrained).
Rotation only earns its keep when paired with reuse detection: if a refresh token that has already been
rotated away is presented again, the authorization server has strong evidence the token was copied by an
attacker (the legitimate client would have moved on to the new one). The correct response is not just to reject
that single request — it is to revoke the entire token family descending from the original grant (every
refresh token, and typically every access token, issued from that authorization). This limits an attacker who
raced the legitimate client to a single stolen use, and it also warns the legitimate client (via the next
invalid_grant) that something is wrong.
Absolute vs. idle expiry
Two expiry clocks are commonly combined:
-
Absolute expiry — a hard ceiling on the token family’s lifetime from the moment of the original grant (e.g. 30 days), forcing a full re-authorization no matter how often the token is refreshed.
-
Idle expiry — a shorter window that resets on every successful refresh (e.g. 14 days of inactivity); if the client never comes back, the refresh token dies quietly instead of remaining valid indefinitely.
A session that is refreshed regularly stays alive up to the absolute ceiling; one that goes quiet dies at the idle ceiling, whichever comes first.
Sender-constraining for public clients
A public client (one that cannot hold a confidential secret — a native app or a browser-based app) cannot prove it is the same party the refresh token was issued to using a client secret. RFC 9700 §4.14 and the in-progress OAuth 2.1 draft both push public clients towards sender-constraining the refresh token — typically DPoP, so the authorization server binds the refresh token to the client’s proof-of-possession key and rejects a refresh request from anyone who does not hold that key, even if they have the refresh token’s string value. Where sender-constraining is not available, PKCE (mandatory for public clients under RFC 7636 and OAuth 2.1) plus rotation and reuse detection is the practical minimum. See Authorization Code and PKCE and Sender-Constrained Tokens: DPoP and mTLS.
The refresh_token grant wire format
POST /oauth2/token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
grant_type=refresh_token&
refresh_token=8xLOxBtZp8&
scope=orders%3Aread
-
grant_type(required) — literalrefresh_token. -
refresh_token(required) — the token obtained from a previous token or refresh response. -
scope(optional) — may only narrow the originally granted scope (RFC 6749 §6), never widen it. -
Client authentication (
Authorization: Basic,client_secret_post,private_key_jwt, mTLS, …) follows whatever method the client registered with — see Client Credentials and Client Authentication.
A successful response looks exactly like the token response above, generally with a new refresh_token value
when rotation is enabled — the client must discard the old one and persist the new one atomically, since the
old value becomes invalid (and reuse-triggering) the moment the new one is issued.