Production-ready OAuth 2.0 Authorization Code Flow with PKCE (RFC 7636) for Single Page Applications — built with React + TypeScript.
A companion repository for the tutorial: How to Implement Authorization Code Flow with PKCE in a Single Page Application on iamdevbox.com.
The OAuth 2.0 Implicit Flow is deprecated (OAuth 2.0 Security BCP, RFC 9700). Authorization Code Flow with PKCE is the current best practice for SPAs because:
- No client secret required — SPAs can't store secrets securely
- Cryptographic binding — links the authorization request to the token request via SHA-256 code challenge
- Single-use codes — prevents authorization code interception attacks
- Refresh token support — enables long-lived sessions without re-authentication
89% of OAuth providers now require PKCE for SPAs (Auth0, Okta, Keycloak, ForgeRock, Ping Identity).
- ✅ RFC 7636 compliant PKCE implementation (
S256method) - ✅ CSRF protection via cryptographically secure state parameter
- ✅ Silent token refresh — auto-refreshes access tokens before expiry
- ✅ Secure storage — access tokens in-memory, PKCE params in sessionStorage only
- ✅ Error handling — surfaces OAuth errors with descriptive messages
- ✅ URL cleanup — removes
codeandstateafter token exchange (prevents replay) - ✅ TypeScript strict mode — fully typed API
- ✅ Unit tested — RFC 7636 test vector validation included
- ✅ Compatible with Keycloak, ForgeRock AM, Okta, Auth0, Azure AD B2C, and any OIDC provider
git clone https://github.com/IAMDevBox/oauth-pkce-spa-example.git
cd oauth-pkce-spa-example
npm install
cp .env.example .env
# Edit .env with your authorization server details
npm run devOpen http://localhost:3000 in your browser.
oauth-pkce-spa-example/
├── src/
│ ├── hooks/
│ │ └── useAuth.ts # React hook: login, logout, getAccessToken, auto-refresh
│ ├── utils/
│ │ ├── pkce.ts # RFC 7636: generateCodeVerifier, generateCodeChallenge
│ │ ├── pkce.test.ts # Unit tests (RFC 7636 test vector + edge cases)
│ │ └── storage.ts # Secure sessionStorage helpers for PKCE params
│ ├── App.tsx # Root component + usage example
│ └── main.tsx # React entry point
├── public/
│ └── index.html
├── .env.example # Config template (Keycloak, ForgeRock, generic OIDC)
├── package.json
├── tsconfig.json
└── vite.config.ts
Copy .env.example to .env and fill in your authorization server details:
VITE_CLIENT_ID=my-spa-client
VITE_AUTH_ENDPOINT=https://auth.example.com/oauth2/authorize
VITE_TOKEN_ENDPOINT=https://auth.example.com/oauth2/token
VITE_END_SESSION_ENDPOINT=https://auth.example.com/oauth2/logout- Create a new client in Keycloak Admin → Clients → Create
- Set Client type:
OpenID Connect - Set Client authentication:
Off(public client — no secret) - Enable Standard flow (Authorization Code Flow)
- Set Valid redirect URIs:
http://localhost:3000/* - Set Web origins:
http://localhost:3000
Then in .env:
VITE_CLIENT_ID=my-spa-client
VITE_AUTH_ENDPOINT=http://localhost:8080/realms/myrealm/protocol/openid-connect/auth
VITE_TOKEN_ENDPOINT=http://localhost:8080/realms/myrealm/protocol/openid-connect/token
VITE_END_SESSION_ENDPOINT=http://localhost:8080/realms/myrealm/protocol/openid-connect/logoutFor a full Spring Boot + Keycloak resource server example, see: Keycloak Spring Boot OAuth2 Integration
Register an OAuth2 client in ForgeRock AM → OAuth2 Provider → Clients:
- Client ID:
pkce-spa-client - Client Type:
Public - Redirect URIs:
http://localhost:3000/callback - Response Types:
code - Token Endpoint Auth Method:
none
Then in .env:
VITE_CLIENT_ID=pkce-spa-client
VITE_AUTH_ENDPOINT=https://openam.example.com/openam/oauth2/realms/root/authorize
VITE_TOKEN_ENDPOINT=https://openam.example.com/openam/oauth2/realms/root/access_tokenimport { useAuth, AuthConfig } from './hooks/useAuth';
const config: AuthConfig = {
clientId: 'my-spa-client',
authorizationEndpoint: 'https://auth.example.com/oauth2/authorize',
tokenEndpoint: 'https://auth.example.com/oauth2/token',
redirectUri: window.location.origin + '/callback',
scopes: ['openid', 'profile', 'email'],
};
function MyComponent() {
const { isAuthenticated, isLoading, error, login, logout, getAccessToken } = useAuth(config);
// Auto-refreshes before expiry — safe to call before every API request
const callApi = async () => {
const token = await getAccessToken();
const res = await fetch('/api/data', {
headers: { Authorization: `Bearer ${token}` }
});
return res.json();
};
if (isLoading) return <div>Loading…</div>;
if (!isAuthenticated) return <button onClick={() => login()}>Login</button>;
return <button onClick={callApi}>Call API</button>;
}import { generateCodeVerifier, generateCodeChallenge, verifyTestVector } from './utils/pkce';
// RFC 7636 compliant PKCE generation
const verifier = generateCodeVerifier(); // 32 bytes, base64url-encoded
const challenge = await generateCodeChallenge(verifier); // SHA-256(verifier), base64url-encoded
// Validate your implementation against RFC 7636 Appendix B test vector
const isValid = await verifyTestVector(); // Must be true| Error | Cause | Fix |
|---|---|---|
invalid_grant: PKCE verification failed |
Code challenge ≠ SHA-256(verifier) | Use S256, not plain. Use base64url encoding (replace +→-, /→_, strip =) |
State parameter mismatch |
CSRF attack or tab duplication | Always validate state before processing code. Generate with crypto.getRandomValues() |
invalid_client: redirect_uri_mismatch |
Registered URI doesn't match exactly | Use exact same URI including trailing slash. Use window.location.origin + '/callback' |
Code verifier not found |
sessionStorage cleared (page refresh mid-flow) | Store verifier in sessionStorage before redirect; handle refresh with BFF pattern |
Full error reference: PKCE Implementation Errors — 100+ Debugged
npm test # Run all tests
npm run test:watch # Watch modeTests validate:
- RFC 7636 Appendix B test vector (code verifier → challenge)
- Base64-URL encoding correctness (no
+,/,=) - State uniqueness (no static seeds)
- Code verifier format validation
- Access tokens: stored in-memory (React ref) — never in localStorage
- Refresh tokens: stored in sessionStorage in this demo. In production, use a Backend-for-Frontend (BFF) to store refresh tokens in httpOnly cookies.
- PKCE parameters: cleared immediately after token exchange
- State validation: checked before processing the authorization code (CSRF protection)
- URL cleanup:
codeandstateremoved from browser history after exchange
For production deployment security considerations, see: OAuth 2.1 Security Best Practices
- 📖 Full Tutorial: PKCE in SPAs — step-by-step implementation guide with React hooks
- 🔑 OAuth 2.0 Complete Developer Guide — full OAuth 2.0 reference covering all grant types
- 🌐 OAuth 2.0 Authorization Flow in Node.js — server-side Authorization Code Flow with Express
- 🔒 OAuth 2.1 Security Best Practices — mandatory PKCE, token binding, and DPoP
- 🛠️ JWT Decoder Tool — decode and inspect JWT access tokens in your browser
- 🔧 PKCE Generator Tool — generate code verifier and challenge pairs interactively
- RFC 7636 — Proof Key for Code Exchange by OAuth Public Clients
- RFC 6749 — The OAuth 2.0 Authorization Framework
- OAuth 2.0 Security BCP — Current best practices (deprecates Implicit Flow)
- OAuth 2.1 — Draft consolidating OAuth 2.0 + PKCE + BCP
MIT © IAMDevBox — IAM tutorials and tools for developers.