Chapter 2 of 8 · 16 min read
PKCE, state and redirect_uri
On this page
An authorization-code flow carries three parameters that are not the credential and not the identity: code_challenge, state and redirect_uri. They are habitually described together as "the OAuth hardening parameters", which encourages the belief that they are interchangeable layers of one defence. They are not. Each one closes a specific gap between the moment a user approves a request and the moment a token is issued, and an implementation carrying two of the three has an opening the other two say nothing about.
The gaps exist because an authorization code makes a journey. It is created by the authorization server, delivered through a browser to a redirect URI, and then presented back to a token endpoint by whatever software received it — three hops, on two different channels, with at least one of them under an operating system's control rather than yours.
Three bindings, three different attackers
| Control | Binds | Enforced by | Attack it defeats | If you omit it |
|---|---|---|---|---|
PKCE (code_challenge / code_verifier) | The authorization code to the client instance that started the flow | Authorization server, at the token endpoint | Interception of the code in transit — a hijacked URL scheme, a leaked browser log, a malicious app on the same device | A stolen code is redeemable by anyone who has it |
state | The callback to the browser session that initiated the flow | The client, on receiving the redirect | Injection of an attacker's code into the victim's client, so the victim's session ends up bound to the attacker's account | Any code delivered to your callback is accepted as yours |
redirect_uri registration | Where a code may be delivered for a given client id | Authorization server, at the authorization endpoint | Redirecting the code away from the legitimate client to a host the attacker owns | Open redirect: a crafted authorization URL sends codes anywhere |
Reading down the "enforced by" column explains why these cannot substitute for one another. Two of the three are checks the authorization server performs and your client cannot influence, and one is a check only your client can perform because only your client knows what it sent. A client that validates state diligently and forgets PKCE has hardened the half of the flow it can see and left the half it cannot.
The code is worthless to whoever did not start the flow
PKCE, which stands for Proof Key for Code Exchange and is specified in RFC 7636 and mandatory in OAuth 2.1 for every client including confidential ones, works by having the client commit to a secret before the flow starts and reveal it only when redeeming the code. The commitment travels through the browser where an attacker may see it, and the secret never does.
import { createHash, randomBytes } from "node:crypto";
function base64url(buf: Buffer): string {
return buf.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
export function createPkcePair(): { verifier: string; challenge: string } {
// 32 random bytes -> 43 base64url characters, the minimum RFC 7636 allows.
const verifier = base64url(randomBytes(32));
const challenge = base64url(createHash("sha256").update(verifier).digest());
return { verifier, challenge };
}The comment in that function hides a small piece of arithmetic worth doing, because it explains why thirty-two is the number rather than a round guess. Thirty-two bytes at eight bits each is two hundred and fifty-six bits of randomness, which is the same width as the SHA-256 digest they will be hashed into, so nothing is wasted at either end. Base64url encodes three bytes as four characters, and thirty-two bytes is ten full groups of three with two bytes left over. Ten groups give forty characters; the two remaining bytes give three more. Forty-three — which is the shortest verifier RFC 7636 permits, arrived at from below rather than trimmed to fit. A shorter verifier would be a smaller secret, and a longer one would add nothing, because the comparison happens through a digest two hundred and fifty-six bits wide whatever is fed into it.
The authorization request carries code_challenge=<challenge> and code_challenge_method=S256. The token request carries code_verifier=<verifier>. The authorization server stores the challenge against the issued code, hashes the verifier it receives, and compares. An attacker who intercepts the redirect sees the code and, if they also captured the authorization request, the challenge — and inverting SHA-256 to recover a 256-bit verifier is not an available move. What they get when they try to redeem it is this, and it is the whole of one control doing one job:
{
"error": "invalid_grant",
"error_description": "PKCE verification failed: code_challenge does not match code_verifier"
}The step that makes that refusal work, rather than merely happen, is that the attacker is being asked for something they cannot compute. They hold the code, and if they captured the authorization request they hold the challenge too. The token endpoint takes whatever verifier they supply, hashes it with SHA-256, and compares the result against the challenge stored against that code. Passing would mean producing an input whose digest equals a digest they already have, which is the preimage problem SHA-256 exists to make impractical. Guessing is no better, because the verifier is two hundred and fifty-six bits drawn at random and knowing the challenge does not shrink that space. So the code in their hands is not a weakened credential, it is an inert one — inert for whoever holds it rather than for whoever the server believes they are, which is why the protection survives an interception that is total.
The other method the spec defines, code_challenge_method=plain, sends the verifier itself as the challenge and provides no protection whatever against an attacker who saw the authorization request. It exists for constrained devices that cannot compute SHA-256, and there is no reason for an MCP client to accept it. The check on code_challenge_methods_supported from chapter 1, on reading discovery metadata before starting a flow, is the enforcement point: if the array does not contain S256, stop.
PKCE is frequently described as "the replacement for the client secret in public clients", which gets the mechanism right and the guarantee wrong. A client secret authenticates the client as an identity — it proves which registered application is talking. PKCE proves only that the entity redeeming the code is the same entity that made the authorization request, and says nothing about who that entity is. A confidential MCP client should therefore do both, and OAuth 2.1 expects it to: client_secret_basic on the token request establishes identity, PKCE establishes continuity of the flow.
state binds a callback to the session that asked for it
The attack state addresses runs in the opposite direction from code interception. The attacker begins an authorization flow themselves, against their own account at the third party, and stops at the redirect without consuming the code. They then cause the victim's browser to hit the victim's client callback carrying the attacker's code — a link in an email, an image tag, anything that produces a GET. If the client accepts it, the client exchanges the attacker's code, receives a token for the attacker's account, and binds it to the victim's session. From then on, everything the victim does through that integration writes into the attacker's account, including any document, credential or export the agent is asked to file there.
Defence is a value the client generates, holds server-side or in session storage, and compares on return:
type FlowRecord = {
state: string; // 128+ bits, single use
verifier: string; // the PKCE code_verifier
issuer: string; // authorization server this flow was started against
resource: string; // MCP server the token is for
createdAt: number; // epoch ms; reject after ~10 minutes
};
// On callback:
const record = flows.take(params.state); // atomic get-and-delete
if (!record) throw new Error("unknown or replayed state");
if (Date.now() - record.createdAt > 600_000) throw new Error("state expired");Two properties of that snippet matter more than the comparison itself. take removes the record, so a state value works exactly once and a replayed callback finds nothing. And the record carries the verifier, the issuer and the resource, which means the state value is the key to the whole flow context — the client does not have to guess which of several in-flight authorizations a callback belongs to, and it cannot be tricked into redeeming a code from one authorization server against the token endpoint of another.
Two ways of producing the state value defeat it quietly. Deriving it from something the attacker can also compute — a hash of the user id, the session id, a counter, the current minute — means the attacker can construct a callback that validates, and the check becomes an assertion that the attacker did arithmetic correctly. Reusing one value across concurrent flows has the same effect for a different reason: if the client keeps a single pendingState field and the user authorises two servers in quick succession, the second flow overwrites the first, and the client either rejects a legitimate callback or, worse, accepts one flow's code against the other's stored context. Thirty-two bytes from a CSPRNG — a cryptographically secure random generator, meaning crypto.randomBytes and its equivalents rather than Math.random — one record per in-flight flow, deleted on use, is the whole requirement, and it is cheap enough that the shortcuts only ever save typing.
Both of those shortcuts rest on one belief worth naming: that state is a nonce whose job is to be present. It is tempting because the check reads as a formality — generate a value, send it, compare it on return — and because a flow with an entirely predictable state passes every test anyone would think to write, including the ones that deliberately tamper with it. What state has to be is unguessable by the party who wants to hand you a callback, since the attack consists of constructing a callback your client will accept. A value the attacker can compute is a value they can include, so the comparison succeeds and reports only that they did the arithmetic correctly. The property being relied on is secrecy rather than presence, and nothing in the shape of the code says so.
Storing the issuer alongside the state covers a separate attack, the authorization-server mix-up, and it does so because of how the record is used rather than through any property of state itself. When a client talks to several authorization servers, the modern control is RFC 9207: the authorization server returns iss=<its issuer URL> on the redirect, and the client checks it against record.issuer before exchanging anything. Discovery metadata advertises support as authorization_response_iss_parameter_supported: true, and clients that connect to arbitrary MCP servers — meaning arbitrary authorization servers — should check it when it is offered.
redirect_uri is registered where you are not
The third control is the one whose location people misplace. The redirect_uri that matters is the set of URIs registered against your client id at the authorization server, in the record created by the console or by the dynamic registration in chapter 1. When an authorization request arrives, the server compares the requested redirect_uri against that registered set by exact string match, and refuses the request outright if it does not appear. OAuth 2.1 removed wildcard and prefix matching precisely because "starts with https://app.example.com/" turned every open redirect on that host — any endpoint that will forward a visitor to a destination named in its own query string — into a code-exfiltration path.
There is one deliberate relaxation, from RFC 8252: for native applications using loopback redirects, the authorization server must ignore the port. A desktop agent cannot know in advance which ephemeral port it will get, so it registers http://127.0.0.1/callback and receives codes on http://127.0.0.1:33418/callback. The host and path still match exactly, and localhost is not equivalent to 127.0.0.1 — a name that resolves through a DNS server someone else controls is not a loopback guarantee.
Exact matching had to replace prefix matching for a reason that runs in two steps rather than one, and the second step is the one people miss. If a registration of https://app.example.com/ admits any URI beginning with that string, then every endpoint on that host which forwards a visitor to a destination named in its own query string becomes a legal redirect target — and endpoints like that exist on most large sites for entirely innocent reasons, such as a login page returning you to the page you came from, or a link tracker. An attacker who finds one requests a redirect to it, the authorization server accepts because the prefix matches, and the open redirect then forwards the code onward to a host of the attacker's choosing. The registered set has therefore stopped being a list of your endpoints and become a list of your host's endpoints, including every one nobody thought of as authorization infrastructure. Exact matching removes the second step by making the destination a value rather than a pattern, which is why the loopback relaxation is careful to relax only the port and to hold host and path exact.
The failure to avoid is building your own allowlist and believing it is the same control. If your MCP server proxies authorization flows to a third-party SaaS, you may well validate the redirect URIs your own clients request, and that check is worth having. It governs only where your authorization server sends codes for your resource. The registration that decides where the SaaS delivers a code is held by the SaaS, against the client id you registered with them, and nothing you deploy alters it. Chapter 7, on the confused deputy that ownership boundary produces, is entirely about the consequences.
Finding out whether they are switched on
Each of the three fails silently when it is missing, because the flow still works — the user still authenticates, the code still arrives, the token still comes back. The only way to know a control is active is to break it deliberately and see the authorization server refuse. Three tampering tests take about ten minutes against a real deployment and are worth running once per authorization server you integrate with, since the behaviour is a property of theirs and not of your code.
| Tamper with | How | Expected refusal | If it succeeds instead |
|---|---|---|---|
| PKCE | Redeem the code with a freshly generated code_verifier that does not match the challenge you sent | 400 invalid_grant, PKCE verification failed | The server accepted the code without checking; treat codes as bearer credentials and escalate with the vendor |
| PKCE method | Send code_challenge_method=plain on the authorization request | 400 invalid_request, unsupported method | The server permits a downgrade an attacker can also request |
redirect_uri | Request redirect_uri=https://example.org/steal with your real client id | 400 invalid_request rendered on the authorization server's own page, never a redirect | Any host can receive your users' codes |
| Code replay | Redeem the same code twice | 400 invalid_grant on the second, plus revocation of the first token | Codes are multi-use, which makes an interception permanently exploitable |
The last row is a fourth safeguard that rides alongside the three and belongs to the authorization server alone. Authorization codes are single-use and short-lived — RFC 6749 recommends a maximum of ten minutes, and most servers issue 30 to 60 seconds — and a second redemption is supposed to invalidate every token derived from the first, on the assumption that two redemptions mean one of them was not you. It is not a control you can implement or configure, so it is a property to verify rather than a task to do.
Notice which of the three controls has no row in that table. Three of the four tests probe PKCE, its method and the redirect registration, all of which are refusals the authorization server performs; the fourth probes single-use codes, which is theirs as well. state is absent because there is nobody else to test. It is the one binding your own code performs, against a value only your code knows, so no experiment run against the authorization server can report whether you check it — the only way to find out is to read your own callback handler. That is the enforced-by column restated as a consequence. Two of the three controls have an external party who will catch your omission by refusing you; one does not, which means a client that skips state looks exactly like a client that does not, in every environment, until the day somebody uses the gap.
A note on the redirect test: the correct behaviour is that the authorization server displays an error to the user rather than redirecting to the unregistered URI, because redirecting would deliver the error — and possibly, on a sloppy implementation, a code — to a host that has not been authorised to receive anything. If your test browser lands on example.org with an ?error= query string, the server is redirecting to unregistered URIs, and the only question left is whether it does the same with a ?code=.
All three assume the attacker is in somebody else's flow
Read the attack column of the table again and a single shape appears. PKCE assumes a legitimate user started a flow and an attacker got hold of the resulting code. state assumes a legitimate user's client received a callback that came from somewhere else. Registered redirect URIs assume a legitimate user was induced to authorise a request that would deliver its code to the wrong place. In every case the victim is the one who authenticated, and the attacker is trying to get across a boundary from outside the flow.
None of them says anything about an attacker who runs their own flow from beginning to end, with a client id that is legitimately registered, a PKCE pair they generated, a state they will happily validate, and a redirect URI registered to them. Every check passes because every check is designed to detect a discontinuity, and there is no discontinuity — the flow is internally consistent, and what has been subverted is whose consent it rides on. That is the case chapter 7 works through, and it is worth carrying the distinction from here: these three controls establish that a flow is coherent, and coherence is not the same thing as the right user having agreed to the right thing.
Three parameters, three bindings, no overlap worth relying on: the code to the client that started the flow, the callback to the session that asked for it, the delivery address to the client id it was registered against. Two are refusals somebody else performs on your behalf, testable but not implementable by you; one is a comparison only your code can make, which means a client that skips it has no external party to catch the omission.
There is a fourth binding in the authorization request that none of the three touches, and it concerns what the resulting token is allowed to be spent on. A code correctly bound to your client, returned to your registered URI, matching the state you issued, exchanges into a token that may still be the wrong token — signed by the right issuer, unexpired, and refused. That is the resource parameter, and the next chapter reproduces the failure it prevents.
MCP auth — read the current version at https://daviddominguez.dev/learn/en/mcp-auth/pkce-state-and-redirect-uri