← Back to the library
BackendHow it works · Applied · 12 min

OAuth authorization code flow with PKCE

What actually happens between clicking "Sign in" and calling an API with a token?

The browser carries a short-lived, single-use code; the client trades it for tokens over a back channel, proving with PKCE that it is the same client that started the flow.

THE MENTAL MODEL

Think of two channels. The front channel (browser redirects) is visible to extensions, history, logs and other apps, so it only ever carries a one-time code plus the state you sent. The back channel (client to token endpoint) carries the secrets: the code_verifier whose hash went out as the code_challenge, and, for confidential clients, client authentication. A stolen or injected code is useless without the verifier, which never left the client. OpenID Connect layers identity on top: the id_token tells the client who logged in, while the access token tells an API what the caller may do. Everything else, from state and nonce to exact redirect matching and refresh rotation, exists to stop a code or token from being replayed in a context it was not issued for.

HOW IT FITS TOGETHER

Authorization code + PKCE, then refreshThe browser only ever carries the code and state. Tokens move on the back channel between the app and the authorization server.
  1. User / Browser → App (client)Click Sign in
  2. App (client) → User / Browser302 to /authorize + challenge, stateApp stores code_verifier, state and nonce bound to this browser session.
  3. User / Browser → Auth serverGET /authorize?code_challenge&state&nonce
  4. Auth server → User / BrowserLogin, consent, 302 redirect_uri?code&stateredirect_uri must exactly match a registered value.
  5. User / Browser → App (client)GET /callback?code&stateApp rejects the callback unless state matches the stored value.
  6. App (client) → Auth serverPOST /token code + code_verifier
  7. Auth server → App (client)access_token, refresh_token, id_tokenServer checks BASE64URL(SHA256(verifier)) equals the challenge; app validates id_token iss, aud, exp and nonce.
  8. App (client) → Resource APIGET /orders, Authorization: Bearer
  9. Resource API → App (client)200 dataAPI validates the access token's issuer, audience, expiry and scopes, never the id_token.
  10. App (client) → Auth serverPOST /token grant_type=refresh_token
  11. Auth server → App (client)New access + rotated refresh tokenThe old refresh token is now invalid; presenting it again signals theft.
Token lifecycleEach credential is narrower and shorter-lived than the one that produced it; rotation turns refresh token theft into a detectable event.
  1. Authorization codeSingle use, expires in seconds to minutes, bound to client_id, redirect_uri and code_challenge.
  2. Access tokenShort-lived bearer (or sender-constrained) credential scoped to an audience; sent to APIs.
  3. id_tokenSigned JWT about the login event for the client only; validate once, then create your own session.
  4. Refresh tokenLong-lived; for public clients it must be rotated or sender-constrained (RFC 9700).
  5. RotationEach refresh returns a new refresh token and invalidates the previous one.
  6. Reuse detectionA retired refresh token presented again revokes the active token for that grant.
  7. Logout / revocationRevoke the refresh token and clear the app session; access tokens expire on their own.

KEY TERMS

PKCE (RFC 7636)
The client creates a random code_verifier (43-128 chars, 32 random bytes is standard), sends S256 = BASE64URL(SHA256(verifier)) as code_challenge, and later proves possession by sending the verifier to the token endpoint. RFC 9700 requires it for public clients and recommends it for confidential ones; OAuth 2.1 makes it mandatory.
state
An unguessable, per-request value bound to the user's session and echoed back on the redirect. It stops CSRF on the callback, where an attacker logs the victim into the attacker's account. PKCE also covers this for the server, but state is still how the client correlates the response with its stored request.
nonce
An OIDC value sent on the authorization request and returned inside the id_token. The client must check it matches, which binds the id_token to this login attempt and blocks replay of an id_token captured elsewhere.
id_token vs access token
The id_token is for the client: who authenticated, when, and how; its aud is your client_id. The access token is for the resource server: what the caller may do; its audience is the API. Never send an id_token to an API as a credential.
Exact redirect URI matching
The authorization server compares the redirect_uri as an exact string against registered values (the only exception is a variable port for native-app loopback URIs). Prefix or wildcard matching lets attackers steer codes to pages they control.
Refresh token rotation
Every refresh response includes a new refresh token and retires the old one. If a retired token comes back, the server cannot know which party is legitimate, so it revokes the grant. That forces re-login but caps the damage of a stolen token.
Backend for Frontend (BFF)
For SPAs, a same-site backend acts as a confidential client, keeps all tokens server-side and gives the browser only an HttpOnly session cookie. RFC 10017 (OAuth 2.0 for Browser-Based Applications) ranks it as the strongest of its three architectures.

IN YOUR STACK

TypeScript · Web Crypto API Client

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

async function createPkcePair() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
  return { verifier, challenge: base64url(new Uint8Array(digest)) };
}

const { verifier, challenge } = await createPkcePair();
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
sessionStorage.setItem("oauth", JSON.stringify({ verifier, state }));

const url = new URL("https://auth.example.com/authorize");
url.search = new URLSearchParams({
  response_type: "code", client_id: "spa", scope: "openid profile",
  redirect_uri: "https://app.example.com/callback",
  state, code_challenge: challenge, code_challenge_method: "S256",
}).toString();
location.assign(url);
  • crypto.subtle is only available in secure contexts (HTTPS or localhost).
  • Use S256, never plain: a plain challenge equals the verifier, so anyone who sees the redirect has it.
  • A pure browser client keeps tokens where any injected script can read them; RFC 10017 prefers a BFF or token-mediating backend when you have a server.
Web Crypto API documentation (opens in new tab)

TypeScript · Next.js + Auth.js v5 Server

// auth.ts
import NextAuth from "next-auth";

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    {
      id: "corp",
      name: "Corp SSO",
      type: "oidc",
      issuer: "https://auth.example.com",
      clientId: process.env.AUTH_CORP_ID,
      clientSecret: process.env.AUTH_CORP_SECRET,
      checks: ["pkce", "state", "nonce"],
    },
  ],
});

// app/api/auth/[...nextauth]/route.ts
import { handlers } from "@/auth";
export const { GET, POST } = handlers;
  • The provider default for checks is ["pkce"]; list state and nonce explicitly if you want all three.
  • Register /api/auth/callback/corp exactly, including scheme and host, for every deployed origin.
  • This is effectively a BFF: tokens stay server-side unless you copy them into the session callback, which exposes them to client code.
Next.js + Auth.js v5 documentation (opens in new tab)

Python · Flask + Authlib Server

from flask import Flask, redirect, session, url_for
from authlib.integrations.flask_client import OAuth

app = Flask(__name__)
app.secret_key = "load-from-secret-store"
oauth = OAuth(app)
oauth.register(
    "corp",
    client_id="web-app",
    client_secret="load-from-secret-store",
    server_metadata_url="https://auth.example.com/.well-known/openid-configuration",
    client_kwargs={"scope": "openid profile email", "code_challenge_method": "S256"},
)

@app.route("/login")
def login():
    return oauth.corp.authorize_redirect(url_for("callback", _external=True))

@app.route("/callback")
def callback():
    token = oauth.corp.authorize_access_token()  # checks state, sends verifier
    session["user"] = token["userinfo"]  # parsed from the validated id_token
    return redirect("/")
  • code_challenge_method in client_kwargs turns PKCE on; S256 is the only supported method.
  • Authlib keeps state, nonce and the verifier in the Flask session, which by default is a signed but readable cookie.
  • Behind a TLS-terminating proxy, url_for(_external=True) can produce http:// and fail exact redirect matching; configure ProxyFix or an explicit URL.
Flask + Authlib documentation (opens in new tab)

Go · golang.org/x/oauth2 Server

var conf = &oauth2.Config{
	ClientID:     "web-app",
	ClientSecret: os.Getenv("CLIENT_SECRET"),
	RedirectURL:  "https://app.example.com/callback",
	Scopes:       []string{"openid", "profile"},
	Endpoint: oauth2.Endpoint{
		AuthURL:  "https://auth.example.com/authorize",
		TokenURL: "https://auth.example.com/token",
	},
}

func login(w http.ResponseWriter, r *http.Request) {
	state, verifier := rand.Text(), oauth2.GenerateVerifier()
	saveToSession(w, r, state, verifier)
	url := conf.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
	http.Redirect(w, r, url, http.StatusFound)
}

func callback(w http.ResponseWriter, r *http.Request) {
	state, verifier := loadFromSession(r)
	if r.URL.Query().Get("state") != state {
		http.Error(w, "state mismatch", http.StatusBadRequest)
		return
	}
	tok, err := conf.Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(verifier))
	// handle err; persist tok; validate tok.Extra("id_token") with an OIDC library
}
  • GenerateVerifier, S256ChallengeOption and VerifierOption are the built-in PKCE helpers; rand.Text is crypto/rand (Go 1.24+).
  • x/oauth2 does not verify id_tokens; use an OIDC library such as coreos/go-oidc for signature, iss, aud and nonce checks.
  • conf.TokenSource refreshes in memory only; persist the returned token yourself or a rotated refresh token is lost on restart.
golang.org/x/oauth2 documentation (opens in new tab)

Java · Spring Security OAuth2 Client Server

@Configuration
@EnableWebSecurity
class SecurityConfig {
  @Bean
  ClientRegistrationRepository clientRegistrations() {
    ClientRegistration corp = ClientRegistrations
        .fromIssuerLocation("https://auth.example.com")
        .registrationId("corp")
        .clientId("web-app")
        .clientSecret(System.getenv("CLIENT_SECRET"))
        .scope("openid", "profile")
        .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
        .clientSettings(ClientRegistration.ClientSettings.builder().requireProofKey(true).build())
        .build();
    return new InMemoryClientRegistrationRepository(corp);
  }

  @Bean
  SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .oauth2Login(Customizer.withDefaults());
    return http.build();
  }
}
  • PKCE is automatic for public clients (no secret, client-authentication-method none); requireProofKey(true), available since 6.5, enables it for confidential clients.
  • If the provider rejects PKCE for confidential clients, set requireProofKey(false) rather than switching flows.
  • oauth2Login validates the id_token, including nonce, before creating the Spring Security session.
Spring Security OAuth2 Client documentation (opens in new tab)

TypeScript · Expo AuthSession Client

import * as WebBrowser from "expo-web-browser";
import { exchangeCodeAsync, makeRedirectUri, useAuthRequest, useAutoDiscovery } from "expo-auth-session";
import { Button } from "react-native";

WebBrowser.maybeCompleteAuthSession();

export function SignIn() {
  const discovery = useAutoDiscovery("https://auth.example.com");
  const redirectUri = makeRedirectUri({ scheme: "com.example.app", path: "oauth2redirect" });
  const [request, , promptAsync] = useAuthRequest(
    { clientId: "mobile-app", scopes: ["openid", "profile", "offline_access"], redirectUri },
    discovery,
  );

  async function signIn() {
    const result = await promptAsync();
    if (result.type !== "success" || !request || !discovery) return;
    const tokens = await exchangeCodeAsync(
      { clientId: "mobile-app", code: result.params.code, redirectUri,
        extraParams: { code_verifier: request.codeVerifier ?? "" } },
      discovery,
    );
    await saveRefreshTokenSecurely(tokens.refreshToken);
  }
  return <Button disabled={!request} title="Sign in" onPress={signIn} />;
}
  • usePKCE defaults to true; the generated verifier lives on request.codeVerifier and must be sent at exchange time.
  • This runs the system browser, as RFC 8252 requires, rather than an embedded web view; the reverse-domain scheme must be registered for the app.
  • A mobile app is a public client: no client secret in the bundle, and keep refresh tokens in the platform keystore (for example expo-secure-store).
Expo AuthSession documentation (opens in new tab)

WHERE IT BITES

  • Sending the id_token to your own API as a bearer credential. Its audience is the client; APIs should accept only access tokens issued for them.
  • Using the implicit flow or putting tokens in a browser-reachable store in an SPA. RFC 9700 says SHOULD NOT for implicit, OAuth 2.1 drops it, and RFC 10017 recommends a BFF when you have a backend.
  • Registering wildcard or prefix redirect URIs, or computing redirect_uri from request headers behind a proxy, so it differs between /authorize and /token.
  • Generating state once per app instead of per request, storing it unbound to the user session, or skipping nonce validation when you consume an id_token.
  • Refreshing from several tabs or threads at once with rotation enabled: the second request presents a retired refresh token and revokes the whole grant. Serialize refreshes.
  • Running native-app login inside an embedded web view, which lets the app read credentials and breaks SSO; RFC 8252 requires the system browser.

CLOSE THE AI. EXPLAIN THIS.

If an attacker intercepts the authorization code on its way back to a public client, what exactly stops them from redeeming it, and which value would they need that never appeared in any URL?

SOURCES

Explainer reviewed