Internet Protocols · Identity Protocols
Authorization Code Flow: Why the Redirect Carries a Code and Not a Token
How the front channel and back channel split the grant, what exact-match redirect URI registration protects, and where state and mix-up defences sit in the exchange.
The authorization code flow is usually described as an indirection: instead of returning the token to the browser, the authorization server returns a short string that the client trades for the token over a separate connection. That description is accurate but incomplete, because it does not explain why the indirection is safe. The safety comes from a property of the two channels: the code is useless to anyone who cannot complete the back-channel exchange, and the exchange is where the client proves it is the same client that started the request.
Two requests, two trust levels
The flow begins with the client constructing an authorization request and causing the user agent to follow it. Nothing in this request is authenticated. The client identifier is public, the redirect URI is public, and the request travels through the browser’s address bar, so any script on any page the browser visits later can observe where it went.
GET /authorize?response_type=code
&client_id=s6BhdRkqt3
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
&scope=photos.read
&state=af0ifjsldkj HTTP/1.1
Host: as.example.net
The authorization server authenticates the resource owner, obtains consent, and redirects back with a code. The client then makes a direct HTTPS request to the token endpoint, and this second request is where authentication happens. For a confidential client, that means presenting its secret; for a public client, it means presenting the PKCE verifier, as covered in the code challenge binding.
The asymmetry is the entire design. Front-channel requests are treated as untrusted input. Back-channel requests are treated as coming from a known party, because they arrive over a connection the client controls.
Redirect URI registration is exact string matching
RFC 6749 requires the authorization server to compare the redirect_uri in the request against a pre-registered value. The comparison is defined as simple string comparison, not pattern matching: the values must be identical after the redirect URI is normalised per the URI syntax rules, with no wildcard, no prefix matching, and no allowance for an added trailing slash or a changed port.
This is stricter than most developers expect, and the strictness is deliberate. An open redirector anywhere on the client’s domain would otherwise let an attacker redirect the code to a host they control, where they could attempt the exchange. Prefix matching turns any path on the domain into a valid target; suffix matching turns any subdomain into a valid target. Exact matching means the set of destinations is exactly the set the client registered, and the client can enumerate it.
Two practical consequences follow. The first is that a client cannot vary its redirect URI dynamically, so per-tenant or per-environment callbacks need separate registrations. The second is that the URI must be registered for every deployment, which is why local development commonly uses a separate client entry rather than a wildcard.
Native applications cannot use exact matching for the port, because they bind an ephemeral port chosen by the operating system. RFC 8252 addresses this by permitting the authorization server to match loopback redirect URIs on scheme, host, and path while ignoring the port, and only for the literal loopback addresses. The same document recommends that native applications prefer a claimed https URI scheme handled by the operating system, and states that an embedded user agent inside the application is not acceptable because the application can read the credentials the user types into it.
State, and what it does not protect
The state parameter is an opaque value the client includes in the authorization request and expects to receive back unchanged. RFC 6749 defines it as a means to maintain state between request and callback and to prevent cross-site request forgery, and RFC 9700 recommends at least 128 bits of entropy from a cryptographic random source.
The attack it addresses is straightforward. Without a binding value, an attacker can obtain a valid authorization code for their own account and cause a victim’s browser to deliver that code to the client’s callback. The client exchanges it, receives a token bound to the attacker’s account, and associates it with the victim’s session. The victim then operates inside the attacker’s account, possibly uploading data the attacker later reads. A state value that the client generated and stored in the user’s session before the redirect makes the callback recognisable: a code arriving with a state the client did not issue is discarded.
The parameter is often overloaded to carry the post-login destination, which works because it is opaque to the authorization server, but it must still be unguessable and single-use. A destination path encoded in plaintext is a guessable state value and defeats the purpose.
state does not protect against code interception. If an attacker observes the code in transit or extracts it from a malicious application registered to handle the client’s custom URI scheme, the state value travels with it and validates correctly. Interception is what PKCE exists for.
The exchange and its error cases
The token request is a form-encoded POST with grant_type=authorization_code, the code, the redirect URI repeated exactly as sent, and the client’s authentication. The redirect URI must be repeated because RFC 6749 requires the token endpoint to verify that it matches the one used in the authorization request, which closes a gap where an attacker who obtained a code could redirect it elsewhere.
POST /token HTTP/1.1
Host: as.example.net
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fclient.example.org%2Fcb
A well-behaved authorization server invalidates the code on first use. This matters because the code is a bearer credential on the front channel: if it is replayed, the second exchange either fails or, in the worse case, succeeds and issues a second token. RFC 6749 requires the code to be bound to the client and to be single-use, and requires that a code presented twice be rejected and, where possible, that tokens already issued from it be revoked.
The code lifetime is bounded. The specification recommends a maximum of ten minutes, which is short enough that an intercepted code has a narrow window and long enough that a slow user agent or a brief network stall does not break the flow. The code is opaque and carries no structure the client may interpret.
Errors can arrive in two places. Failures detected at the authorization endpoint are delivered as query parameters on the redirect: error=access_denied, error=invalid_scope, error=unsupported_response_type, plus an error_description and the state echoed back. Failures at the token endpoint are HTTP 400 responses with a JSON body carrying the same error vocabulary plus invalid_grant and invalid_client. The two error surfaces are different in shape, and a client that handles only one of them will mis-handle the other.
Mix-up attacks and the issuer parameter
A client that talks to more than one authorization server has a specific weakness. The attacker registers a client at a second authorization server they control, using the victim client’s redirect URI. The victim client’s authorization request is manipulated so that it goes to the attacker’s authorization server, which returns a code. The client, not knowing which server it is talking to, sends the code and its own client credentials to its token endpoint. The attacker’s server now holds the client’s secret and a valid code.
The defence is to bind the callback to the issuer. RFC 9207 defines the iss authorization response parameter, which the authorization server includes in the redirect, and requires the client to verify that the value matches the issuer it intended to use. Without it, the client must keep its own record of which server each authorization request went to, keyed by state, and reject callbacks whose state maps to a different issuer.
This is a case where the protocol’s own generality creates the hazard: supporting multiple authorization servers is legal, and the base specification did not require any binding between the callback and the server that produced it. Deployments that hardcode a single issuer are unaffected in practice, which is why the parameter is less widely known than its importance suggests.
Where the flow ends
The result of a successful exchange is an access token, optionally a refresh token, and in OpenID Connect an ID token as well. The access token is then presented to the resource server, whose validation options are constrained by the token format chosen; self-contained tokens shift the validation work to every resource server, which is the subject of JWT access token validation.
The flow’s remaining fragility is the browser itself. A redirect leaves the code in history and in the Referer of any resource the callback page loads, so the callback should not load third-party resources before the code is consumed, and should not render anything that reflects the URL. The code is short-lived and single-use, but neither property helps if the exchange has already happened by the time the leak is exploited.