SPA Integration with PKCE
Single-page applications (SPAs) run entirely in the browser, which means they cannot safely store a client secret. SingleSign uses the Authorization Code flow with Proof Key for Code Exchange (PKCE) to secure the token exchange without a secret.
This guide covers the complete flow from login initiation to token refresh. All code samples use vanilla TypeScript — adapt them to React, Vue, Angular, or any framework.
How the Flow Works
┌──────────┐ ┌─────────────┐ ┌──────────────┐
│ Browser │────1───▶│ SingleSign │ │ Your API │
│ (SPA) │◀───2────│ /authorize │ │ │
│ │────3───▶│ /token │ │ │
│ │◀───4────│ (tokens) │ │ │
│ │─────────5─────────────────────▶│ (API call) │
└──────────┘ └─────────────┘ └──────────────┘
- Browser redirects to
/oauth/authorizewith acode_challenge. - User authenticates; SingleSign redirects back with an authorization
code. - Browser sends the
codeplus the originalcode_verifierto/oauth/token. - SingleSign verifies the PKCE pair and returns tokens.
- Browser calls your API with the access token.
Prerequisites
- A registered SingleSign app with Application Type set to "Single-Page Application."
- Your
client_idandplatform_guid. - At least one redirect URI registered (e.g.,
http://localhost:5173/callback).
Step 1: Configuration
const config = {
clientId: "your-client-id",
platformGuid: "your-platform-guid",
redirectUri: "http://localhost:5173/callback",
scopes: "openid profile email",
authorizationEndpoint: "https://api.singlesign.com/oauth/authorize",
tokenEndpoint: "https://api.singlesign.com/oauth/token",
userinfoEndpoint: "https://api.singlesign.com/oauth/userinfo",
};
Step 2: PKCE Helpers
function generateRandomString(length: number): string {
const array = new Uint8Array(length);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
bytes.forEach((b) => (binary += String.fromCharCode(b)));
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function sha256(plain: string): Promise<ArrayBuffer> {
const encoder = new TextEncoder();
return crypto.subtle.digest("SHA-256", encoder.encode(plain));
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const hash = await sha256(verifier);
return base64UrlEncode(new Uint8Array(hash));
}
Step 3: Start the Login Flow
Generate PKCE values and a CSRF state, store them in sessionStorage, then redirect.
async function login(): Promise<void> {
// Generate PKCE pair
const codeVerifier = generateRandomString(32);
const codeChallenge = await generateCodeChallenge(codeVerifier);
// Generate CSRF state
const state = generateRandomString(16);
// Persist across the redirect
sessionStorage.setItem("pkce_code_verifier", codeVerifier);
sessionStorage.setItem("oauth_state", state);
const params = new URLSearchParams({
platform_guid: config.platformGuid,
response_type: "code",
client_id: config.clientId,
redirect_uri: config.redirectUri,
scope: config.scopes,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
window.location.href = `${config.authorizationEndpoint}?${params}`;
}
Why sessionStorage?
- Survives the redirect round-trip within the same tab.
- Automatically cleared when the tab closes.
- Not shared across tabs, limiting the attack surface.
Step 4: Handle the Callback
On your /callback route, validate state and exchange the code.
async function handleCallback(): Promise<{
access_token: string;
id_token: string;
expires_in: number;
refresh_token?: string;
}> {
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const returnedState = params.get("state");
const error = params.get("error");
// Handle authorization errors
if (error) {
const description = params.get("error_description") || "Unknown error";
throw new Error(`Authorization failed: ${error} — ${description}`);
}
// Validate state
const savedState = sessionStorage.getItem("oauth_state");
if (!returnedState || returnedState !== savedState) {
throw new Error("State mismatch — possible CSRF attack. Please try logging in again.");
}
// Retrieve code verifier
const codeVerifier = sessionStorage.getItem("pkce_code_verifier");
if (!codeVerifier) {
throw new Error("Missing PKCE code verifier. Please restart the login flow.");
}
// Exchange code for tokens
const response = await fetch(config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: code!,
redirect_uri: config.redirectUri,
client_id: config.clientId,
code_verifier: codeVerifier,
platform_guid: config.platformGuid,
}),
});
// Clean up
sessionStorage.removeItem("pkce_code_verifier");
sessionStorage.removeItem("oauth_state");
if (!response.ok) {
const err = await response.json();
throw new Error(`Token exchange failed: ${err.error} — ${err.error_description}`);
}
return response.json();
}
Step 5: Store and Use Tokens
async function completeLogin(): Promise<void> {
const tokens = await handleCallback();
// Store tokens — sessionStorage keeps them scoped to this tab
sessionStorage.setItem("access_token", tokens.access_token);
sessionStorage.setItem("id_token", tokens.id_token);
if (tokens.refresh_token) {
sessionStorage.setItem("refresh_token", tokens.refresh_token);
}
// Record expiry so we can refresh proactively
const expiresAt = Date.now() + tokens.expires_in * 1000;
sessionStorage.setItem("token_expires_at", expiresAt.toString());
// Redirect to the app
window.location.replace("/dashboard");
}
Fetch User Profile
async function fetchUserProfile(): Promise<Record<string, unknown>> {
const token = sessionStorage.getItem("access_token");
const response = await fetch(config.userinfoEndpoint, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`UserInfo failed: ${response.status}`);
}
return response.json();
}
Step 6: Refresh Tokens (Optional)
If you requested the offline_access scope, you received a refresh_token. Use it to get a new access token without requiring the user to log in again.
async function refreshAccessToken(): Promise<void> {
const refreshToken = sessionStorage.getItem("refresh_token");
if (!refreshToken) {
throw new Error("No refresh token available.");
}
const response = await fetch(config.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: config.clientId,
platform_guid: config.platformGuid,
}),
});
if (!response.ok) {
// Refresh token may be expired — force re-login
sessionStorage.clear();
window.location.href = "/login";
return;
}
const tokens = await response.json();
sessionStorage.setItem("access_token", tokens.access_token);
if (tokens.refresh_token) {
sessionStorage.setItem("refresh_token", tokens.refresh_token);
}
const expiresAt = Date.now() + tokens.expires_in * 1000;
sessionStorage.setItem("token_expires_at", expiresAt.toString());
}
Proactive Refresh
Set up a timer to refresh the token before it expires:
function scheduleTokenRefresh(): void {
const expiresAt = Number(sessionStorage.getItem("token_expires_at"));
const now = Date.now();
// Refresh 60 seconds before expiry
const refreshIn = Math.max((expiresAt - now) - 60_000, 0);
setTimeout(async () => {
await refreshAccessToken();
scheduleTokenRefresh(); // schedule the next refresh
}, refreshIn);
}
Step 7: Logout
Clear local state and, optionally, redirect to SingleSign's logout endpoint:
function logout(): void {
sessionStorage.removeItem("access_token");
sessionStorage.removeItem("id_token");
sessionStorage.removeItem("refresh_token");
sessionStorage.removeItem("token_expires_at");
window.location.href = "/";
}
Token Storage: Security Considerations
| Storage Method | Risk Level | Notes |
|---|---|---|
sessionStorage | Low | Recommended for SPAs. Scoped to tab, cleared on close. |
localStorage | Medium | Persists across tabs and sessions. Vulnerable to XSS. Avoid for sensitive tokens. |
| In-memory variable | Lowest | Lost on page refresh. Good for short sessions; pair with silent refresh. |
| Cookies | Low–Medium | Use HttpOnly, Secure, SameSite=Strict. Requires a BFF (backend-for-frontend) pattern. |
Recommendation: Use sessionStorage for most SPAs. If you need tokens to survive page refreshes across tabs, consider a backend-for-frontend proxy.
Complete Example
<!DOCTYPE html>
<html lang="en">
<head><title>SingleSign SPA Demo</title></head>
<body>
<button id="login">Log in with SingleSign</button>
<pre id="user"></pre>
<script type="module">
// ... include config, PKCE helpers, login(), handleCallback(), fetchUserProfile() from above ...
// Login button
document.getElementById("login").addEventListener("click", login);
// If on the callback route, complete the flow
if (window.location.pathname === "/callback") {
try {
const tokens = await handleCallback();
sessionStorage.setItem("access_token", tokens.access_token);
const user = await fetchUserProfile();
document.getElementById("user").textContent = JSON.stringify(user, null, 2);
} catch (err) {
document.getElementById("user").textContent = `Error: ${err.message}`;
}
}
</script>
</body>
</html>
Next Steps
- PKCE Concepts — Deep dive into how PKCE works.
- Tokens & Claims — Decode and verify your JWTs.
- Endpoint Reference — Full API reference.
- Troubleshooting — Fix common SPA integration issues.
Last updated: 2026-02-16
Related docs
- SingleSign Developer DocumentationLearn how to integrate SingleSign identity and authentication into your applications using OAuth 2.0 and OpenID Connect.
- React authentication with PKCEReact authentication done the recommended way: Authorization Code with PKCE, and no client secret in your JavaScript bundle.
- SaaS single sign-onSSO for SaaS applications using OAuth 2.0 and OpenID Connect: the flow to pick, the scopes to request, and a working integration path with SingleSign.
- Proof Key for Code Exchange (PKCE)Why PKCE exists, how the S256 challenge method works, and when to use it with SingleSign.
- Mobile app authenticationMobile app authentication for iOS and Android: Authorization Code with PKCE in a system browser, custom scheme redirects, and secure token storage on device.
- Firebase Auth comparisonSingleSign vs Firebase Authentication on lock-in, portability and consent, plus the cases where staying on Firebase is right.
- SingleSign vs Auth0SingleSign vs Auth0 compared on the difference that matters: who owns the account. A side-by-side table, then the three cases where each one is the right call.
- Authentication for startupsAuthentication for startups without a monthly-active-user meter: standards-based OAuth 2.0 sign-in you will not have to replace when the product finds traction.