# How OAuth 2.0 and OAuth 2.1 Work, Message by Message

Source: https://startwithidentity.com/guides/protocols/oauth-message-flow/
Last updated: 2026-11-05
License: content by Start with Identity. Cite the source URL.

---

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](https://www.rfc-editor.org/rfc/rfc6749) and the [OAuth 2.1 draft (draft-17, 31 October 2026)](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17), 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](https://startwithidentity.com/standards/oauth-2-0/) and [OAuth 2.1](https://startwithidentity.com/standards/oauth-2-1/) standard pages. For the identity layer and the ID token, see [how OIDC works, message by message](https://startwithidentity.com/guides/protocols/oidc-message-flow/).

## The sequence at a glance

| # | Message | OAuth 2.0 | OAuth 2.1 draft-17 |
| --- | --- | --- | --- |
| 1 | Authorization request (browser) | [RFC 6749 §4.1.1](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.1) + [RFC 7636 §4.3](https://www.rfc-editor.org/rfc/rfc7636#section-4.3) | [§4.1.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-4.1.1) |
| 2 | Authorization response (redirect with code) | [§4.1.2](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.2) | [§4.1.2](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-4.1.2) |
| 3 | Token request (code exchange) | [§4.1.3](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3) | [§3.2.2, §4.1.3](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-4.1.3) |
| 4 | Token response | [§5.1](https://www.rfc-editor.org/rfc/rfc6749#section-5.1), errors [§5.2](https://www.rfc-editor.org/rfc/rfc6749#section-5.2) | [§3.2.3–3.2.4](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-3.2.3) |
| 5 | Client credentials request | [§4.4.2](https://www.rfc-editor.org/rfc/rfc6749#section-4.4.2) | [§4.2](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-4.2) |
| 6 | Refresh request | [§6](https://www.rfc-editor.org/rfc/rfc6749#section-6) | [§4.3](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-4.3) |
| 7 | API request with bearer token | [RFC 6750 §2.1](https://www.rfc-editor.org/rfc/rfc6750#section-2.1) | [§5.1.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-5.1.1) |
| 8 | API error | [RFC 6750 §3](https://www.rfc-editor.org/rfc/rfc6750#section-3) | [§5.3](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-5.3) |

## Messages 1 and 2: authorization request and response

```http
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](https://www.rfc-editor.org/rfc/rfc9700#section-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](https://www.rfc-editor.org/rfc/rfc9700#section-2.1), [§4.1.3](https://www.rfc-editor.org/rfc/rfc9700#section-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](https://www.rfc-editor.org/rfc/rfc6749#section-3.3) |
| `state` | CSRF protection and client context. | RFC 6749 [§10.12](https://www.rfc-editor.org/rfc/rfc6749#section-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](https://www.rfc-editor.org/rfc/rfc8707#section-2.1) |

The response is a redirect:

```http
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](https://www.rfc-editor.org/rfc/rfc9207#section-2); a client talking to more than one AS must check it to stop mix-up attacks ([RFC 9700 §4.4](https://www.rfc-editor.org/rfc/rfc9700#section-4.4)).

## Messages 3 and 4: token request and response

```http
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](https://www.rfc-editor.org/rfc/rfc6749#section-2.3.1)); stronger options are `private_key_jwt` ([RFC 7523 §2.2](https://www.rfc-editor.org/rfc/rfc7523#section-2.2)) and mutual TLS ([RFC 8705 §2](https://www.rfc-editor.org/rfc/rfc8705#section-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
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](https://www.rfc-editor.org/rfc/rfc6750)) or `DPoP` ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)). | RFC 6749 [§7.1](https://www.rfc-editor.org/rfc/rfc6749#section-7.1) |
| `expires_in` | Lifetime in seconds. | RFC 6749 §5.1 |
| `refresh_token` | Long-lived credential for message 6. | RFC 6749 [§1.5](https://www.rfc-editor.org/rfc/rfc6749#section-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](https://www.rfc-editor.org/rfc/rfc6749#section-5.2)).

## Message 5: client credentials (no user)

```http
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](https://www.rfc-editor.org/rfc/rfc6749#section-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](https://www.rfc-editor.org/rfc/rfc7523#section-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](https://startwithidentity.com/glossary/workload-identity-federation/) replaces the client secret entirely.

## Message 6: refresh, with rotation

```http
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](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-17#section-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

```http
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](https://www.rfc-editor.org/rfc/rfc6750#section-2.1)). Tokens in the URL query are forbidden in OAuth 2.1 and discouraged by [RFC 9700 §4.3.2](https://www.rfc-editor.org/rfc/rfc9700#section-4.3.2) because URLs end up in logs and `Referer` headers. A rejection looks like:

```http
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](https://www.rfc-editor.org/rfc/rfc6750#section-3.1)).

### The JWT access token, decoded

```json
{ "typ": "at+jwt", "alg": "ES256", "kid": "as-key-2026-10" }
```

```json
{
  "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](https://www.rfc-editor.org/rfc/rfc9068#section-2.1) |
| `iss`, `exp`, `iat`, `jti` | Issuer, expiry, issue time, unique ID; all required. | [RFC 9068 §2.2](https://www.rfc-editor.org/rfc/rfc9068#section-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](https://www.rfc-editor.org/rfc/rfc9068#section-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](https://www.rfc-editor.org/rfc/rfc7662#section-2.2)).

### Sender-constrained tokens with DPoP

With [DPoP](https://startwithidentity.com/glossary/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](https://www.rfc-editor.org/rfc/rfc9449#section-6.1)):

```http
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](https://www.rfc-editor.org/rfc/rfc9449#section-4.2)). A stolen DPoP-bound token is useless without the private key. Mutual TLS binding ([RFC 8705 §3](https://www.rfc-editor.org/rfc/rfc8705#section-3)) achieves the same with a client certificate.

## Who checks what

**Authorization server**

1. `redirect_uri` exactly matches a registered value (RFC 9700 §2.1; 2.1 §4.1.1).
2. PKCE: `BASE64URL(SHA256(code_verifier))` equals the stored `code_challenge`, and `plain` is refused ([RFC 7636 §4.6](https://www.rfc-editor.org/rfc/rfc7636#section-4.6); 2.1 §10). Refusing requests without PKCE also blocks the [PKCE downgrade](https://www.rfc-editor.org/rfc/rfc9700#section-4.8).
3. Codes are single-use, short-lived and bound to the client they were issued to (RFC 6749 §4.1.2, §4.1.3).

**Client**

4. `state` matches, and `iss` matches when present (RFC 6749 §10.12, RFC 9207 §2.4).
5. Read the granted `scope` in the token response, and store tokens server-side or in a backend-for-frontend, never in `localStorage`.

**API (resource server)**, from [RFC 9068 §4](https://www.rfc-editor.org/rfc/rfc9068#section-4):

6. `typ` is `at+jwt`; `iss` exactly matches the AS; `aud` contains this API's identifier.
7. Signature verifies with the AS's published keys; `alg` is on your allow-list and never `none`.
8. `exp` has not passed (small skew only), then enforce `scope` per endpoint. If the token is DPoP-bound, run the twelve proof checks in [RFC 9449 §4.3](https://www.rfc-editor.org/rfc/rfc9449#section-4.3), including `ath`.

## 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](https://startwithidentity.com/cves/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](https://startwithidentity.com/cves/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](https://startwithidentity.com/cves/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](https://startwithidentity.com/cves/cve-2025-26620/) (Duende) |
| Signature and audience checks with gaps | Forged or misdirected tokens accepted | [CVE-2026-55040](https://startwithidentity.com/cves/cve-2026-55040/) (SharePoint), [CVE-2025-27371](https://startwithidentity.com/cves/cve-2025-27371/) |
| Authorization policy evaluated incorrectly | Scope or permission escalation | [CVE-2025-14778](https://startwithidentity.com/cves/cve-2025-14778/) (Keycloak UMA), technique: [scope escalation through delegation](https://startwithidentity.com/techniques/scope-escalation-delegation/) |
| Long-lived, unbound refresh tokens | Stolen refresh token works for weeks | [refresh token abuse](https://startwithidentity.com/techniques/refresh-token-abuse/), [token replay at an unbound endpoint](https://startwithidentity.com/techniques/token-replay-unbound-endpoint/) |
| Users approve a malicious client | The protocol works perfectly for the attacker | [OAuth consent phishing](https://startwithidentity.com/techniques/oauth-consent-phishing/), [device code phishing](https://startwithidentity.com/techniques/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](https://startwithidentity.com/tools/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](https://startwithidentity.com/tools/oidc-discovery-checker/) to confirm PKCE `S256` support and the issuer string. The [protect an API with OAuth scopes recipe](https://startwithidentity.com/recipes/protect-an-api-with-oauth-scopes/) turns the resource-server checks into code.

## Related

[OAuth 2.0](https://startwithidentity.com/standards/oauth-2-0/), [OAuth 2.1](https://startwithidentity.com/standards/oauth-2-1/), [OIDC message flow](https://startwithidentity.com/guides/protocols/oidc-message-flow/), [OAuth vs OIDC](https://startwithidentity.com/guides/fundamentals/oauth-vs-oidc/), [implementing OAuth 2.0 and OIDC](https://startwithidentity.com/guides/implementation/oauth-2-openid-connect-implementation/), [MCP authorization](https://startwithidentity.com/standards/mcp-authorization/). Glossary: [access token](https://startwithidentity.com/glossary/access-token/), [refresh token](https://startwithidentity.com/glossary/refresh-token/), [bearer token](https://startwithidentity.com/glossary/bearer-token/), [scope](https://startwithidentity.com/glossary/scope/), [token introspection](https://startwithidentity.com/glossary/token-introspection/), [mTLS](https://startwithidentity.com/glossary/mtls/).
