Authorization Code + PKCE for Public Clients: An Architecture Note
An architecture note on Authorization Code flow with PKCE for public browser and mobile clients: the minimal design, trust boundaries, operational checks, failure modes, and when to redesign.
10 Aug 2025, 02:41 UTC

Your browser or mobile app needs to sign users in through a trusted identity provider and call your API on their behalf — but anything shipped to a user's device can be read by anyone, so the client cannot hold a secret. Authorization Code flow with PKCE (Proof Key for Code Exchange, RFC 7636) is the standard answer: it lets a secretless public client complete the flow safely by proving, at token exchange time, that it is the same client that started the login. This note covers the smallest design that works, where the trust boundaries sit, what to check in production, and the conditions that should push you to a different design.
Requirements that drive the design
Three constraints define the problem:
- The client is public: JavaScript in a browser or a distributed mobile binary. No client secret can survive distribution, so client authentication at the token endpoint is impossible.
- Users authenticate at a provider you trust (your own authorization server, or a hosted identity provider), not at the app.
- Your API must accept calls "on behalf of user X" and reject everything else, including tokens replayed by third parties.
The implicit flow used to be the browser answer; it is deprecated because tokens traveled through the front channel (the browser redirect), where they could leak via history, referrers, or injected scripts. The password grant is likewise deprecated — never collect user credentials in your app.
The smallest suitable design
Four moving parts, no more:
- Start the flow. Generate a random
code_verifier(43–128 characters), derivecode_challenge = BASE64URL(SHA256(verifier)), and redirect the user's browser (or the system browser on mobile) to the authorization endpoint withresponse_type=code,client_id,redirect_uri,scope, a randomstate,code_challenge, andcode_challenge_method=S256. - Handle the redirect. The provider returns
?code=...&state=...to your registered redirect URI. Reject anything whosestatedoes not match the value you stored before redirecting. - Exchange the code. POST to the token endpoint with the code,
redirect_uri,client_id, and the originalcode_verifier. The provider hashes the verifier and compares it to the challenge it saw in step 1. A mismatched verifier must fail the exchange. - Call the API and stay signed in. Send the short-lived access token as a bearer token. Use refresh tokens with rotation — each use returns a new refresh token and invalidates the old one — so a stolen-and-replayed refresh token is detectable.
For native apps, follow RFC 8252: use the system browser (not an embedded webview, which can be spied on by the host app) and an HTTPS redirect or a claimed custom URI scheme.
Trust and data boundaries
Be explicit about who trusts what, because most OAuth bugs are boundary confusion:
- The provider is trusted for authentication and token issuance. Everything else is untrusted by default.
- The public client holds no secrets and proves continuity only via PKCE and
state. Treat every value arriving on the redirect as hostile until checked. - The redirect_uri allowlist is the anti-interception boundary. Register exact URIs only — no wildcards, no open redirectors. Lax matching is how authorization codes get delivered to an attacker's endpoint.
- The state parameter is the anti-CSRF boundary: it binds the redirect response to the browser session that initiated it.
- The API trusts a token only after validating it. For a JWT access token that means signature against the provider's current JWKS keys, plus
iss,aud, and expiry. For opaque tokens, call the provider's introspection endpoint. Note the limit: a valid JWT proves issuance, not that the account is still active or the session unrevoked.
Token placement follows from the boundaries. In a single-page app, keep access tokens in memory, not localStorage (readable by any injected script). If refresh tokens must live in the browser at all, minimize XSS exposure and rely on rotation; native apps can use the OS secure enclave/keystore.
Operational checks worth wiring up
- Exact redirect URI matching — audit the registered list after every provider or config change.
- Token endpoint error rates and PKCE failures — a spike in failed code exchanges usually means a broken client release or an interception attempt.
- Refresh-token reuse detection — a rotated token presented twice is a theft signal; the provider (or your server, if you run it) should revoke the whole token family. Alert on it.
- JWKS rotation pickup — after the provider rotates signing keys, confirm your API refreshes its cached keys promptly, or valid tokens start failing signature checks.
- Clock-skew tolerance — allow a small leeway (commonly 30–60 seconds) on expiry validation and alert if you needed more.
- Issuer/audience mismatch logs at the API — tokens from the wrong provider or meant for a different API should be loud, not silently 401.
Failure modes and their mitigations
| Failure | Cause | Mitigation |
|---|---|---|
| Authorization code interception | Malicious app or script captures the redirect | PKCE: the interceptor lacks the verifier |
| CSRF on the redirect | Attacker injects their own code into your session | state binding, checked before exchange |
| Code exfiltration via redirect | Wildcard or prefix-matched redirect URIs | Exact-match allowlist only |
| Refresh-token replay | Token stolen from storage | Rotation + reuse detection + family revocation |
| Provider outage | Authorization server down | Existing tokens work until expiry; new logins fail — plan UX accordingly |
| Validation breaks after key rotation | Stale cached JWKS | Refresh keys on unknown kid; monitor 401 spikes |
Verifying the design before you ship
Against a provider sandbox, run an end-to-end login and confirm the exchange succeeds with the correct S256 verifier and fails with a wrong one — that negative test proves PKCE is actually enforced. Decode an issued access token and check iss, aud, and exp against your API's validation config. Then deliberately replay a used code, tamper with state, and reuse a rotated refresh token, and observe rejection and revocation. Exact error responses and revocation endpoints vary by provider, so confirm behavior rather than assuming it.
Conditions that would change the design
- The app gains a trusted backend. Become a confidential client with client authentication at the token endpoint. Keep PKCE anyway — current guidance (OAuth 2.1 drafts and major provider security recommendations) advises it for all clients, since it also defends against code-injection and mix-up attacks.
- Revocation must be instant. Self-contained JWTs can't be revoked before expiry. Switch to opaque tokens with introspection, or keep short-lived JWTs plus a denylist checked at the API.
- Browser storage and third-party-cookie restrictions bite. Adopt a backend-for-frontend: the backend holds tokens server-side and the browser gets a plain session cookie.
- No user is involved. Machine-to-machine calls belong on the client credentials grant, not anything described here.
One caveat on timing: OAuth 2.1 remains a draft, so this note reflects OAuth 2.0 plus widely adopted extensions. Provider defaults for token lifetimes, rotation, and PKCE enforcement differ and change between releases — check your provider's current documentation before locking parameters.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.