- RFC 9068: JWT Profile for OAuth 2.0 Access TokensBertocci (IETF, October 2021) - the profile this page unpacks
- RFC 8725: Best Current Practices for Securing JWTsthe confusion-attack backdrop for the at+jwt typing rule
- RFC 8707: Resource Indicatorsthe
resourceparameter behind the §3 audience rules - RFC 6750, section 3.1the
invalid_tokenerror every checklist failure rides on - The JWT inspectorthe six checks, running live in your browser
RFC 6749 is deliberately silent on what an access token looks like: it is a string the client presents and the resource server must understand. But many servers wanted to validate tokens themselves, so authorization servers began issuing them as JWTs - "many commercial OAuth 2.0 implementations elected to issue access tokens using a format that can be parsed and validated by resource servers directly, without further authorization server involvement" (section 1). The layouts worked, but only per vendor: same ideas, different claim names.
RFC 9068 (Bertocci; October 2021) is the interoperable answer: one claim layout, one typing convention, and one validation story for access tokens issued as JWTs. Do not confuse it with RFC 7523, which is about JWTsas a grant - a client authenticating with an assertion; 9068 is about JWTs as the token (section 1). The payoff is offline validation: a resource server checks the signature against the AS's published keys and enforces the claims locally - no introspection round trip per request. The two coexist; an RS that needs immediate revocation can still introspect.
To the client it is still one opaque-looking string. The profile's first conventions live in the header: tokens MUST be signed, alg: noneMUST NOT be used, asymmetric signing is RECOMMENDED, and RS256 MUST be supported by conforming ASes and RSes (section 2.1). And every JWT access token MUST declare the at+jwt media type in its typheader - since the "application/" prefix is RECOMMENDED to be omitted, the value used SHOULD be at+jwt - which is what makes an access token distinguishable from every other JWT in circulation (section 2.1).
Authorization: Bearer eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJSUzI1NiIsImtpZCI6IjcxIn0.<payload>.<signature>
And what it does with it: split the three dot-separated segments, decode, and check. The payload is the whole contract:
// header (the segment above, decoded)
{
"typ": "at+jwt",
"alg": "RS256",
"kid": "71"
}
// payload
{
"iss": "https://as.example.com/",
"sub": "<resource-owner-or-client>",
"aud": "https://api.example.com/",
"client_id": "<client-id>",
"jti": "<token-id>",
"iat": 1634571490,
"exp": 1634575090,
"scope": "invoices:issue"
}
// signature: the AS's key, verifiable from its published JWKS| Claim | Status | Means |
|---|---|---|
typ header, 2.1 | SHOULD be at+jwt | Explicit typing - the primary defense against ID-token/access-token confusion (with RFC 8725's practices behind it) |
iss | REQUIRED | The issuer; a resource server MUST match it exactly (section 4) |
sub | REQUIRED | The resource owner's subject - or, for client-credentials tokens, an identifier of the client itself (2.2) |
aud | REQUIRED | Who the token is for; the resource server MUST find itself in it (section 4) |
client_id | REQUIRED registered in RFC 8693 §4.3 | The client the AS authenticated when minting the token |
iat / exp | REQUIRED | Issued at, and expiry - the resource server compares the current time against exp (section 4) |
jti | REQUIRED | A unique identity for this token - the anchor for revocation and audit |
scope | SHOULD 2.2.3 | Present whenever the request carried a scope, so the RS sees what was granted |
Authentication flourishes like auth_time, acr, andamr are optional (2.2.1), and the profile borrowsroles / groups / entitlements from SCIM (2.2.3.1). The audience rule underneath the table is section 3: when the request carried a resource parameter (RFC 8707), theaud SHOULD equal it; otherwise the AS MUST apply a default resource indicator. And the spec is blunt about ambiguity: "The authorization server MUST NOT issue a JWT access token if the authorization granted by the token would be ambiguous" (section 3).
Section 4 is the practical payload of the spec: the checks that make a JWT access token locally decidable. "Resource servers receiving a JWT access token MUST validate it in the following manner" - and this is the sequence a resource server runs on every protected request:
| # | Check (section 4) | On failure |
|---|---|---|
| 1 | The typ header is at+jwt or application/at+jwt - "reject tokens carrying any other value" | Reject: not this profile's token |
| 2 | The issuer identifier MUST exactly match the iss claim | Reject: wrong authorization server |
| 3 | aud contains a resource indicator the resource server expects for itself | Reject: minted for someone else |
| 4 | The signature verifies with the alg-specified algorithm and the keys provided by the AS - and alg: none MUST be rejected | Reject: forged or tampered |
| 5 | The current time MUST be before exp - "some small leeway, usually no more than a few minutes" MAY cover clock skew | Reject: expired |
| 6 | Authorization claims, if present, SHOULD inform the accept-or-reject decision alongside the request's context | Reject: does not meet this API's contract |
Any failure answers 401 invalid_token: the resource server "MUST handle errors as described in Section 3.1 of [RFC6750]", the error the bearer page tabulates. The JWT inspectorruns this same sequence live in your browser - six steps, with tamper buttons for each defense.
The token decodes; the client can read it; and the spec closes that door explicitly: "The client MUST NOT inspect the content of the access token" (section 6). The client's job is to present the token, not to parse authorization decisions out of it - because the profile is an AS-to-RS contract, and the format can change at any time without notice to the client. A dashboard that reads scope from the token to hide buttons has coupled itself to an implementation detail.
Section 5 adds a quieter corollary for authorization servers: using different signing keys for ID tokens and access tokens buys nothing, because resource servers accept any key the AS publishes - the typing and the audience checks are the separation, not the keyring.
| Failure | What happens | The fix (and its clause) |
|---|---|---|
| Token confusion | An ID token, a refresh assertion, or a crafted JWT is presented at an API and accepted | Explicit typing: typ MUST be at+jwt and other values are rejected (§4); ASes MUST give each resource a distinct aud to prevent cross-JWT confusion (§5, with RFC 8725) |
alg: none accepted | An unsigned "token" sails through a validator that trusts the header | none MUST NOT be used, and the RS MUST reject it (§2.1, §4) |
| No audience discipline | A token minted for service A is accepted by service B - one leak becomes lateral movement | aud MUST include the accepting RS (§4); ASes MUST NOT issue ambiguous tokens (§3) |
| Machine tokens read as users | Authorization keyed on sub breaks: a client-credentials token's sub is the client, not a person (2.2) | Model machine identities as first-class principals - living with the token |
| Expired-but-usable | A generous clock-skew window keeps dead tokens alive past their window | The RFC's own allowance is small: leeway "usually no more than a few minutes" (§4); real margins belong on the client's refresh side |