Wiring PKCE Login Into a Single-Page App With Okta (Without Hand-Rolling OAuth)
Okta's recommended SPA login is OIDC Authorization Code flow with PKCE. Here's the setup, a working okta-auth-js example, and the token-storage trade-off you need to decide on deliberately.
07 Aug 2025, 06:04 UTC

Your single-page app needs login, and Okta is the identity provider. The useful news up front: the flow you want is OpenID Connect Authorization Code with PKCE, and Okta's SDKs implement almost all of it for you. The mistakes that bite teams — using the legacy implicit flow, stuffing tokens in localStorage, relying on silent iframe renewal — are all avoidable if you make three decisions deliberately.
Why PKCE and not the implicit flow
Older SPA tutorials used the OAuth implicit flow, which returned access tokens directly in the redirect URL fragment. That leaks tokens into browser history, logs, and referrer headers, and it offers no clean refresh story. The current recommended pattern for browser-based apps is Authorization Code flow with PKCE (Proof Key for Code Exchange). PKCE adds a one-time secret: your app generates a random code_verifier, sends its SHA-256 hash (code_challenge) to Okta's authorize endpoint, and only the holder of the original verifier can exchange the returned code for tokens. An attacker who intercepts the code gets nothing.
Okta supports this flow natively for app integrations of type SPA. Assume a current Okta developer org and a recent okta-auth-js version; dashboard labels and SDK defaults shift between releases, so confirm against the current Okta docs before shipping.
Setting up the Okta side
In the Okta Admin Console (admin access required), create an app integration: OIDC sign-in method, application type Single-Page Application. Then configure:
- Sign-in redirect URI — the exact URL your app listens on after login, e.g.
http://localhost:5173/login/callbackfor local dev. This must match exactly, including port and trailing path. - Sign-out redirect URI — where users land after logout.
- Trusted origins — your app's origin must be listed for CORS, or token requests from the browser will be blocked.
- Grant types — Authorization Code is on by default for SPAs; enable Refresh Token only if you actually want refresh tokens (more on that below).
Note the client ID and your Okta domain (the issuer). The issuer matters: tokens from Okta's org authorization server and from a custom authorization server differ. Only the custom server issues JWT access tokens you can validate locally against a JWKS endpoint and customize with claims — if your API needs to verify tokens on its own, use the custom server issuer (typically https://<your-domain>/oauth2/default).
The app side: let the SDK do the flow
Hand-rolling the authorize redirect, state parameter, nonce, and code exchange is where subtle OAuth bugs come from. okta-auth-js wraps them. A minimal setup, run in your app's frontend code (no special permissions needed):
import { OktaAuth } from '@okta/okta-auth-js';
const oktaAuth = new OktaAuth({
issuer: 'https://<your-okta-domain>/oauth2/default',
clientId: '<your-client-id>',
redirectUri: window.location.origin + '/login/callback',
scopes: ['openid', 'profile', 'email'],
pkce: true // default for SPA-type clients in current SDK versions
});
// Start login (redirects the browser to Okta)
await oktaAuth.signInWithRedirect();
// On your /login/callback route:
if (oktaAuth.isLoginRedirect()) {
await oktaAuth.handleLoginRedirect(); // exchanges code for tokens
}The SDK generates and stores the PKCE verifier, validates state and the ID token's signature and claims, and performs the code exchange at the token endpoint. After handleLoginRedirect() resolves, check await oktaAuth.authStateManager.getAuthState() — isAuthenticated should be true and accessToken populated. Decode the access token (jwt.io works for inspection) and confirm the iss matches your issuer and aud matches what your API expects.
The storage and renewal trade-off
Here's the decision with real teeth: where do tokens live, and how do they get renewed?
- localStorage (SDK default for SPAs) — survives page reloads, but any XSS in your app or a dependency can exfiltrate tokens. Acceptable for low-sensitivity apps with tight CSP and dependency hygiene; uncomfortable for anything regulated.
- In-memory storage — tokens vanish on reload, so renewal depends on either refresh tokens or silent renewal via a hidden iframe hitting Okta's authorize endpoint with
prompt=none. The iframe approach breaks as browsers block third-party cookies — treat it as unreliable going forward. - Refresh tokens with rotation — Okta supports refresh token rotation for SPAs: each use issues a new refresh token and invalidates the old one, limiting replay damage. Still, a refresh token in browser storage is a long-lived credential an XSS can steal mid-rotation-window.
- Backend-for-frontend (BFF) — a thin server component holds tokens and issues the browser a cookie-based session. More infrastructure, but tokens never touch JavaScript. This is the direction security guidance is moving for high-sensitivity apps.
A practical default: start with the SDK's defaults and refresh token rotation enabled, keep token lifetimes short, and move to a BFF if your threat model includes serious XSS exposure.
Verify it actually works
Before calling it done: log in, then revoke the user's session in the Okta Admin Console and confirm your app fails closed on the next renewal. Test in a browser with third-party cookies blocked — if renewal silently depends on them, you'll find out now instead of in production. And confirm your API independently validates the access token's signature (via the issuer's JWKS), expiry, issuer, and audience rather than trusting the frontend.
The actionable takeaway: register an SPA integration, use okta-auth-js with PKCE (the default), point your API at the custom authorization server's JWKS, and make the token-storage decision explicitly instead of inheriting it. Verify current SDK and dashboard behavior against Okta's docs before publishing anything based on this — these details do drift between releases.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.