OAuth 2.0
Access, refresh and ID tokens
Three tokens with three different recipients. Using one where another was meant is the root of many OAuth bugs.
OAuth and OpenID Connect hand out three kinds of token, and most token bugs come from using one where another was meant. An access token authorizes calls to an API. A refresh token gets new access tokens from the authorization server. An ID token tells the client that a user signed in. They have different audiences, different formats, different lifetimes and different rules.
Three tokens, three recipients
| Access token | Refresh token | ID token | |
|---|---|---|---|
| Defined by | RFC 6749 §1.4 (format unspecified); JWT profile in RFC 9068 | RFC 6749 §1.5, §6 | OpenID Connect Core §2 |
| Purpose | Authorize requests to a resource server | Obtain new access tokens without the user | Tell the client about an authentication event |
| Presented to | Resource server (API) | Authorization server's token endpoint only | Nobody; the client consumes it |
| Audience | The API (aud in RFC 9068) | The issuing authorization server | The client's client_id |
| Format | Opaque string or JWT, issuer's choice | Opaque to the client; server-defined | A JWT, which MUST be signed (unsigned only in a narrow code-flow case the client must opt into) |
| Client may read it? | Should treat it as opaque | No | Yes, and must validate it |
| Typical lifetime | Minutes to an hour | Hours to months, often with inactivity expiry | Minutes; used once at login |
| Main risk if stolen | API access until expiry | Fresh access tokens for the full grant | Little on its own, unless an API wrongly accepts it |
Access tokens: opaque or JWT
RFC 6749 describes an access token as a string that "is usually opaque to the client" and leaves its structure to the authorization server. Two designs are common:
- Opaque (reference) tokens: a random string that points at a record on the authorization server. The resource server learns what it means by calling the introspection endpoint. Revocation is immediate; every check costs a network call (or a cache).
- Structured (self-contained) tokens: usually a signed JWT carrying issuer, audience, expiry, subject and scope. The resource server validates the signature locally with the issuer's published keys. Fast, but a token stays valid until it expires unless the server also checks a revocation source.
RFC 9068 (October 2021) standardizes JWT access tokens so resource servers can validate them the same way across vendors:
- They MUST be signed and MUST NOT use
algnone; asymmetric signing is RECOMMENDED, and RS256 support is REQUIRED of conforming servers (§2.1). - The header MUST include the media type
application/at+jwtintyp, and the value used SHOULD beat+jwt. This explicit typing exists so that an ID token cannot be passed off as an access token. - Required claims:
iss,exp,aud,sub,client_id,iat,jti;scopeSHOULD be included when one was requested (§2.2).
Header: {"typ":"at+jwt","alg":"RS256","kid":"RjEwOwOA"}
Claims:
{
"iss": "https://authorization-server.example.com/",
"sub": "5ba552d67",
"aud": "https://rs.example.com/",
"exp": 1639528912,
"iat": 1618354090,
"jti": "dbe39bf3a3ba4238a513f51d6e1691c4",
"client_id": "s6BhdRkqt3",
"scope": "openid profile reademail"
}
Adapted from RFC 9068 Figure 2. The RFC's own example writes "typ":"at+JWT" in upper case even though §2.1 says the value used SHOULD be at+jwt; media type names compare case-insensitively, so validators should accept both spellings.
A resource server receiving one MUST (§4): check typ is at+jwt or application/at+jwt; check iss exactly matches the expected issuer; check aud contains its own identifier; verify the signature with the issuer's keys and reject none; and check the current time is before exp (a few minutes' leeway is allowed). On failure it returns invalid_token.
Sending a bearer token
A bearer token is usable by whoever holds it, with no proof of possession. RFC 6750 defines three ways to send one:
GET /resource HTTP/1.1
Host: server.example.com
Authorization: Bearer mF_9.B5f-4.1JqM
- Authorization header: clients SHOULD use it and resource servers MUST support it (§2.1).
- Form-encoded body (
access_token=...): allowed only under strict conditions (single-partapplication/x-www-form-urlencodedbody, notGET), and SHOULD NOT be used except where the browser cannot set the header (§2.2). - URI query parameter (
?access_token=...): SHOULD NOT be used "unless it is impossible to transport the access token in the Authorization request header field or the HTTP request entity-body" (§2.3), because URLs end up in logs, history and Referer headers.
RFC 9700 §4.3.2 tightens the last point to a hard rule: clients MUST NOT pass access tokens in a URI query parameter in the way described in RFC 6750 §2.3. The OAuth 2.1 draft drops the query method altogether. RFC 6750 §5.3 adds: always TLS (MUST), validate the certificate chain (MUST), never store bearer tokens in cookies that can be sent in the clear (MUST NOT), and issue short-lived (one hour or less) and audience-restricted tokens (SHOULD).
When a request fails, the resource server answers with WWW-Authenticate: Bearer and an error code: invalid_request (400), invalid_token (401), or insufficient_scope (403) (§3.1).
Refresh tokens
A refresh token lets a client get a new access token "using the same authorization grant" without involving the user (RFC 6749 §6). It is what makes short-lived access tokens practical, and it is also the most valuable thing an attacker can steal from a client: RFC 9700 §4.14.1 notes it represents "the full scope of access granted" and is not tied to a single resource.
POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA&client_id=s6BhdRkqt3
The requested scope on a refresh MUST NOT exceed what was originally granted. If the server issues a new refresh token, the client MUST discard the old one (§6). RFC 9700 §4.14.2 adds the current rules:
- Authorization servers MUST decide, based on a risk assessment, whether to issue refresh tokens to a client at all.
- Refresh tokens MUST be bound to the scope and resource servers the resource owner consented to.
- For confidential clients, RFC 6749 already binds the refresh token to the client, which must authenticate to use it.
- For public clients, the server MUST detect replay using one of two methods: sender-constraining (binding the token to a key with DPoP or mutual TLS) or rotation (a new refresh token on every refresh; the old one is invalidated, and if an invalidated token is ever presented, the server revokes the active one, forcing the legitimate client to re-authorize).
- Refresh tokens SHOULD expire after a period of client inactivity. Servers MAY revoke them automatically on events such as a password change or logout.
Rotation detects theft after the fact and only if both parties use the token; sender-constraining prevents use by anyone without the key. Where the client can keep a key (in a platform keystore, or as a non-extractable key in the browser) sender-constraining is the stronger choice.
ID tokens are not access tokens
An ID token is an OpenID Connect JWT whose aud is the client's own client_id. It says "this user authenticated at this issuer at this time, for you". It does not authorize API calls. Sending it to an API as if it were an access token is a common mistake with real consequences: the API cannot tell which client the user approved, every client of the same issuer holds equivalent tokens, and the token was never scoped. RFC 9068's mandatory typ check and audience check are there to make resource servers reject ID tokens. Conversely, a client must never treat possession of an access token as proof of who the user is.
Introspection and revocation
Introspection (RFC 7662)
For opaque tokens, the resource server asks the authorization server. The introspection endpoint MUST require some form of authorization (client authentication or a separate token) to stop token scanning (§2.1):
POST /introspect HTTP/1.1
Host: server.example.com
Accept: application/json
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
token=mF_9.B5f-4.1JqM&token_type_hint=access_token
HTTP/1.1 200 OK
Content-Type: application/json
{
"active": true,
"client_id": "l238j323ds-23ij4",
"scope": "read write dolphin",
"sub": "Z5O3upPC88QrAjx00dis",
"aud": "https://protected.example.net/resource",
"iss": "https://server.example.com/",
"exp": 1419356238,
"iat": 1419350238
}
Only active is REQUIRED; for an inactive, expired, revoked or unknown token the answer is just {"active": false}. Responses MAY be cached by the resource server, at the cost of not noticing a revocation until the cache entry expires (§2.2).
Revocation (RFC 7009)
Clients should revoke tokens they no longer need, typically at logout or when the user disconnects an integration. Servers implementing RFC 7009 MUST support revoking refresh tokens and SHOULD support revoking access tokens. Revoking a refresh token SHOULD also invalidate access tokens from the same grant, where the server supports access token revocation.
POST /revoke HTTP/1.1
Host: server.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
token=45ghiukldjahdnhzdauz&token_type_hint=refresh_token
HTTP/1.1 200 OK
The server answers 200 both when it revoked the token and when the token was already invalid, since the goal is achieved either way (§2.2). A self-contained JWT access token cannot be "un-issued"; unless resource servers consult a revocation source, it stays valid until exp. That is the strongest argument for short access token lifetimes.
Choosing lifetimes
The RFCs give few fixed numbers: RFC 6750 §5.3 suggests bearer tokens of one hour or less, and RFC 6749 §4.1.2 recommends authorization codes of at most 10 minutes. Everything else is a trade-off you set by risk:
| Shorter lifetime | Longer lifetime |
|---|---|
| Smaller window for a stolen token | Fewer token requests, less load |
| Revocation and permission changes take effect sooner (for JWT access tokens) | Fewer failures when the authorization server is briefly unavailable |
| More refresh traffic; more chances for refresh races in multi-tab apps | Users stay signed in to long-running sessions |
A sensible pattern: short-lived, audience-restricted access tokens; refresh tokens that rotate or are sender-constrained, with an inactivity timeout and an absolute maximum; ID tokens used once at sign-in and then replaced by the application's own session.
Checklist
- APIs accept only access tokens, check
typ(for RFC 9068 tokens),iss,aud, signature andexp, and reject ID tokens. - Tokens sent in the
Authorizationheader, never in URLs. - Responses carrying tokens use
Cache-Control: no-store. - Public clients' refresh tokens are rotated or sender-constrained; all refresh tokens expire on inactivity.
- Introspection endpoint requires authentication; caching period chosen deliberately.
- Clients revoke tokens on logout and disconnection.
- Tokens never logged. To inspect a test token, use the JWT decoder, and never paste production tokens into tools you do not control.
Sources
- RFC 6749: The OAuth 2.0 Authorization Framework, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
- RFC 6750: The OAuth 2.0 Authorization Framework: Bearer Token Usage, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
- RFC 9068: JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
- RFC 9700: Best Current Practice for OAuth 2.0 Security, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
- RFC 7662: OAuth 2.0 Token Introspection, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
- RFC 7009: OAuth 2.0 Token Revocation, IETF. IETF Trust Legal Provisions (brief quotations with attribution).
- OpenID Connect Core 1.0 incorporating errata set 2, OpenID Foundation. OpenID Foundation specification (cited, brief quotations).