Blog · OAuth & OIDC

OAuth PKCE explained, with no client secret

OAuth PKCE explained: how the code verifier and S256 challenge protect the authorization code flow without a client secret, with JavaScript you can copy.

PKCE (Proof Key for Code Exchange, pronounced “pixie”) protects the OAuth authorization code flow by making the app prove, at the token step, that it’s the same app that started the sign-in. Before redirecting, the app generates a random code verifier, hashes it with SHA-256 to get a code challenge, and sends only the challenge. When it later exchanges the authorization code for tokens, it sends the original verifier. The server hashes it and checks that it matches. An attacker who steals the code doesn’t have the verifier, so the code is useless to them, and no client secret is required.

PKCE is defined in RFC 7636. It started as a fix for mobile apps, and current OAuth security guidance recommends it for every client type.

The problem PKCE solves

In the authorization code flow, the identity provider redirects the user back to your app with a short-lived code in the URL. Your app exchanges that code for tokens.

Historically, the exchange step was protected by a client secret: only your server knew it, so only your server could redeem the code. That works for a backend, but many apps can’t keep a secret:

  • Single-page apps ship all their code to the browser.
  • Mobile and desktop apps can be decompiled.
  • CLI tools run on users’ machines.

Without a secret, anyone who intercepts the code (through a malicious app registered for the same custom URL scheme, a leaky log, a browser extension or a referrer header) could redeem it themselves. PKCE closes that gap with a one-time secret generated per sign-in.

How the flow works

  1. Generate a verifier. A high-entropy random string, 43 to 128 characters from the unreserved URL character set.
  2. Derive the challenge. code_challenge = BASE64URL(SHA256(code_verifier)), with method S256.
  3. Redirect to authorize. Send code_challenge and code_challenge_method=S256 with the usual parameters. Keep the verifier locally.
  4. Receive the code. The provider stores the challenge alongside the code it issues.
  5. Exchange the code. POST the code and code_verifier to the token endpoint.
  6. Provider verifies. It hashes the verifier, compares to the stored challenge, and only then issues tokens.

The verifier never appears in a URL. It only travels in the back-channel POST to the token endpoint.

Generating the verifier and challenge in JavaScript

This uses the Web Crypto API, available in modern browsers, Node.js 18+, Deno, Bun and edge runtimes.

function base64url(bytes) {
  let str = "";
  for (const b of new Uint8Array(bytes)) str += String.fromCharCode(b);
  return btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

export function createCodeVerifier() {
  // 32 random bytes -> 43 base64url characters
  return base64url(crypto.getRandomValues(new Uint8Array(32)));
}

export async function createCodeChallenge(verifier) {
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(verifier)
  );
  return base64url(digest);
}

Then build the authorization request:

const verifier = createCodeVerifier();
const challenge = await createCodeChallenge(verifier);
const state = createCodeVerifier(); // any unguessable value works
const nonce = createCodeVerifier();

sessionStorage.setItem("pkce_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oidc_nonce", nonce);

const url = new URL("https://acme.gatekeepauth.com/authorize");
url.search = new URLSearchParams({
  response_type: "code",
  client_id: "acme-notes",
  redirect_uri: "https://app.example.com/auth/callback",
  scope: "openid email",
  code_challenge: challenge,
  code_challenge_method: "S256",
  state,
  nonce,
}).toString();

location.assign(url);

And redeem the code on the callback:

const params = new URLSearchParams(location.search);
if (params.get("state") !== sessionStorage.getItem("oauth_state")) {
  throw new Error("state mismatch");
}

const res = await fetch("https://acme.gatekeepauth.com/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code: params.get("code"),
    redirect_uri: "https://app.example.com/auth/callback",
    client_id: "acme-notes",
    code_verifier: sessionStorage.getItem("pkce_verifier"),
  }),
});
const tokens = await res.json(); // id_token, access_token, ...

Note there’s no client_secret anywhere. The client authenticates the exchange with the verifier.

In production, use a maintained OIDC library rather than hand-rolling this. It also validates the ID token signature, issuer, audience, expiry and nonce, which the snippet above leaves out.

S256 vs plain

RFC 7636 defines two methods. plain sends the verifier itself as the challenge, which only helps if the authorization request can’t be observed. S256 sends a hash, so seeing the challenge reveals nothing about the verifier. There’s no reason to use plain in a new app, and many providers reject it.

PKCE, state and nonce: three different jobs

These three parameters are easy to confuse:

ParameterProtects againstChecked by
code_challenge / code_verifierStolen or injected authorization codesThe provider, at the token endpoint
stateCross-site request forgery on your callbackYour app, on the redirect back
nonceReplayed ID tokensYour app, inside the ID token

Use all three. Libraries like Auth.js expose them as checks: ["pkce", "state", "nonce"].

Public clients and token_endpoint_auth_method

When you register an app without a secret, the client is a public client, and its token endpoint authentication method is none. That’s not weaker than a secret in practice: a secret embedded in a browser bundle or mobile binary isn’t secret, and PKCE gives per-request proof that a static secret can’t.

Server-side apps can use PKCE without a secret too. It keeps configuration simple and means there’s no long-lived credential to leak or rotate.

How Gatekeep uses PKCE

Gatekeep makes PKCE with S256 mandatory for every app and issues no client secrets. Every app is a public client with token_endpoint_auth_method: none. Authorization codes last 60 seconds and work once, and the full flow is described in how sign-in works.

Any standard OIDC library can talk to it. See the endpoints reference, register an app, or follow the Next.js passkey login guide. When you’re ready, request early access.

Delete the password field.

Passkey sign-in for every app you ship. Free up to 5,000 monthly active users.