Skip to main content

React SPA Integration

This recipe shows how a browser-only React single-page application can use Authorization Code Flow with PKCE and call Muhajir Studio external APIs directly from the browser.

Browser Integration Model

React SPAs are Public PKCE clients. They do not use client_secret, and all token/API browser calls must come from a registered web origin.

ServiceProduction URLUsed for
APIhttps://api.muhajirstudio.com/Authorization, token exchange, public metadata, and external APIs.
Main webhttps://login.muhajirstudio.com/Hosted user login, signup, email verification, and consent.
Admin webhttps://admin.muhajirstudio.com/Application registration.

Admin Setup

Register the SPA with:

FieldExampleNotes
Allowed web originhttps://spa.example.comMust exactly match the browser Origin.
Redirect path/auth/callbackThe route your SPA handles after login.
Scopesopenid profile emailAdmin-selected scopes control effective grants.

Dynamic third-party CORS is enabled for the token endpoint and /api/external/*. It allows registered origins only, uses credentials: false, and allows the Authorization and Content-Type headers.

1. Generate PKCE in the Browser

Generate a verifier, challenge, state, and nonce for each login attempt. Store short-lived values in sessionStorage so the callback can validate them.

function randomBase64Url(bytes = 32) {
const values = crypto.getRandomValues(new Uint8Array(bytes));
return btoa(String.fromCharCode(...values))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

async function sha256Base64Url(value: string) {
const data = new TextEncoder().encode(value);
const hash = await crypto.subtle.digest('SHA-256', data);
return btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}

2. Redirect to Authorization

Build the authorization URL using the production API base URL.

In the examples below, apiBaseUrl is the production API URL and clientId is the public client ID from web-admin.

const verifier = randomBase64Url();
const challenge = await sha256Base64Url(verifier);
const state = randomBase64Url();
const nonce = randomBase64Url();

sessionStorage.setItem('oidc.pkce.verifier', verifier);
sessionStorage.setItem('oidc.oauth.state', state);
sessionStorage.setItem('oidc.oauth.nonce', nonce);

const params = new URLSearchParams({
client_id: clientId,
redirect_uri: `${window.location.origin}/auth/callback`,
response_type: 'code',
scope: 'openid profile email',
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
});

window.location.assign(`${apiBaseUrl}/api/auth/oauth2/authorize?${params}`);

redirect_uri must exactly match the derived redirect URI registered in web-admin.

3. Handle the Callback

On /auth/callback, validate state, then exchange the authorization code.

const search = new URLSearchParams(window.location.search);

if (search.get('error')) {
throw new Error(search.get('error_description') ?? search.get('error')!);
}

if (search.get('state') !== sessionStorage.getItem('oidc.oauth.state')) {
throw new Error('Invalid OAuth state');
}

const tokenRes = await fetch(`${apiBaseUrl}/api/auth/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: search.get('code')!,
code_verifier: sessionStorage.getItem('oidc.pkce.verifier')!,
client_id: clientId,
redirect_uri: `${window.location.origin}/auth/callback`,
}),
});

const tokenBody = await tokenRes.json();
if (!tokenRes.ok) {
throw new Error(tokenBody.error_description ?? tokenBody.error);
}

Do not set credentials: 'include' for third-party token or external API requests. Third-party CORS is non-credentialed.

4. Store and Use Tokens Carefully

Prefer in-memory token storage for SPAs. If you persist tokens in browser storage, treat that as an explicit security trade-off because JavaScript-accessible storage is exposed to XSS.

Call the external API with the bearer access token:

const meRes = await fetch(`${apiBaseUrl}/api/external/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});

Handle common responses:

StatusMeaningClient action
200Token accepted.Render the returned scope-filtered profile.
401Token missing, expired, or invalid.Clear local auth state and start login again.
403Token valid but missing a required scope.Ask an admin to update scopes or request a new login after scope changes.

Production Checklist

  • Register the exact production origin and callback path.
  • Use code_challenge_method=S256 only.
  • Validate state before token exchange.
  • Do not use client_secret.
  • Do not rely on cookies for /api/external/*; send Authorization: Bearer ACCESS_TOKEN.
  • Keep token storage minimal and account for XSS risk.
  • Re-run login when scopes are changed by an admin.