Public clients cannot keep a secret
A browser or mobile application runs in an environment the user and other applications can inspect. Any client secret shipped in its bundle can be copied, so OAuth treats these applications as public clients. The authorization code flow with Proof Key for Code Exchange, or PKCE, binds the authorization response to the client instance that started the request without relying on a static secret. It protects the code if an attacker intercepts the redirect.
PKCE does not replace TLS, redirect URI validation, issuer checks, or safe token storage. It solves one part of the authorization-code interception problem. Write down the client type, redirect URI, issuer, scopes, token lifetime, and session handoff before integrating. A provider dashboard setting that calls a client confidential does not make a browser bundle confidential.
Generate a verifier and challenge correctly
The client generates a high-entropy code verifier and keeps it in a protected, short-lived location until the callback. It sends a code challenge derived from that verifier, normally using the S256 method. The authorization server stores the challenge with the authorization code. When the client exchanges the code, it sends the verifier; the server hashes it and compares the result. A random verifier must never be reused across authorization attempts.
Use a cryptographically secure random generator and an encoding allowed by the provider's grammar. Keep the verifier scoped to the transaction and delete it after success or failure. Do not put the verifier in a URL, analytics event, or server log. If a user opens two login attempts in separate tabs, associate each state and verifier with its own transaction rather than letting the second attempt overwrite the first.
Use state and nonce for the right threats
The state value binds the callback to the browser transaction that initiated it and helps prevent login CSRF. Generate it independently from the PKCE verifier, store it in a short-lived protected context, and compare it before exchanging the code. An OpenID Connect nonce binds an ID token to the request and helps prevent replay. Do not treat the provider's returned user ID or email as a substitute for state validation.
Validate the callback in a fixed order: expected route, state, provider error, authorization code shape, issuer, and token response. A callback can be delivered late, duplicated, or with an error. Clear the transaction state after a terminal outcome. Do not display provider error details directly in a page without encoding them; error parameters are untrusted input.
Register exact redirect URIs
Authorization servers should compare redirect URIs exactly or according to a narrowly documented rule. Avoid a wildcard path that lets an attacker register a page under the same origin or a redirect on an untrusted host. Separate production, staging, and local clients. Do not accept a redirect_uri from the browser and forward it without comparing it to a server-side allowlist.
A reverse proxy can change the visible scheme or host, so configure the application and provider with the public origin the user actually sees. Test behind the CDN and Cloud Run domain. If a migration needs two callback paths, register both intentionally and remove the old one after sessions drain. A redirect URI is an authentication boundary, not a convenience setting.
Exchange codes on the right boundary
A single-page application may exchange the code directly with the provider using PKCE, while a server-rendered application may send the code to its backend. Choose the boundary based on where tokens and sessions should live. If the server exchanges the code, validate the state in the browser and bind the transaction to the server session before accepting the callback. If the browser exchanges it, use the provider's supported CORS and token rules rather than inventing a proxy that logs credentials.
Validate the token response, issuer, audience, expiration, and nonce where applicable. Do not trust a profile returned by an arbitrary endpoint until the access token and provider identity are verified. Map the provider subject to an internal account key and handle email changes deliberately. The provider user ID is usually the stable identity; an email address can be reused or updated.
Store and rotate sessions safely
After OAuth completes, create a session appropriate to the client. A server can issue a secure, HttpOnly, SameSite cookie and keep provider tokens on the server. A browser-only client needs a carefully designed in-memory or platform storage strategy and short-lived access tokens. Do not store refresh tokens in a place that every script can read unless the threat model explicitly accepts that risk. Revoke or rotate refresh credentials according to the provider's rules.
Handle sign-out, account unlinking, provider errors, and a user who denies consent. A local session should not remain active merely because the provider still has a browser login. For high-risk actions, require a recent authentication or another factor. OAuth proves a relationship with the provider; it does not automatically authorize every operation in your product.
Test interception and recovery cases
Test a valid flow, wrong state, missing verifier, reused code, expired code, wrong redirect URI, provider denial, callback replay, two simultaneous tabs, changed issuer, changed audience, and a token with a bad nonce. Use a fake provider in automated tests and a real staging client for browser behavior. Verify that no code, verifier, access token, or refresh token reaches logs or analytics.
PKCE is a small protocol with a large trust boundary. Treat the verifier, state, redirect URI, token exchange, and session handoff as one security workflow. When each value is generated, bound, validated, and cleared intentionally, a public client can use OAuth without pretending that a bundled secret is private.
Review the callback as an untrusted entry point
The callback route should accept only the expected state, code, error fields, and provider issuer. Treat every query parameter as attacker-controlled and keep the safe return path separate from the provider's values. If the callback fails, clear the transaction state and show a generic message with a request ID rather than echoing a provider error or authorization code.
Record the provider, client type, redirect URI version, and outcome without recording tokens or verifiers. Recheck registered callbacks during domain migrations and remove old environments after sessions drain. PKCE is strongest when the browser transaction, provider registration, server exchange, and session creation are reviewed as one flow.
Implementation example
Create a high-entropy state and code verifier for each authorization transaction, derive the S256 challenge, and bind the callback to the same browser transaction. Validate issuer, redirect URI, state, nonce where used, and the exact client type before exchanging the code. Keep the safe return path separate from provider-controlled query values.
code_challenge = BASE64URL(SHA256(code_verifier))
authorize?response_type=code&code_challenge_method=S256&code_challenge=...Verify and troubleshoot
Test success, denied consent, callback with wrong state, expired code, reused code, wrong redirect URI, provider issuer mismatch, and a mobile app resuming after process death. Assert that the server never accepts a callback without the stored verifier and that error pages do not echo codes, tokens, or provider details. Record outcome and provider without recording credentials.
Operations and recovery
Review registered redirect URIs during domain migrations and remove preview callbacks after use. Expire abandoned transactions, rotate client credentials where applicable, and preserve a safe local logout or recovery path. If a provider incident occurs, disable new exchanges or route to a status message without accepting an unverified callback.
References and further reading
Use RFC 7636 for PKCE, OAuth 2.0 Security Best Current Practice, and the provider's issuer and redirect-registration documentation. Keep public-client limitations explicit: PKCE protects the code exchange but does not make a client secret safe to ship.