Authorization Code and PKCE
|
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. |
The authorization code grant (RFC 6749 §4.1) is the flow every other flow on this site is measured against:
a redirect-capable user agent (almost always a browser) carries an opaque, single-use authorization code from
the authorization server back to the client, and the client exchanges that code for tokens on a back channel
the user agent never touches. This page works through every parameter at the wire level — the authorization
request, the redirect response, the token request, the token response, and every error code — then covers
PKCE (RFC 7636), which the in-progress OAuth 2.1 consolidation (draft-ietf-oauth-v2-1-16, 3 September 2026)
makes mandatory for every client, confidential or not.
The round trip in one picture
The authorization request
The client redirects the resource owner’s user agent to the authorization server’s /authorize endpoint as a
plain GET with query parameters. Nothing in this request is confidential — it travels through the browser’s
address bar, history, and any proxy in front of it — so it must never carry a secret.
GET /authorize?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&scope=openid%20profile%20orders.read
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&nonce=n-0S6_WzA2Mj
&prompt=login
&login_hint=alice%40example.com
&max_age=3600
&acr_values=urn%3Amace%3Aincommon%3Aiap%3Asilver
&response_mode=query HTTP/1.1
Host: as.example.com
| Parameter | Meaning |
|---|---|
|
|
|
The client’s identifier, issued at registration. Not secret; it identifies which registered client configuration (redirect URIs, allowed scopes) applies. |
|
Where the authorization response is sent. Must match, byte-for-byte, one of the URIs registered for this |
|
Space-delimited list of the permissions requested. |
|
An opaque value the client generates and later verifies unchanged on return — the CSRF defence for the authorization response. See state vs. nonce vs. PKCE. |
|
The PKCE challenge derived from a client-generated secret; see PKCE. |
|
|
|
An OIDC-only value echoed inside the ID token to bind it to this specific authorization request, defending against ID-token replay. See state vs. nonce vs. PKCE. |
|
Controls the authorization server’s UI: |
|
A hint (username, e-mail) to pre-fill the login form; never a substitute for actual authentication. |
|
Maximum acceptable time, in seconds, since the resource owner last actively authenticated; forces re-authentication if exceeded. Returned to the client as |
|
Space-delimited, preference-ordered list of requested Authentication Context Class Reference values — how the client asks for a particular authentication strength (e.g. MFA) without knowing which specific method the authorization server will use. See Authentication Methods, 2FA and Passwordless. |
|
How the authorization response parameters are returned: |
|
|
The authorization response
On success, the authorization server redirects back to redirect_uri with the code and the client’s own
state echoed unchanged, plus iss identifying which authorization server issued the response (RFC 9207) — the defence against mix-up attacks, covered in full on
Security Best Practices.
HTTP/1.1 302 Found
Location: https://client.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
&iss=https%3A%2F%2Fas.example.com
The client must verify, before doing anything else with the response:
-
stateequals the value it generated for this request (a mismatch means the response does not belong to this browser session — reject it). -
iss, when present, equals the issuer this client expected to talk to (defeats mix-up attacks where two authorization servers share a client and the wrong one’s response is delivered). -
The redirect landed on exactly the
redirect_urithis client registered — enforced by the authorization server, but worth defence-in-depth logging on the client side too.
Error responses
If the authorization request itself is malformed or refused, the authorization server redirects back the same
way with error (and, if it can, state) instead of code:
HTTP/1.1 302 Found
Location: https://client.example.com/callback?error=access_denied
&error_description=The%20resource%20owner%20denied%20the%20request
&state=af0ifjsldkj
error |
Meaning |
|---|---|
|
Missing a required parameter, a parameter appears twice, or the request is otherwise malformed. |
|
This client is not authorized to use the authorization code grant. |
|
The resource owner or the authorization server denied the request (the user clicked "Deny"). |
|
The authorization server does not support |
|
The requested scope is invalid, unknown, or exceeds what the client may request. |
|
The authorization server hit an unexpected condition. |
|
The authorization server is overloaded or under maintenance — retry later. |
A malformed redirect_uri or unknown client_id is a special case: the authorization server must not
redirect at all (there is nowhere safe to send the error), and instead renders an error page directly, to avoid
becoming an open redirector.
The token request
The user agent hands the code to the client at redirect_uri; the client then exchanges it for tokens with a
direct, server-to-server POST that never touches the browser. This is where the client authenticates itself
(see Client Credentials and Client
Authentication for the full comparison of methods) and where the PKCE code_verifier is presented.
POST /token HTTP/1.1
Host: as.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW # client_secret_basic, confidential clients only
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback
&client_id=s6BhdRkqt3
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
redirect_uri must be repeated here identically to the value sent in the authorization request — it is not
used for another redirect, it is compared by the authorization server as proof the code redemption request
comes from the same client that started the flow. client_id is required even for confidential clients that
also authenticate via Authorization: Basic, since some authentication methods (e.g. private_key_jwt) do not
carry it implicitly.
The token response
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache
{
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "Bearer",
"expires_in": 600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA",
"scope": "openid profile orders.read",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}
Cache-Control: no-store and Pragma: no-cache are mandatory on this response — tokens must never end up in a
shared cache. id_token is present only when scope included openid. See
Access and Refresh Tokens and
ID Tokens vs. Access Tokens for what each field means and
how each token is validated.
Token endpoint error responses
HTTP/1.1 400 Bad Request
Content-Type: application/json
Cache-Control: no-store
{
"error": "invalid_grant",
"error_description": "The authorization code has already been used"
}
error |
Meaning |
|---|---|
|
Missing or duplicated parameter, or the request is otherwise malformed. |
|
Client authentication failed (bad secret, unknown |
|
The code is invalid, expired, already redeemed, issued to a different client, or the |
|
This client is not authorized to use the authorization code grant. |
|
The authorization server does not support |
|
The requested scope exceeds what was granted at authorization time. |
|
An authorization code is single-use and short-lived (RFC 6749 recommends 10 minutes maximum; most deployments use far less). A code redeemed twice is a strong signal of interception or replay — RFC 9700 recommends treating a second redemption attempt as evidence of compromise and revoking every token already issued from that code, exactly like the refresh-token reuse-detection rule on Access and Refresh Tokens. |
PKCE
PKCE — Proof Key for Code Exchange, RFC 7636, pronounced "pixy" — closes a gap the base authorization code flow leaves open: without it, anything that can observe or intercept the authorization response (a malicious app registered for the same custom URI scheme on a mobile device, a network intermediary, a misconfigured redirect) can steal the authorization code and redeem it itself, because redeeming a code required nothing more than knowing it and, for a public client, no secret was ever in play to prevent that.
PKCE fixes this by binding the code to a secret only the client that started the flow knows:
-
The client generates a
code_verifier: a cryptographically random string, 43-128 characters, from the unreserved URI character set. -
The client derives a
code_challengefrom it —code_challenge = BASE64URL(SHA256(code_verifier))whencode_challenge_method=S256— and sends only the challenge in the authorization request. The verifier itself never leaves the client at this stage. -
The authorization server stores the challenge alongside the issued code.
-
At the token request, the client sends the raw
code_verifier. The authorization server recomputesBASE64URL(SHA256(code_verifier))and compares it to the stored challenge; a mismatch fails the exchange withinvalid_grant.
Whoever intercepts the authorization code in transit does not have the code_verifier — it was never sent
over that channel — so the stolen code is worthless to them. This is exactly the authorization-code
interception attack PKCE was designed to stop.
|
|
Why OAuth 2.1 requires PKCE even for confidential clients
RFC 6749 originally scoped PKCE as protection for public clients, since a confidential client’s own client
authentication at the token endpoint already stops a third party from redeeming a stolen code — the attacker
would still need the client secret. In practice this distinction has proven fragile: confidential-client secrets
leak (misconfigured repositories, logging, server-side request forgery reading environment variables), multiple
server instances sometimes share a secret in ways that widen its blast radius, and there is no operational cost
to requiring PKCE everywhere — it is one extra pair of values the client already has the machinery to generate.
The in-progress OAuth 2.1 consolidation (draft-ietf-oauth-v2-1-16) removes the distinction and mandates PKCE
for every authorization code request, confidential or public, precisely because defence-in-depth against a
leaked secret costs nothing and closes an entire class of code-interception incidents outright, rather than
leaving them contingent on a second control staying intact.
Spring Authorization Server does not wait for
the draft to become a standard: it already requires PKCE by default for public clients and supports it
transparently for confidential ones, so a Spring-issued authorization server is already aligned with where
OAuth 2.1 is heading. See that page for the RegisteredClient configuration; it is not repeated here.
state vs. nonce vs. PKCE
These three values look similar — all three are client-generated, opaque-looking strings carried through the authorization request — but they protect three different things, and none of them substitutes for another.
| Value | Protects against | Verified by |
|---|---|---|
|
CSRF against the authorization response — an attacker tricking a victim’s browser into completing an authorization flow the attacker initiated, so the victim’s session gets linked to the attacker’s account. |
The client, comparing the returned |
|
Replay of a stolen or reused ID token — an attacker who obtains a previously issued ID token presenting it again to claim a fresh authentication event happened. |
The client, comparing the |
PKCE ( |
Theft or interception of the authorization code itself between the authorization response and the token request. |
The authorization server, recomputing the challenge from the verifier presented at the token endpoint. |
All three are typically in play together on any OIDC authorization code request, and none is optional in a
hardened deployment: skipping state reopens CSRF, skipping nonce on an OIDC request reopens ID-token replay,
and skipping PKCE reopens code interception — three independent attacks with three independent, cheap defences.
Exact redirect-URI matching
RFC 9700 and OAuth 2.1 both mandate exact string matching of redirect_uri against the registered value — scheme, host, port, and path all compared byte-for-byte, with no wildcard subdomains and no pattern matching on
the path. Query strings are the one place implementations vary: RFC 6749 allows a registered URI to be a
prefix with the client appending additional query parameters, but this has been the source of real
vulnerabilities (an attacker appending their own query parameters to redirect a code to a different endpoint on
the same host), so current guidance — and OAuth 2.1 — treats the full registered URI, query string included,
as the only acceptable match. Loosely matched redirect URIs are consistently the root cause behind open
redirector and code-theft incidents; see Security Best
Practices for the exact attack shapes this closes.
The OIDC hybrid flow, briefly
OpenID Connect defines hybrid response_type values (code id_token, code token, code id_token token)
that return some tokens directly in the authorization response fragment alongside the authorization code — originally useful when a client wanted an ID token immediately, before completing the code exchange, to render
a signed-in UI state without waiting on a round trip. The hybrid flow inherits the front-channel token exposure
that motivates removing the pure implicit grant (see
Legacy Implicit and Password Grants) for whichever
tokens it returns directly, so current guidance treats it as a narrow, mostly legacy option: prefer the plain
authorization code flow and read the ID token only after the code exchange completes, at which point the ID
token was delivered over the back channel like everything else in this page. Full hybrid-flow mechanics are
covered on OpenID Connect.