An OAuth authorization code looks small enough to misclassify.

com.example.app:/oauth2redirect?code=abc123

It is tempting to treat that code like the prize. But in the authorization code flow, the code is supposed to be an intermediate object. RFC 6749 says it should expire shortly, must not be used more than once, and is bound to the client identifier and redirection URI.1

PKCE adds one more binding:

this code belongs to the party that can later produce this verifier

That changes the operational question. If another app, browser extension, log line, proxy, or debug tool sees the redirect code, can it turn that sighting into a token?

With PKCE done correctly, possession of the code is not enough.

The Verifier Stays Behind

RFC 7636 defines two names that are easy to blur:

code_verifier   random secret generated by the client
code_challenge  value derived from the verifier and sent in the auth request

The verifier is a high-entropy string, using unreserved URI characters, with a length between 43 and 128 characters.2 The client creates a challenge from it. The important modern method is S256:

code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))

The authorization request carries the challenge:

GET /authorize?
  response_type=code
  client_id=native-photo-app
  redirect_uri=com.example.app:/oauth2redirect
  code_challenge=H(...)
  code_challenge_method=S256

The redirect later carries the code:

com.example.app:/oauth2redirect?code=...

The token request carries the verifier:

POST /token
grant_type=authorization_code
code=...
redirect_uri=com.example.app:/oauth2redirect
client_id=native-photo-app
code_verifier=...

The authorization server recalculates the challenge from the verifier and compares it to the challenge it stored with the code.3 That is the whole move.

The verifier is not a client secret. It is not stable. It is not a password for the app. It is a one-flow proof that the party redeeming the code is the same party that started the authorization request.

Why Public Clients Needed This

The authorization code grant was originally optimized for clients that could authenticate at the token endpoint. RFC 6749 highlights that the code flow keeps the access token out of the user-agent and allows client authentication.4

Native apps and browser-based apps have a different problem. They are public clients: they cannot keep a long-lived client secret from the device or browser environment they run in. RFC 8252, the native-app best current practice, says native-app authorization requests should use external user-agents, primarily the user’s browser.5 It also says native apps must not use embedded user-agents for authorization requests because the host app can observe credentials, form entries, and cookies.6

External browsers improve the login boundary, but they create a redirect handoff. On mobile and desktop systems, custom schemes and app links can be misconfigured, raced, logged, or intercepted. PKCE is the small proof attached to that handoff.

Without it, a public client that receives an authorization code may be holding something close to a bearer credential.

Lab: Code Theft Versus Proof Theft

The lab below implements a tiny authorization server:

  • it issues authorization codes;
  • it stores an optional code_challenge and method with each code;
  • it enforces exact client_id and redirect_uri matching;
  • it spends each code at most once;
  • it implements real S256 using SHA-256 and base64url;
  • it simulates three attacker views.

The default run uses S256, an attacker who sees only the redirect code, and a server with a downgrade guard. The attacker tries to redeem the stolen code first. The token endpoint rejects the request because there is no matching verifier. The legitimate client then redeems the same code with the real verifier.

Default metric Value
Authorization code issued 1
Code bound to PKCE challenge 1
Attacker token 0
Victim client token 1
Injected attacker token 0
Blocked steps 1

Switch Proof to no PKCE. The attacker token flips to yes: the first caller to the token endpoint spends the code. Switch Proof to plain PKCE and Attacker to sees request + code. The attacker token again flips to yes, because the plain challenge is the verifier. Switch back to S256: seeing the challenge still does not reveal the verifier.

code boundno
attacker tokenno
client tokenno
injected tokenno
blocked steps0
auditpasses

S256 keeps the verifier out of the redirect. An intercepted code alone is not enough.

Reproducing The Artifact

The simulator exports a small Node-testable API:

node - <<'NODE'
const lab = require("./assets/js/oauth-pkce-lab.js");
const EXPECTED_VECTOR = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
const EXPECTED_DEFAULT_SUMMARY = {
  authCodeIssued: 1,
  codeBound: 1,
  attackerToken: 0,
  clientToken: 1,
  injectedToken: 0,
  blocked: 1,
  injected: 0
};
const EXPECTED_TOTALS = {
  checked: 27,
  rows: 27,
  proofs: 3,
  passed: 27,
  total: 27,
  passedChecks: 216,
  totalChecks: 216
};
const EXPECTED_BY_PROOF = [
  "none:9:4:0:2:9:72/72",
  "plain:9:3:4:1:9:72/72",
  "S256:9:0:7:1:9:72/72"
];
const EXPECTED_CASE_ROWS = [
  "none/codeOnly/optional:1:0:1:0:0:1:8/8:true",
  "none/codeOnly/downgradeGuard:1:0:1:0:0:1:8/8:true",
  "none/codeOnly/requirePkce:0:0:0:0:0:1:8/8:true",
  "none/requestAndCode/optional:1:0:1:0:0:1:8/8:true",
  "none/requestAndCode/downgradeGuard:1:0:1:0:0:1:8/8:true",
  "none/requestAndCode/requirePkce:0:0:0:0:0:1:8/8:true",
  "none/injectedUnbound/optional:1:0:0:0:1:0:8/8:true",
  "none/injectedUnbound/downgradeGuard:1:0:0:0:1:0:8/8:true",
  "none/injectedUnbound/requirePkce:0:0:0:0:0:2:8/8:true",
  "plain/codeOnly/optional:1:1:0:1:0:1:8/8:true",
  "plain/codeOnly/downgradeGuard:1:1:0:1:0:1:8/8:true",
  "plain/codeOnly/requirePkce:1:1:0:1:0:1:8/8:true",
  "plain/requestAndCode/optional:1:1:1:0:0:1:8/8:true",
  "plain/requestAndCode/downgradeGuard:1:1:1:0:0:1:8/8:true",
  "plain/requestAndCode/requirePkce:1:1:1:0:0:1:8/8:true",
  "plain/injectedUnbound/optional:1:1:0:0:1:0:8/8:true",
  "plain/injectedUnbound/downgradeGuard:1:1:0:0:0:1:8/8:true",
  "plain/injectedUnbound/requirePkce:1:1:0:1:0:1:8/8:true",
  "S256/codeOnly/optional:1:1:0:1:0:1:8/8:true",
  "S256/codeOnly/downgradeGuard:1:1:0:1:0:1:8/8:true",
  "S256/codeOnly/requirePkce:1:1:0:1:0:1:8/8:true",
  "S256/requestAndCode/optional:1:1:0:1:0:1:8/8:true",
  "S256/requestAndCode/downgradeGuard:1:1:0:1:0:1:8/8:true",
  "S256/requestAndCode/requirePkce:1:1:0:1:0:1:8/8:true",
  "S256/injectedUnbound/optional:1:1:0:0:1:0:8/8:true",
  "S256/injectedUnbound/downgradeGuard:1:1:0:0:0:1:8/8:true",
  "S256/injectedUnbound/requirePkce:1:1:0:1:0:1:8/8:true"
];
const EXPECTED_CHECKS = [
  { name: "s256-rfc7636-vector", passed: true },
  { name: "s256-blocks-intercepted-code", passed: true },
  { name: "s256-allows-legitimate-client", passed: true },
  { name: "plain-observer-can-redeem", passed: true },
  { name: "no-pkce-first-redeemer", passed: true },
  { name: "pkce-required-rejects-no-pkce", passed: true },
  { name: "optional-shows-injected-unbound-risk", passed: true },
  { name: "guarded-server-blocks-injected-unbound", passed: true }
];

function sameJson(actual, expected) {
  return JSON.stringify(actual) === JSON.stringify(expected);
}

function rowDrifts(actualRows, expectedRows) {
  const drifts = [];
  const rows = Math.max(actualRows.length, expectedRows.length);
  for (let index = 0; index < rows; index += 1) {
    const actual = index < actualRows.length ? actualRows[index] : null;
    const expected = index < expectedRows.length ? expectedRows[index] : null;
    if (!sameJson(actual, expected)) {
      drifts.push({ index, actual, expected });
    }
  }
  return drifts;
}

const vector = lab.challengeFor(
  "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
  "S256"
);
const defaultSummary = lab.evaluate({}).summary;
console.log(vector);
console.log(defaultSummary);
const audit = lab.auditOauthPkceLab();
const failed = audit.cases.filter(
  (row) => !row.passed || row.passedChecks !== row.totalChecks
);
const totals = {
  checked: audit.checked,
  rows: audit.cases.length,
  proofs: audit.byProof.length,
  passed: audit.passed,
  total: audit.total,
  passedChecks: audit.passedChecks,
  totalChecks: audit.totalChecks
};
const byProofRows = audit.byProof.map((row) => [
  row.proof,
  row.cases,
  row.attackerTokens,
  row.clientTokens,
  row.injectedTokens,
  row.passed,
  `${row.passedChecks}/${row.totalChecks}`
].join(":"));
const caseRows = audit.cases.map((row) => [
  `${row.proof}/${row.attacker}/${row.server}`,
  row.authCodeIssued,
  row.codeBound,
  row.attackerToken,
  row.clientToken,
  row.injectedToken,
  row.blocked,
  `${row.passedChecks}/${row.totalChecks}`,
  row.passed
].join(":"));
const checkRows = audit.cases.map((row) => ({
  case: `${row.proof}/${row.attacker}/${row.server}`,
  checks: row.checks.map((check) => ({
    name: check.name,
    passed: check.passed
  }))
}));
const expectedCheckRows = EXPECTED_CASE_ROWS.map((row) => ({
  case: row.split(":")[0],
  checks: EXPECTED_CHECKS
}));
const byProofDrifts = rowDrifts(byProofRows, EXPECTED_BY_PROOF);
const caseRowDrifts = rowDrifts(caseRows, EXPECTED_CASE_ROWS);
const checkRowDrifts = rowDrifts(checkRows, expectedCheckRows);
const auditShape = {
  vector,
  defaultSummary,
  totals,
  byProofDrifts,
  caseRowDrifts,
  checkRowDrifts
};
const shapeErrors = [
  vector === EXPECTED_VECTOR ? null : "vector",
  sameJson(defaultSummary, EXPECTED_DEFAULT_SUMMARY) ? null : "defaultSummary",
  sameJson(totals, EXPECTED_TOTALS) ? null : "totals",
  byProofDrifts.length === 0 ? null : "byProofRows",
  caseRowDrifts.length === 0 ? null : "caseRows",
  checkRowDrifts.length === 0 ? null : "checkRows"
].filter(Boolean);
console.table(audit.byProof);
console.table(audit.cases.map((row) => ({
  proof: row.proof,
  attacker: row.attacker,
  server: row.server,
  bound: row.codeBound,
  attackerToken: row.attackerToken,
  clientToken: row.clientToken,
  injectedToken: row.injectedToken,
  blocked: row.blocked,
  checks: `${row.passedChecks}/${row.totalChecks}`,
  passed: row.passed
})));
if (shapeErrors.length) {
  throw new Error(
    `PKCE audit shape drifted in ${shapeErrors.join(", ")}:\n` +
    JSON.stringify(auditShape, null, 2)
  );
}
const summary =
  `${audit.passed}/${audit.total} policy cases and ` +
  `${audit.passedChecks}/${audit.totalChecks} checks passed`;
if (
  failed.length ||
  !audit.ok ||
  audit.passed !== audit.total ||
  audit.passedChecks !== audit.totalChecks
) {
  throw new Error(JSON.stringify({
    failed,
    passed: audit.passed,
    total: audit.total,
    passedChecks: audit.passedChecks,
    totalChecks: audit.totalChecks
  }, null, 2));
}
console.log(summary);
NODE

The S256 line prints the RFC 7636 Appendix B value:

E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM

The default summary is:

{
  "authCodeIssued": 1,
  "codeBound": 1,
  "attackerToken": 0,
  "clientToken": 1,
  "injectedToken": 0,
  "blocked": 1,
  "injected": 0
}

The audit checks all 27 combinations of proof method, attacker view, and server policy, for 216 named checks. It verifies the S256 test vector, the no-PKCE first-redeemer failure mode, the plain request-observer failure mode, the S256 interception success case for the legitimate client, and the injected-unbound code downgrade case.

The Downgrade Edge

RFC 9700 describes a PKCE downgrade attack where an attacker can create a code not bound to any challenge, then cause a client that expects PKCE to redeem that unbound code while sending a verifier.7 A loose server might ignore the verifier because the code has no stored challenge.

That is why the lab has a Server control.

PKCE optional       old behavior; unbound codes are accepted
downgrade guard     reject verifier-with-unbound-code token requests
PKCE required       do not issue unbound codes in the first place

Set Attacker to injects unbound code and Server to PKCE optional. The “injected token” metric turns to yes: the client receives a token, but it belongs to the attacker’s authorization session. Set Server to downgrade guard; the token endpoint rejects the mismatch. Set Server to PKCE required; the attacker cannot obtain the unbound code.

PKCE is not only about a stolen redirect. It is also about refusing to silently fall back to a weaker interpretation when the client and server disagree about whether a proof was required.

What PKCE Does Not Do

PKCE is not user authentication. The authorization server still has to authenticate the user.

PKCE is not authorization policy. The resource server still has to enforce scopes, audience, tenancy, and object-level permissions.

PKCE is not a substitute for redirect URI validation. RFC 6749 requires the authorization server to ensure that the redirect URI used to obtain the code is identical to the one used during token exchange, and to require redirect URI registration for public clients.8

PKCE is not a long-term client secret. A native app cannot hide a durable secret from the environment it runs in. The verifier is useful because it is fresh and because it was not sent through the redirect path.

Evidence, Interpretation, Speculation

Evidence. RFC 7636 defines code_verifier, code_challenge, S256, and the token-endpoint comparison. RFC 6749 defines the authorization code grant, one-time code use, redirect URI binding, and redirect URI attack concerns. RFC 8252 gives native-app browser guidance. RFC 9700 gives current OAuth security best current practice language around PKCE downgrade handling.

Interpretation. PKCE is a receipt system for the authorization code. The authorization endpoint stores a public commitment; the token endpoint asks for the private opening. The authorization code is still important, but it is no longer the whole proof.

Speculation. Many OAuth bugs survive because teams file them under the wrong noun. They ask whether the code was “valid.” A better first question is: valid for whom, at which redirect URI, and with which verifier?

Final Shape

The authorization code is a handoff, not a treasure chest.

PKCE makes the handoff auditable. A code seen in the redirect is only half the story. The other half is a verifier that stayed behind until the token endpoint asked for it.

  1. D. Hardt, RFC 6749, “The OAuth 2.0 Authorization Framework”, October 2012. Section 4.1.2 states that authorization codes should expire shortly, must not be used more than once, and are bound to the client identifier and redirection URI. 

  2. N. Sakimura, J. Bradley, and N. Agarwal, RFC 7636, “Proof Key for Code Exchange by OAuth Public Clients”, September 2015. Section 4.1 defines code_verifier as a high-entropy cryptographic random string of 43 to 128 unreserved characters. 

  3. RFC 7636, Sections 4.2 through 4.6. The S256 transformation is BASE64URL-ENCODE(SHA256(ASCII(code_verifier))); the server stores the challenge with the code and later verifies the token request by recomputing the challenge from code_verifier

  4. RFC 6749, Section 1.3.1. The authorization code is obtained through the authorization server, keeps the resource owner’s credentials away from the client, and lets the access token be transmitted directly to the client rather than through the user-agent. 

  5. W. Denniss and J. Bradley, RFC 8252, “OAuth 2.0 for Native Apps”, October 2017. The abstract states that native-app authorization requests should use external user-agents, primarily the user’s browser. 

  6. RFC 8252, Section 8.12. It requires that native apps must not use embedded user-agents for authorization requests and explains that embedded user-agents can expose user credentials, form entries, and cookies to the hosting app. 

  7. D. Fett, P. Hunt, T. Lodderstedt, M. Jones, and D. Waite, RFC 9700, “Best Current Practice for OAuth 2.0 Security”, January 2025. Section 4.8.2 says authorization servers must reject a token request containing code_verifier if the corresponding authorization request had no code_challenge

  8. RFC 6749, Section 10.6. To prevent redirect URI manipulation, the authorization server must ensure the redirect URI used to obtain the code is identical to the redirect URI used when exchanging it for a token, and must require public clients to register redirect URIs.