OAuth 2.0 Security: Single-Page Apps & PKCE
Single-Page Applications (SPAs) executing inside client-side web browsers are classified by OAuth 2.0 standards as Public Clients. Unlike confidential backend servers, browser JavaScript cannot securely store client secrets without exposing them to reverse-engineering, Cross-Site Scripting (XSS), or browser developer tools.
Historically, SPAs used the deprecated Implicit Flow, which returned access tokens directly in URL hash fragments (#access_token=...), exposing tokens to browser history logs and referrer headers. The modern gold standard is OAuth 2.0 with PKCE (Proof Key for Code Exchange). This guide details PKCE cryptographic verifiers, authorization code exchanges, and secure token storage strategies using HTTP-only cookies.
Mental Model: Proof Key for Code Exchange (PKCE) Flow
PKCE dynamically binds an authorization code request to the specific browser session that initiated the login flow, neutralizing authorization code interception attacks.
Before redirecting the user to the Identity Provider (IdP), the SPA generates a high-entropy random string called the Code Verifier. It creates a cryptographic SHA-256 hash of this string called the Code Challenge.
The SPA sends the code_challenge to the authorization server during login. When the IdP redirects back with an authorization_code, the SPA sends both the authorization_code AND the unhashed code_verifier back to token endpoint. The IdP hashes code_verifier and verifies it matches original code_challenge before returning access tokens. For API-level OAuth rules, read securing rest apis oauth2 jwt best practices and zero trust architecture cloud native microservices.
Quick reference
- PKCE replaces client secrets with dynamic per-request cryptographic verifier pairs.
- Code Verifier is a high-entropy unguessable random string (43 to 128 characters).
- Code Challenge is generated via base64url(sha256(code_verifier)).
- Prevents malicious browser extensions from intercepting and redeeming authorization codes.
- Required by IETF OAuth 2.1 specifications for all browser-based public clients.
Remember this
Implement PKCE by sending code_challenge during login and code_verifier during token redemption.
Generating Cryptographic Code Verifier & Code Challenge Pairs
Generating secure PKCE verifiers requires using browser Web Crypto APIs (window.crypto.getRandomValues) rather than predictable Math.random() functions.
First, generate a 32-byte cryptographically secure random array and encode it as URL-safe base64 string (code_verifier). Second, pass code_verifier through crypto.subtle.digest('SHA-256', buffer) to compute its SHA-256 hash digest (code_challenge).
Store code_verifier temporarily in sessionStorage or an in-memory variable during authorization redirect, and clear it immediately after exchanging the code for tokens.
Quick reference
- Use Web Crypto API (window.crypto.getRandomValues) to generate 32 random bytes.
- Apply base64url encoding without padding characters (=, +, /) to satisfy RFC 7636.
- Compute SHA-256 hash using crypto.subtle.digest('SHA-256', verifierBytes).
- Store code_verifier temporarily in sessionStorage during login redirect.
- Clear code_verifier from client storage immediately after token exchange completes.
Remember this
Use Web Crypto API window.crypto.getRandomValues and SHA-256 to create URL-safe PKCE verifier pairs.
Token Storage: In-Memory vs HTTP-Only SameSite Cookies
Storing access tokens in localStorage or sessionStorage makes them vulnerable to XSS (Cross-Site Scripting) attacks. Any malicious third-party script injected via an npm dependency or CDN script tag can read localStorage.getItem('access_token') and exfiltrate user credentials.
To achieve maximum security, store tokens inside HTTP-Only, Secure, SameSite=Strict cookies managed by a Backend-For-Frontend (BFF) proxy or serverless API route.
JavaScript running in the browser DOM cannot access HTTP-Only cookies. When the SPA calls API routes, the browser includes the cookie automatically, protecting tokens from XSS exfiltration.
Quick reference
- Never store sensitive JWT access or refresh tokens in localStorage or sessionStorage.
- Malicious XSS scripts can read localStorage variables and steal authentication tokens.
- Use HTTP-Only cookies to make tokens completely invisible to JavaScript DOM APIs.
- Set Secure flag to enforce HTTPS-only cookie transmission across network adapters.
- Apply SameSite=Strict or SameSite=Lax flags to neutralize Cross-Site Request Forgery (CSRF).
Remember this
Store authentication tokens inside HTTP-Only SameSite=Strict cookies to protect tokens from XSS exfiltration.
Handling Silent Refresh Tokens & Token Rotation
Because SPA access tokens should be short-lived (e.g., 15 minutes expiration), application sessions require automated background token renewal.
Implement Refresh Token Rotation. Every time the client presents a refresh token to obtain a new access token, the IdP invalidates the old refresh token and issues a brand-new refresh token.
If an attacker captures a leaked refresh token and attempts to use it after legitimate rotation, the IdP detects reuse, invalidates all tokens associated with that user session immediately, and forces a fresh re-authentication.
Quick reference
- Configure short-lived access tokens (15 minutes) paired with refresh token rotation.
- Identity Provider issues a new refresh token upon every refresh request execution.
- Refresh token reuse detection automatically invalidates compromised user sessions.
- Execute silent token refresh using background fetch calls before access tokens expire.
- Handle token expiration gracefully by redirecting users to login without losing active form state.
Remember this
Deploy Refresh Token Rotation with reuse detection to protect short-lived access token sessions.
Key takeaway
To test OAuth 2.0 PKCE flow, trigger login in Chrome DevTools. Inspect network requests to confirm code_challenge is sent to authorization endpoint and code_verifier is passed to /oauth/token.
Related Articles
Explore this topic