Skip to content

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)  │
└──────────┘         └─────────────┘         └──────────────┘
  1. Browser redirects to /oauth/authorize with a code_challenge.
  2. User authenticates; SingleSign redirects back with an authorization code.
  3. Browser sends the code plus the original code_verifier to /oauth/token.
  4. SingleSign verifies the PKCE pair and returns tokens.
  5. Browser calls your API with the access token.

Prerequisites

  • A registered SingleSign app with Application Type set to "Single-Page Application."
  • Your client_id and platform_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 MethodRisk LevelNotes
sessionStorageLowRecommended for SPAs. Scoped to tab, cleared on close.
localStorageMediumPersists across tabs and sessions. Vulnerable to XSS. Avoid for sensitive tokens.
In-memory variableLowestLost on page refresh. Good for short sessions; pair with silent refresh.
CookiesLow–MediumUse 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

Last updated: 2026-02-16