Connect a browser-only SPA
A single-page application can act as a public OAuth client. A browser OIDC library owns PKCE, authorization state, callback validation and the token lifecycle. The hosted Identity pages still own password entry, registration, MFA and consent.
This model gives browser JavaScript an access token. Prefer the BFF tutorial whenever the application has a backend that can keep tokens outside the browser.
Current deployment constraint
Identity currently allows discovery to be read cross-origin, but its token, revocation and UserInfo endpoints do not publish cross-origin permissions for arbitrary application origins. A direct SPA therefore works only when those requests are same-origin with its tenant issuer.
For example, an SPA served below https://auth.example.com/apps/acme/ may call the https://auth.example.com/oauth2/* endpoints directly. An SPA at https://app.example.com cannot complete a direct token exchange with that issuer in the current release. Use a BFF for that deployment.
Do not work around the restriction with a wildcard reverse-proxy header. Cross-origin browser clients need explicit, client-scoped trusted origins before this model can safely support a separate application origin.
Before you start
Register a client with:
{
"clientId": "acme-spa",
"displayName": "Acme browser application",
"applicationType": "WEB",
"tokenEndpointAuthMethod": "NONE",
"consentPolicy": "EXPLICIT",
"redirectUris": ["https://auth.example.com/apps/acme/auth/callback"],
"scopes": ["openid", "email", "orders.read"],
"defaultResource": "https://api.example.com",
"nativeAuthenticationEnabled": false
}The client has no secret. A value shipped in JavaScript cannot authenticate a client. Register every production and local callback exactly; do not use wildcard redirects.
Keep offline_access out of the initial integration. Add it only after deciding how the application will protect and rotate a refresh token exposed to the browser runtime.
1. Choose the browser library
Two standards-based choices cover this use case:
| Library | Level | Use it when |
|---|---|---|
oidc-client-ts | session-oriented OIDC client | the application wants redirect, callback and user-session orchestration |
@openid/appauth | direct AppAuth protocol mapping | the application wants to own token storage and lifecycle policy around lower-level primitives |
The following example uses oidc-client-ts because it provides the complete browser session orchestration:
npm install oidc-client-tsIf the application uses AppAuth-JS instead, install the direct AppAuth implementation:
npm install @openid/appauth2. Configure one public client
import { UserManager, WebStorageStateStore } from 'oidc-client-ts'
export const identity = new UserManager({
authority: 'https://auth.example.com',
client_id: 'acme-spa',
redirect_uri: 'https://auth.example.com/apps/acme/auth/callback',
response_type: 'code',
scope: 'openid email orders.read',
stateStore: new WebStorageStateStore({ store: window.sessionStorage }),
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
automaticSilentRenew: false,
monitorSession: false,
})The authority is the exact tenant issuer. The library discovers endpoints from that issuer and generates a fresh PKCE verifier, challenge and authorization state for each redirect.
Session storage survives the authorization redirect in the same tab. It is still readable by JavaScript, so a successful script injection can read its tokens. Enforce a restrictive Content Security Policy, avoid unsafe HTML injection and keep third-party scripts bounded.
3. Start hosted login
export async function signIn(): Promise<void> {
await identity.signinRedirect({
extraQueryParams: {
resource: 'https://api.example.com',
},
})
}Identity may display registration, email verification, an upstream provider, MFA or consent. Those branches remain inside the hosted flow; the SPA only waits for its registered callback.
4. Complete the callback
Run this code only on the registered callback route:
const user = await identity.signinRedirectCallback()
if (user.expired || !user.access_token) {
throw new Error('Identity returned no usable access token')
}
const subject = user.profile.sub
const issuer = user.profile.issThe library validates the stored authorization response state and processes the code with the original PKCE verifier. Require the returned issuer to be the configured authority and identify product accounts by (issuer, subject).
If the callback contains error, clear the pending attempt and show an application-safe message. access_denied may mean that the user cancelled consent; login_required means a new interactive authorization is required. Use the complete authorization and token error reference for protocol handling.
Equivalent AppAuth-JS wiring
AppAuth-JS exposes the same work as explicit protocol objects. Configure the notifier before starting or completing a redirect:
import {
AuthorizationNotifier,
AuthorizationRequest,
AuthorizationServiceConfiguration,
BaseTokenRequestHandler,
GRANT_TYPE_AUTHORIZATION_CODE,
RedirectRequestHandler,
TokenRequest,
} from '@openid/appauth'
const issuer = 'https://auth.example.com'
const clientId = 'acme-spa'
const redirectUri = 'https://auth.example.com/apps/acme/auth/callback'
const configuration =
await AuthorizationServiceConfiguration.fetchFromIssuer(issuer)
const notifier = new AuthorizationNotifier()
const authorization = new RedirectRequestHandler()
authorization.setAuthorizationNotifier(notifier)
export function signInWithAppAuth(): void {
const nonce = crypto.randomUUID()
authorization.performAuthorizationRequest(
configuration,
new AuthorizationRequest({
client_id: clientId,
redirect_uri: redirectUri,
response_type: AuthorizationRequest.RESPONSE_TYPE_CODE,
scope: 'openid email orders.read',
state: undefined,
extras: {
nonce,
resource: 'https://api.example.com',
},
}),
)
}
notifier.setAuthorizationListener(async (request, response, error) => {
if (error || !response) throw error ?? new Error('Authorization failed')
const verifier = request.internal?.code_verifier
const expectedNonce = request.extras?.nonce
if (!verifier || !expectedNonce) {
throw new Error('Missing authorization transaction state')
}
const tokens = await new BaseTokenRequestHandler().performTokenRequest(
configuration,
new TokenRequest({
client_id: clientId,
redirect_uri: redirectUri,
grant_type: GRANT_TYPE_AUTHORIZATION_CODE,
code: response.code,
refresh_token: undefined,
extras: { code_verifier: verifier },
}),
)
await commitValidatedSession(tokens, {
expectedIssuer: issuer,
expectedAudience: clientId,
expectedNonce,
})
})
await authorization.completeAuthorizationRequestIfPossible()commitValidatedSession is application code: verify the ID-token signature and require the expected issuer, audience, nonce and lifetime before using its subject. AppAuth-JS performs the OAuth redirect, PKCE and token requests; its token-response parsing is not a replacement for that OIDC claim validation. Persist only the minimum token state required by the chosen browser-session policy. Do not run oidc-client-ts and AppAuth-JS for the same client session.
5. Call the product API
const user = await identity.getUser()
if (!user || user.expired) {
await identity.signinRedirect()
throw new Error('Redirecting to Identity')
}
const response = await fetch('https://api.example.com/orders', {
headers: { Authorization: `Bearer ${user.access_token}` },
})The product API must separately allow the SPA origin and must validate the token’s signature, issuer, audience, lifetime and required scopes. Browser-side claim checks do not authorize an API request.
6. Expire and sign out safely
Without offline_access, start a new authorization redirect after expiry. An existing Identity browser session can make that redirect complete without another password prompt while issuer policy still controls MFA and consent.
Application logout revokes available tokens, removes local state and returns to an anonymous application route:
try {
await identity.revokeTokens(['refresh_token', 'access_token'])
} finally {
await identity.removeUser()
location.assign('/signed-out')
}Identity does not currently advertise an OIDC end_session_endpoint. Do not call the library’s RP-initiated logout API. Application logout leaves the hosted Identity browser SSO session intact.
Verify the finished use case
- the SPA and OAuth endpoints share the exact issuer origin;
- the client is
WEBwithtokenEndpointAuthMethod: NONEand has no secret; - changing callback
statemakes the library reject the response before tokens are accepted; - the authorization request uses S256 PKCE and the exact registered callback;
- the access token reaches only APIs that expect its audience and scopes;
- clearing the application session removes every locally held token;
- a separate-origin SPA is routed through a BFF instead of relying on permissive proxy CORS.
Use browser integration invariants to compare this model with the BFF boundary.