How OAuth 2.0 and OAuth 2.1 Work, Message by Message
OAuth 2.0 is a set of HTTP exchanges that end with an API receiving an access token and deciding whether to honor it. There are three grants worth knowing in 2026: authorization code with PKCE when a user is present, client credentials when no user is, and refresh to renew either without starting over. This page shows each message with fake but realistic values, explains every parameter with its section in RFC 6749 and the OAuth 2.1 draft (draft-17, 31 October 2026), and lists what the authorization server, the client and the API each have to check.
For the overview, the roles and status, see the OAuth 2.0 and OAuth 2.1 standard pages. For the identity layer and the ID token, see how OIDC works, message by message.
The sequence at a glance
| # | Message | OAuth 2.0 | OAuth 2.1 draft-17 |
|---|---|---|---|
| 1 | Authorization request (browser) | RFC 6749 §4.1.1 + RFC 7636 §4.3 | §4.1.1 |
| 2 | Authorization response (redirect with code) | §4.1.2 | §4.1.2 |
| 3 | Token request (code exchange) | §4.1.3 | §3.2.2, §4.1.3 |
| 4 | Token response | §5.1, errors §5.2 | §3.2.3–3.2.4 |
| 5 | Client credentials request | §4.4.2 | §4.2 |
| 6 | Refresh request | §6 | §4.3 |
| 7 | API request with bearer token | RFC 6750 §2.1 | §5.1.1 |
| 8 | API error | RFC 6750 §3 | §5.3 |
Messages 1 and 2: authorization request and response
GET /authorize?response_type=code
&client_id=reports-web-example
&redirect_uri=https%3A%2F%2Freports.example.com%2Foauth%2Fcallback
&scope=invoices.read
&state=7d1f2b9e-example
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https%3A%2F%2Fapi.example.com%2Finvoices HTTP/1.1
Host: as.example.com
| Parameter | Meaning | Spec |
|---|---|---|
response_type=code |
Only code survives in OAuth 2.1; token (implicit) is gone. |
RFC 6749 §4.1.1; RFC 9700 §2.1.2 |
client_id |
Registered client identifier. | RFC 6749 §2.2 |
redirect_uri |
Compared by exact string match against registration (loopback ports excepted for native apps). | RFC 9700 §2.1, §4.1.3; 2.1 §4.1.1 |
scope |
Space-delimited permissions requested. Design scopes per API action, not per app. | RFC 6749 §3.3 |
state |
CSRF protection and client context. | RFC 6749 §10.12 |
code_challenge, code_challenge_method=S256 |
PKCE. Required by default in 2.1; plain removed. |
RFC 7636 §4.2–4.3; 2.1 §4.1.1 |
resource |
Names the API the token is for, so the AS can audience-restrict it. | RFC 8707 §2.1 |
The response is a redirect:
HTTP/1.1 302 Found
Location: https://reports.example.com/oauth/callback?code=Qcb0Orv1zh30vL1MPRsbm-example
&state=7d1f2b9e-example&iss=https%3A%2F%2Fas.example.com
RFC 6749 §4.1.2 recommends codes live at most 10 minutes and says a code used twice must be refused, with tokens already issued from it revoked where possible. The iss parameter is RFC 9207; a client talking to more than one AS must check it to stop mix-up attacks (RFC 9700 §4.4).
Messages 3 and 4: token request and response
POST /token HTTP/1.1
Host: as.example.com
Authorization: Basic cmVwb3J0cy13ZWItZXhhbXBsZTpub3QtYS1yZWFsLXNlY3JldA==
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=Qcb0Orv1zh30vL1MPRsbm-example
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&redirect_uri=https%3A%2F%2Freports.example.com%2Foauth%2Fcallback
Authorization: Basic is client_secret_basic (RFC 6749 §2.3.1); stronger options are private_key_jwt (RFC 7523 §2.2) and mutual TLS (RFC 8705 §2). The redirect_uri line is required in OAuth 2.0 when it was sent in message 1, and dropped from the code exchange in OAuth 2.1 (draft §10). The verifier and challenge are the illustrative values from RFC 7636 Appendix B.
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
{
"access_token": "eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImFzLWtleS0yMDI2LTEwIn0.eyJpc3MiOi...sig",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA-example",
"scope": "invoices.read"
}
| Field | Meaning | Spec |
|---|---|---|
access_token |
Opaque to the client. Here a JWT under RFC 9068, decoded below. | RFC 6749 §5.1 |
token_type |
Bearer (RFC 6750) or DPoP (RFC 9449). |
RFC 6749 §7.1 |
expires_in |
Lifetime in seconds. | RFC 6749 §5.1 |
refresh_token |
Long-lived credential for message 6. | RFC 6749 §1.5 |
scope |
Granted scope, which may be narrower than requested. Read it. | RFC 6749 §5.1, §3.3 |
Cache-Control: no-store |
Tokens must not be cached by intermediaries. | RFC 6749 §5.1 |
An error looks like {"error":"invalid_grant","error_description":"code already used"} with HTTP 400 (RFC 6749 §5.2).
Message 5: client credentials (no user)
POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=invoices.export
&resource=https%3A%2F%2Fapi.example.com%2Finvoices
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJFUzI1NiIsImtpZCI6ImJhdGNoLWtleS0xIn0.eyJpc3MiOiJiYXRjaC1leHBvcnRlci1leGFtcGxlIn0.c2ln
No browser, no code, no refresh token is expected (RFC 6749 §4.4.3). The client authenticates directly, here with a signed JWT whose iss and sub are its client_id and whose aud is this AS (RFC 7523 §3). Cache the token until shortly before expires_in rather than requesting one per call. For workloads that already hold a platform identity, workload identity federation replaces the client secret entirely.
Message 6: refresh, with rotation
POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=tGzv3JOkF0XG5Qx2TlKWIA-example
&client_id=reports-web-example
With rotation the response carries a new refresh_token and the old one stops working. If the old one is presented again, the AS treats it as theft and revokes the whole family. For public clients, RFC 9700 §4.14.2 requires refresh tokens to be either sender-constrained or rotated, and OAuth 2.1 carries that forward (draft §10, §4.3.3). The scope of a new refresh token must equal the old one unless the client asked to reduce it.
Messages 7 and 8: calling the API
GET /invoices?status=open HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6ImFzLWtleS0yMDI2LTEwIn0.eyJpc3MiOi...sig
The header form is the one to use (RFC 6750 §2.1). Tokens in the URL query are forbidden in OAuth 2.1 and discouraged by RFC 9700 §4.3.2 because URLs end up in logs and Referer headers. A rejection looks like:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="invoices", error="invalid_token",
error_description="token expired"
Error codes are invalid_request (400), invalid_token (401) and insufficient_scope (403) (RFC 6750 §3.1).
The JWT access token, decoded
{ "typ": "at+jwt", "alg": "ES256", "kid": "as-key-2026-10" }
{
"iss": "https://as.example.com",
"sub": "5ba552d67-example",
"aud": "https://api.example.com/invoices",
"client_id": "reports-web-example",
"scope": "invoices.read",
"exp": 1793887931,
"iat": 1793887331,
"jti": "dbe39bf3a3ba4238a513f51d6e1691c4-example"
}
| Claim | Meaning | Spec |
|---|---|---|
typ: at+jwt |
Distinguishes access tokens from ID tokens, so one cannot be used as the other. | RFC 9068 §2.1 |
iss, exp, iat, jti |
Issuer, expiry, issue time, unique ID; all required. | RFC 9068 §2.2 |
aud |
The resource indicator of this API. | RFC 9068 §2.2, RFC 8707 |
sub |
The user, or the client itself for client credentials. | RFC 9068 §2.2 |
client_id |
Which client obtained the token. | RFC 9068 §2.2 |
scope |
What the token permits. | RFC 9068 §2.2.3 |
Opaque tokens are checked with the introspection endpoint instead: POST /introspect with token=..., authenticated as the API, returns {"active":true,"scope":"invoices.read","client_id":"reports-web-example","aud":"https://api.example.com/invoices","exp":1793887931} (RFC 7662 §2.1–2.2).
Sender-constrained tokens with DPoP
With DPoP, the client sends a fresh proof JWT signed by its own key on every request, and the access token is bound to that key's thumbprint (cnf.jkt, RFC 9449 §6.1):
GET /invoices HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJ0eXAiOiJhdCtqd3Qi...sig
DPoP: eyJ0eXAiOiJkcG9wK2p3dCIsImFsZyI6IkVTMjU2IiwiandrIjp7Imt0eSI6IkVDIn19.eyJqdGkiOiJlMWozVl9iS2ljOC1MQUVCIiwiaHRtIjoiR0VUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vaW52b2ljZXMifQ.c2ln
The proof's header has typ: dpop+jwt, alg and the public jwk; its payload has jti, htm (method), htu (URL), iat and, at an API, ath (hash of the access token) (RFC 9449 §4.2). A stolen DPoP-bound token is useless without the private key. Mutual TLS binding (RFC 8705 §3) achieves the same with a client certificate.
Who checks what
Authorization server
redirect_uriexactly matches a registered value (RFC 9700 §2.1; 2.1 §4.1.1).- PKCE:
BASE64URL(SHA256(code_verifier))equals the storedcode_challenge, andplainis refused (RFC 7636 §4.6; 2.1 §10). Refusing requests without PKCE also blocks the PKCE downgrade. - Codes are single-use, short-lived and bound to the client they were issued to (RFC 6749 §4.1.2, §4.1.3).
Client
statematches, andissmatches when present (RFC 6749 §10.12, RFC 9207 §2.4).- Read the granted
scopein the token response, and store tokens server-side or in a backend-for-frontend, never inlocalStorage.
API (resource server), from RFC 9068 §4:
typisat+jwt;issexactly matches the AS;audcontains this API's identifier.- Signature verifies with the AS's published keys;
algis on your allow-list and nevernone. exphas not passed (small skew only), then enforcescopeper endpoint. If the token is DPoP-bound, run the twelve proof checks in RFC 9449 §4.3, includingath.
Common implementation mistakes, with the CVEs and techniques behind them
| Mistake | What goes wrong | Real examples |
|---|---|---|
| API accepts any token, or skips validation when a lookup fails | Fabricated bearer tokens reach protected tools | CVE-2026-59822 (LiteLLM MCP gateway, exploited) |
| Auth bypass rules matched against the wrong part of the URL | A crafted query string skips authentication | CVE-2025-54576 (OAuth2-Proxy) |
| Token endpoint URL built from tenant-controlled input | The client secret is posted to an attacker's host | CVE-2026-89111 (SAP Cloud SDK for Python) |
| Token cache keyed loosely | One client's token attached to another client's request | CVE-2025-26620 (Duende) |
| Signature and audience checks with gaps | Forged or misdirected tokens accepted | CVE-2026-55040 (SharePoint), CVE-2025-27371 |
| Authorization policy evaluated incorrectly | Scope or permission escalation | CVE-2025-14778 (Keycloak UMA), technique: scope escalation through delegation |
| Long-lived, unbound refresh tokens | Stolen refresh token works for weeks | refresh token abuse, token replay at an unbound endpoint |
| Users approve a malicious client | The protocol works perfectly for the attacker | OAuth consent phishing, device code phishing |
The last row is the hard one: consent and device-code phishing exploit OAuth working as designed. The defenses are admin-consent policies, publisher verification, blocking the device authorization grant where it is not needed, and short token lifetimes.
Try it
Paste an access token into the JWT decoder and check typ, iss, aud, exp and scope against the table above. If your authorization server also publishes OpenID metadata, run it through the OIDC discovery checker to confirm PKCE S256 support and the issuer string. The protect an API with OAuth scopes recipe turns the resource-server checks into code.
Related
OAuth 2.0, OAuth 2.1, OIDC message flow, OAuth vs OIDC, implementing OAuth 2.0 and OIDC, MCP authorization. Glossary: access token, refresh token, bearer token, scope, token introspection, mTLS.
Frequently asked questions
- What is OAuth 2.0, in one sentence?
- OAuth 2.0 is the IETF framework (RFC 6749) that lets a client application obtain a scoped, expiring access token from an authorization server and present it to an API, so the application never handles the user's password and the API can check exactly what the client was allowed to do.
- What messages make up the OAuth authorization code flow?
- A browser redirect to the authorization endpoint carrying response_type=code, client_id, redirect_uri, scope, state and a PKCE code_challenge; a redirect back to the client with a one-time code; a back-channel POST to the token endpoint with the code and the PKCE code_verifier; a JSON token response; and then API calls carrying the access token in the Authorization header.
- What does OAuth 2.1 change on the wire?
- The draft OAuth 2.1 (draft-ietf-oauth-v2-1-17, October 2026) makes PKCE required by default, removes the implicit and resource owner password grants and the PKCE plain method, requires exact string matching of redirect URIs, forbids bearer tokens in query strings, requires public-client refresh tokens to be sender-constrained or one-time-use, and drops redirect_uri from the code exchange request.
- What must an API check on every access token?
- For JWT access tokens, RFC 9068 section 4 requires the resource server to check that typ is at+jwt, that iss exactly matches the authorization server, that aud names this resource server, that the signature verifies with the authorization server's keys and alg is not none, and that exp has not passed. Then it applies the scopes. For opaque tokens, the API calls the introspection endpoint defined in RFC 7662 and checks active, aud and scope.
Related on Start with Identity
- GuideHow WebAuthn and Passkeys Work, Message by Message
WebAuthn on the wire: registration options, the attestation response, decoded clientDataJSON and authenticator data, authentication options and the assertion, w
- GuideHow Kerberos Works, Message by Message
Kerberos V5 on the wire: AS-REQ, pre-authentication, AS-REP, TGS-REQ and TGS-REP, AP-REQ and AP-REP, with each ASN.1 field tied to its RFC 4120 section, the che
- GuideHow LDAP Works, Message by Message
LDAP on the wire: StartTLS, the service-account bind, the user search, the user bind and the result codes, with each LDAPMessage field tied to RFC 4511, RFC 451
- BlogAuthMask flaw let any Android app pull work-account tokens from Microsoft Authenticator
Enclave disclosed on October 23 that Microsoft Authenticator for Android trusted a caller's claimed identity, so a zero-permission app could pose as Teams and r
- BlogConsentFix: hijacking Microsoft 365 through the OAuth consent flow
ConsentFix adapts the ClickFix pattern to identity. Instead of running a command on the victim's machine, it walks them through an OAuth consent flow and asks t
- CVEDrupal Simple OAuth/OIDC auth bypass via an alternate path
Drupal Simple OAuth / OIDC 6.0.0 through 6.0.6 allowed authentication to be skipped on an alternate path. Patched in 6.0.7.