Logging in with Google, Microsoft, Keycloak or Auth0 means your app never sees the password. It sends the user to the identity provider, which sends them back with a one-time code; the app exchanges that code for tokens. OAuth 2.1 — the cleaned-up version of OAuth 2.0 — keeps a single flow for apps in the browser: the authorization code with PKCE.
PKCE (Proof Key for Code Exchange, pronounced “pixy”) solves one precise problem. A single-page app is a public client: it has no secret it could hide, because everything it ships can be read. So it needs another way to prove that whoever redeems the code is whoever asked for it. The demos on this page use real cryptography (Web Crypto), not an imitation.
response_type=token, tokens in the URL fragment) is gone from OAuth 2.1: tokens in URLs end up in the browser history, in logs and in Referer headers. Moving to the code flow with PKCE is usually a configuration change in your library and at the provider.The flow, in seven steps
The app prepares a few random values, sends the browser to the provider, receives a code back and exchanges it — together with a proof — for tokens. Only the last step involves your API. Go through it in the demo; tick “attacker” to see what the state check stops.
app (browser) identity provider API
│ 1. verifier, challenge = S256(verifier), state, nonce │
│ 2. GET /authorize?…&code_challenge&state&nonce ─▶│ │
│ 3. login, MFA, consent │
│◀─ 4. 302 /callback?code=…&state=… (check state!) │
│ 5. POST /token code + code_verifier ────────────▶│ S256(verifier) == challenge?
│◀─ 6. access_token, id_token, refresh_token ───────│ │
│ 7. GET /orders Authorization: Bearer <access_token> ─────────────────────▶│PKCE: proving you're the one who asked
Before the redirect, the app generates a random code_verifier and sends only its hash, the code_challenge, to /authorize. The provider remembers it next to the code. When the app redeems the code at /token, it sends the verifier itself; the provider hashes it and compares. Someone who intercepts the code — a malicious app registered for the same redirect, a log, a browser extension — doesn't have the verifier, and the code is worthless to them.
/** base64url without padding (RFC 4648 §5), as PKCE requires */
function base64url(bytes: Uint8Array): string {
let binary = '';
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/** 32 random bytes → 43 characters (the minimum allowed is 43, the maximum 128) */
export function createVerifier(): string {
return base64url(crypto.getRandomValues(new Uint8Array(32)));
}
/** code_challenge = BASE64URL(SHA-256(code_verifier)), method S256 */
export async function challengeFor(verifier: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
return base64url(new Uint8Array(digest));
}
// RFC 7636, appendix B:
// challengeFor('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk')
// → 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'- 1The app generates a verifier
0 characters · between 43 and 128 are allowed
code_challenge… - 2Only the challenge goes to /authorize
The provider hasn't received anything yet.
- 3Redeem the code at /token
Use S256, never plain (with plain the challenge is the verifier, so whoever sees the redirect has everything). The verifier stays in sessionStorage — this tab only — and is deleted as soon as the code is exchanged.
state, nonce and redirect_uri
state— a random value saved before the redirect and checked in/callback. Without it, an attacker can send the victim to your callback with the attacker's code, and the victim ends up logged into the attacker's account (login CSRF). PKCE also blocks this, but providers still requirestate, and you need it anyway to carry your own context.nonce— a random value sent to/authorizethat must come back inside the ID token. It proves that the ID token was issued for this login, not copied from an earlier one.redirect_uri— registered at the provider exactly, without wildcards. And where you go after login (returnTo) is saved by your app before the redirect; never take it from a?returnUrl=in the address bar, or your login page becomes an open redirect.
import { challengeFor, createVerifier } from './pkce';
const AUTHORIZE = 'https://id.example.com/authorize';
export async function login(returnTo = '/'): Promise<void> {
const verifier = createVerifier();
const state = createVerifier(); // random, single use
const nonce = createVerifier();
// sessionStorage: this tab only, gone when the tab closes
sessionStorage.setItem('oauth', JSON.stringify({ verifier, state, nonce, returnTo }));
const url = new URL(AUTHORIZE);
url.search = new URLSearchParams({
response_type: 'code',
client_id: 'atlas-spa',
redirect_uri: `${location.origin}/callback`, // registered EXACTLY, no wildcards
scope: 'openid profile email',
code_challenge: await challengeFor(verifier),
code_challenge_method: 'S256',
state,
nonce,
}).toString();
location.assign(url);
}Three tokens, three jobs
The answer from /token contains up to three tokens, and mixing them up is the most common mistake. The ID token tells the app who logged in. The access token tells the API what the caller may do. The refresh token gets new access tokens without a new login. Decoding a JWT in the browser is useful for display, but it isn't validation: the signature is checked with the provider's public keys (JWKS), and that's work for a library.
| ID token | Access token | Refresh token | |
|---|---|---|---|
| Read by | your app | your API | the provider |
| Sent to | nowhere — it stays in the app | only your API's origin | only the provider's /token |
| Lives | minutes | 5–60 minutes | hours to days, rotated on every use |
Safer: a BFF keeps the tokens
After step 6, the tokens live in the browser — exactly where an XSS can reach them. The IETF document on OAuth for browser-based apps describes a stronger option: a Backend for Frontend. A small server on your own site does the code exchange, keeps the tokens on the server side and gives the browser only an HttpOnly session cookie, which JavaScript can't read.
browser (SPA) BFF (your server, same site) identity provider
│ GET /auth/login ──────────▶│ PKCE + state + nonce, kept on the server ──▶│ /authorize
│◀─ 302 to the provider ─────│ │
│ …login at the provider, redirect back to the BFF… │
│ │ code + verifier ──────────────────────────▶│ /token
│ │◀── access, id, refresh tokens (stay HERE) ──│
│◀─ Set-Cookie: session=…; HttpOnly; Secure; SameSite=Strict │
│ GET /api/orders (cookie) ─▶│ adds Authorization: Bearer … ──▶ API │The SPA calls /api on its own origin; the BFF adds the access token and handles refresh. An XSS can still make requests while the page is open, but it can't steal tokens to use elsewhere. The trade-off: one more component to run, and CSRF protection for the cookie (SameSite, an anti-CSRF header). For tokens kept in the browser, the queue-and-refresh interceptor is on the page about tokens and refresh.
In Angular: a library, a guard and an interceptor
Don't implement the protocol yourself. Certified libraries such as angular-auth-oidc-client or angular-oauth2-oidc handle provider discovery, PKCE, ID token validation including the signature, refresh and logout. The code on this page shows what they do, so you can configure and debug them with your eyes open. Your part:
- Configuration: authority, client id,
redirect_uri, scopes and the code flow with PKCE — usually the default. - A guard on the private routes, which starts the login and remembers where to return. See guards & resolvers.
- An interceptor that adds the access token only for your API's origin. A token sent to every URL ends up at third parties; see security for the rest of the picture.
When YES, when NO
- Login through an external identity provider: company SSO, Google, Microsoft, Apple.
- Several apps that share the same login and the same users.
- MFA, passkeys and account recovery that you'd rather leave to specialists.
- A single app with its own users and backend: a classic session cookie may be all you need. Tokens and refresh are on their own page.
- Server-to-server calls: that's the client credentials flow, on the server, never in the browser.
- Writing the protocol yourself in production: use a certified library.
Common pitfalls
- The implicit flow, or tokens in the URL. They end up in history, logs and the
Refererheader. - Not checking
state. The callback accepts a code that your app never asked for. - The ID token sent to the API. It's for the app; the API takes access tokens, with the right audience.
- Decoding instead of validating. Without the signature check, anyone can write a JWT with any claims.
- The verifier in
localStorage, kept after use. It belongs insessionStorageand dies with the exchange. ?returnUrl=taken from the address bar. Your login page becomes an open redirect used in phishing.
Cheat sheet
browser apps (OAuth 2.1) authorization code + PKCE, nothing else (no implicit flow)
code_verifier 43–128 random chars, kept in sessionStorage, deleted after use
code_challenge BASE64URL(SHA-256(verifier)), method S256 (never "plain")
state random, checked on /callback: stops login CSRF
nonce random, must be in the ID token: stops replayed tokens
redirect_uri registered exactly, no wildcards; returnTo saved BEFORE the redirect
ID token who logged in (for the app); validate iss, aud, exp, nonce, signature
access token what you may call (for the API); only to your API's origin
refresh token rotated on every use, or not in the browser at all
safest a BFF: tokens on the server, an HttpOnly cookie in the browser
logout local session + the provider's end_session_endpoint
do not write it yourself certified libraries: angular-auth-oidc-client, angular-oauth2-oidc