AngularAtlased. 22.2

Ch. 17Security & globalisation74 / 75

OAuth 2.1 & OpenID Connect with PKCE

The authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.

architecture ~0 min read

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.

If you still use the implicit flow
The implicit flow (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> ─────────────────────▶│
LiveAuthorization code with PKCE, step by step
1verifier, challenge, state, nonce
2GET /authorize
3login, MFA, consent
4302 /callback?code&state
5POST /token + verifier
6access, ID, refresh token
7GET /api … Bearer
Press “Next” through all seven steps. Then tick “Forged callback” and start again: the flow stops at step 4.

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'
LiveVerifier, challenge and the check at /token
  1. 1The app generates a verifier

    0 characters · between 43 and 128 are allowed

    code_challenge…
  2. 2Only the challenge goes to /authorize

    The provider hasn't received anything yet.

  3. 3Redeem the code at /token
Generate a verifier, send the challenge, then redeem the code both ways. Load the RFC 7636 example to check the implementation against the standard.

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 require state, and you need it anyway to carry your own context.
  • nonce — a random value sent to /authorize that 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 tokenAccess tokenRefresh token
Read byyour appyour APIthe provider
Sent tonowhere — it stays in the apponly your API's originonly the provider's /token
Livesminutes5–60 minuteshours 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

OAuth / OIDC
  • 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.
Something simpler
  • 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 Referer header.
  • 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 in sessionStorage and 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

Ch. 17 · 0 of 4 complete

Finished this lesson?

Mark it and it shows up on your map: in the contents and on the home page.

Marks itself as done once you have read the whole lesson.

  1. Security: XSS, sanitisation & CSP
  2. Authentication: tokens & refresh
  3. OAuth 2.1 & OpenID Connect with PKCE
  4. Internationalisation (i18n)
See your whole map

All 75 pages · type to filter

  • 01What's new in Angular 21 & 22All the important changes in v21 and v22, briefly, with a link to the page that explains each one.Start
  • 01Your first app, step by stepA reading list built from an empty component to a finished app — signals, @for, inputs and outputs, a service and a form, one step at a time.Start
  • 01Quick decisions“Where do I put this?”, “Signal or Observable?”, “Shared or feature?” — short answers to everyday questions.Start
  • 01Angular interview questions60 real interview questions for junior, mid and senior, with the answer to give, the follow-up and the red flags. Plus a mock interview.Start
  • 02Standalone & bootstrapHow an app starts without NgModule: bootstrapApplication, app.config.ts, imports in the component.Modern fundamentals
  • 02Data binding & eventsInterpolation, property and attribute bindings, events with $event and key filters, and how [( )] is built — what goes into the DOM and what comes back.Modern fundamentals
  • 02Control flow: @if, @for, @switchThe block syntax that replaces *ngIf/*ngFor/ngSwitch, with track, @empty and exhaustive checks.Modern fundamentals
  • 02@let and the new template expressionsLocal variables with @let, arrow functions, spread, regex, instanceof, comments inside tags.Modern fundamentals
  • 02@defer — lazy loading in templatesLoad heavy components only when needed: triggers, prefetch, placeholder, loading, error.Modern fundamentals
  • 02Class & style bindingsWhy [class.x] and [style.x] replaced ngClass and ngStyle (with automatic migration from v21).Modern fundamentals
  • 02Component styles & encapsulation:host, the three encapsulation modes, theming with CSS variables, and why ::ng-deep is a last resort.Modern fundamentals
  • 02Pipes: when and howPure vs impure pipes, custom pipes, and how you choose between a pipe, computed() and a method.Modern fundamentals
  • 03signal() and computed()The reactive primitives: writable state, derived state, equality, untracked and immutability rules.Signals & reactivity
  • 03effect() — when YES, when NOSide effects synchronised with signals: localStorage, logging, imperative APIs. And their traps.Signals & reactivity
  • 03linkedSignal() — editable derived stateA signal that resets when its source changes, but that you can also set manually.Signals & reactivity
  • 03resource, rxResource, httpResourceAsync data as signals: status, value, error, reload, automatic cancellation. Stable since v22.Signals & reactivity
  • 03debounced() — delayed signalsThe “debounce” version of a signal, without RxJS. Perfect for search.Signals & reactivity
  • 03Lists: search, filter, sort, paginateA chain of computed signals that recalculates only what changed, with the filters kept in the URL so a link reopens the same view.Signals & reactivity
  • 03Observables from zeroWhat an Observable is, subscribe and unsubscribe, the operators you use every week, cold vs hot — and when a signal is enough.Signals & reactivity
  • 03Signals ↔ RxJStoSignal, toObservable, takeUntilDestroyed, outputFromObservable — and when RxJS remains the right choice.Signals & reactivity
  • 04input() and output()Inputs as signals (required, transform, alias) and typed outputs. Data down, events up.Components: communication
  • 04model() — two-way bindingBanana-in-a-box [( )] with a single API. When to use it, and when input + output is better.Components: communication
  • 04viewChild & contentChildQueries as signals: viewChild, viewChildren, contentChild, contentChildren, the read option.Components: communication
  • 04Lifecycle & DestroyRefWhich hooks still matter in the signals era, afterNextRender, DestroyRef and typed ngOnChanges.Components: communication
  • 04Content projectionng-content with select, multiple slots, default content and the trap of conditional projection.Components: communication
  • 04ng-template & ngTemplateOutletTemplates as values: render props in Angular, typed context and customising components.Components: communication
  • 05Smart vs DumbContainer vs presentational: who holds the state, who only displays, and how to split them properly.Component design
  • 05Generic components <T>A correctly typed DataTable<T> or Select<T>, with templates for cells.Component design
  • 05Dynamic componentsNgComponentOutlet, createComponent with inputBinding/outputBinding, and a dashboard built from config.Component design
  • 05Directives & host compositionAttribute directives, the host: {} metadata and the Directive Composition API (hostDirectives).Component design
  • 05Custom form controlsFormValueControl (Signal Forms) vs ControlValueAccessor: a star rating, in both variants.Component design
  • 05Change detectionOnPush is the default from v22, zone.js is gone from v21. What triggers rendering and how not to get burned.Component design
  • 06inject() & @Service()The new @Service() decorator from v22, inject() vs the constructor, and the injection context.Dependency Injection
  • 06Providers & the injector hierarchyRoot vs route vs component, useClass/useValue/useFactory/useExisting, and the optional/self/skipSelf modifiers.Dependency Injection
  • 06InjectionToken & provideX()Typed configuration, provideFeature() functions, makeEnvironmentProviders and initializers.Dependency Injection
  • 06injectAsync() — lazy servicesLoad a heavy service only when you need it, with prefetch on idle.Dependency Injection
  • 07Folder structurecore / shared / features, what goes into each, and what a feature looks like inside.Application architecture
  • 07Shared vs featureWhen to move a component into shared and (especially) when NOT to. A concrete checklist.Application architecture
  • 07Interfaces & modelsInline interface or separate file? DTO vs domain model, type vs interface, where to put them.Application architecture
  • 07State management with signalsSimple signal stores, facades, local vs global state, and when you need NgRx.Application architecture
  • 07Style guide & namingThe current official conventions: file names without suffixes, protected, readonly, member order.Application architecture
  • 07Barrels, aliases & boundariesindex.ts, path aliases, a feature’s public API and dependency rules that can be checked automatically.Application architecture
  • 07Errors: @boundary & ErrorHandlerThe new @boundary block from v22.2, the global ErrorHandler, HTTP errors and errors from resource.Application architecture
  • 08Modern routing & lazy loadingloadComponent, child routes, parameters as inputs and parameter inheritance (the new default in v22).Routing
  • 08Guards & resolverscanActivate, canMatch, canDeactivate and resolve as functions, with redirects via UrlTree / RedirectCommand.Routing
  • 09Signal Forms — the basicsForms built on signals: form(), [formField], schema-based validation. Stable since v22.Forms
  • 09Signal Forms — advancedCustom and cross-field validation, async/HTTP with debounce, dynamic lists, disabled/hidden, reusable schemas.Forms
  • 09Typed Reactive FormsTyped FormGroup, nonNullable, FormArray — and how they coexist with Signal Forms.Forms
  • 10HttpClient, interceptors & the API layerHttpClient without setup (v21), Fetch by default (v22), functional interceptors and an API layer with DTO mapping.HTTP & data
  • 11Performance — a checklistWhat really matters: OnPush + signals, track, @defer, lazy routes, images, bundles and hydration.Quality & performance
  • 11Testing with VitestVitest is the default runner from v21. TestBed, components with signals, services, HTTP and harnesses.Quality & performance
  • 11DevTools: look inside your appChrome DevTools and Angular DevTools step by step: what each panel shows, with exercises that work on this site.Quality & performance
  • 11Angular AriaHeadless, accessible components that you style however you like: tabs, accordion, menu, listbox...Quality & performance
  • 12SSR, prerender & render modesWhen to render on the server, when at build time and when only in the browser: RenderMode per route, app.routes.server.ts and the real costs.SSR & hydration
  • 12Hydration: event replay & incrementalHow the browser takes over server HTML without re-rendering it: full hydration, event replay, @defer (hydrate on …) and mismatch errors.SSR & hydration
  • 12Server-compatible codewindow doesn’t exist on the server: PLATFORM_ID, afterNextRender, DOCUMENT, TransferState, the HTTP cache and PendingTasks.SSR & hydration
  • 13Micro frontendsWhen it is worth splitting an app into several deployments and when it is just complexity: Native Federation, Module Federation, iframes, Web Components.Architecture at scale
  • 13Web Components with Angular ElementscreateCustomElement: an Angular component used as an HTML tag anywhere, plus communication between apps through events and BroadcastChannel.Architecture at scale
  • 13Monorepo, libraries & boundariesA workspace with several apps and libraries: ng generate library, Nx, secondary entry points, publishing and dependency rules.Architecture at scale
  • 13Hexagonal architecture (ports & adapters)A domain without Angular, ports as abstract classes and interchangeable adapters: HTTP, localStorage or in-memory, without touching the UI.Architecture at scale
  • 14Signals under the hoodThe reactive graph: producers and consumers, push-pull, why computed is lazy and glitch-free, the diamond problem and when effects run.Advanced reactivity
  • 14Advanced RxJS: higher-order operatorsswitchMap, mergeMap, concatMap and exhaustMap on a live diagram, plus retry with backoff, shareReplay and how to avoid leaks.Advanced reactivity
  • 14NgRx SignalStoreLarge-scale state management on signals: withState, withComputed, withMethods, rxMethod, entities and hooks — and when a simple store is enough.Advanced reactivity
  • 14State machines with signalsNo more impossible states: a typed machine on signals, with transitions, guards and a live state diagram.Advanced reactivity
  • 15WebSockets: real time that survives the networkA typed socket as signals: reconnect with backoff and jitter, heartbeat, an outbox while offline, and SSR safety.Real time & offline
  • 15Optimistic updates & rollbackAn instant UI that stays honest: snapshot and rollback, out-of-order responses, idempotency keys.Real time & offline
  • 15Offline-first: Service Worker & cachingThe Angular Service Worker: caching strategies, update prompts and an outbox for changes made offline.Real time & offline
  • 16Web WorkersHeavy computation without blocking the UI: a real worker in the page, message passing and when it is not worth it.Advanced UI & performance
  • 16Huge lists: virtual scroll100,000 rows without lag: CDK virtual scroll, itemSize, trackBy and the alternatives (pagination, infinite scroll).Advanced UI & performance
  • 16Modern animations & View Transitionsanimate.enter and animate.leave instead of the deprecated @angular/animations, CSS transitions and View Transitions on navigation.Advanced UI & performance
  • 16Memory leaks: finding and fixing themSubscriptions, timers and listeners that outlive their component — how to spot them in DevTools and close them.Advanced UI & performance
  • 17Security: XSS, sanitisation & CSPWhat Angular does automatically against XSS, when bypassSecurityTrust becomes dangerous, plus CSP with nonce, Trusted Types and XSRF.Security & globalisation
  • 17Authentication: tokens & refreshAn interceptor that attaches the token, refreshes once on 401 and replays the queued requests — plus where you keep the token.Security & globalisation
  • 17OAuth 2.1 & OpenID Connect with PKCEThe authorization code flow with PKCE, step by step: verifier and challenge, state and nonce, the ID token, and why a BFF is safer.Security & globalisation
  • 17Internationalisation (i18n)$localize, the i18n attribute, ICU plural/select, a build per language vs runtime translations, and locale-based formatting.Security & globalisation