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.
| Service | Production URL | Used for |
|---|---|---|
| API | https://api.muhajirstudio.com/ | Authorization, token exchange, public metadata, and external APIs. |
| Main web | https://login.muhajirstudio.com/ | Hosted user login, signup, email verification, and consent. |
| Admin web | https://admin.muhajirstudio.com/ | Application registration. |
Admin Setup
Register the SPA with:
| Field | Example | Notes |
|---|---|---|
| Allowed web origin | https://spa.example.com | Must exactly match the browser Origin. |
| Redirect path | /auth/callback | The route your SPA handles after login. |
| Scopes | openid profile email | Admin-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:
| Status | Meaning | Client action |
|---|---|---|
200 | Token accepted. | Render the returned scope-filtered profile. |
401 | Token missing, expired, or invalid. | Clear local auth state and start login again. |
403 | Token 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=S256only. - Validate
statebefore token exchange. - Do not use
client_secret. - Do not rely on cookies for
/api/external/*; sendAuthorization: Bearer ACCESS_TOKEN. - Keep token storage minimal and account for XSS risk.
- Re-run login when scopes are changed by an admin.