Connect a web application with a BFF
This tutorial signs a user into a browser application while keeping OAuth tokens outside browser JavaScript. The browser carries an opaque application-session cookie; the backend-for-frontend, or BFF, owns the authorization attempt and token lifecycle.
Use openid-client for a higher-level server OIDC client or oauth4webapi when the application wants direct control over the protocol steps. Both implement the same authorization flow described below. A browser-only application uses a different security boundary; follow the SPA tutorial before choosing @openid/appauth or oidc-client-ts.
Before you start
Register a WEB client with:
- the exact callback, for example
https://app.example.com/api/auth/callback; Authorization Codeand S256 PKCE;- scopes
openid emailplus API scopes needed by the product; - a default resource when the app calls a protected API;
offline_accessonly when the BFF needs a long-lived session.
A confidential BFF uses CLIENT_SECRET_BASIC. Keep that secret only in the server’s secret store.
1. Discover the tenant
ISSUER='https://auth.example.com'
curl --fail --silent --show-error \
"$ISSUER/.well-known/openid-configuration"Require the returned issuer to equal the configured issuer exactly. Read authorization_endpoint and token_endpoint from the document.
2. Create one login attempt
On GET /login, generate four independent values:
| Value | Purpose | Lifetime |
|---|---|---|
state | binds the callback to this browser attempt | until callback, usually a few minutes |
nonce | binds the ID token to this login | until ID-token validation |
| PKCE verifier | proves the callback belongs to the initiating BFF | until code exchange |
| login attempt ID | finds the server-side record without exposing its contents | until callback |
Store the state, nonce, verifier, issuer, redirect URI and post-login destination in a server-side record. Put only the random attempt ID in an HttpOnly, Secure, SameSite=Lax cookie.
Construct the redirect:
GET {authorization_endpoint}
?response_type=code
&client_id=acme-web
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fapi%2Fauth%2Fcallback
&scope=openid%20email%20orders.read%20offline_access
&resource=https%3A%2F%2Fapi.example.com
&state=RANDOM_STATE
&nonce=RANDOM_NONCE
&code_challenge=BASE64URL_SHA256_VERIFIER
&code_challenge_method=S256Allow only server-selected post-login destinations. Never accept an arbitrary callback or return URL from a query parameter.
3. Validate the callback before exchanging anything
The successful callback contains code, state and iss. In this order:
- load and atomically consume the login attempt from its cookie;
- reject a missing, expired or already consumed attempt;
- compare
statewith the stored value; - require
issto equal the stored issuer; - use the redirect URI and verifier stored with the attempt.
If the callback contains error, display a safe application message and discard the attempt. Preserve no authorization code or verifier after either outcome.
4. Exchange the code
ISSUER='https://auth.example.com'
curl --fail --silent --show-error \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=authorization_code' \
--data-urlencode 'client_id=my-public-client' \
--data-urlencode 'redirect_uri=com.example.app:/oauth/callback' \
--data-urlencode 'code=AUTHORIZATION_CODE' \
--data-urlencode 'code_verifier=PKCE_CODE_VERIFIER' \
"$ISSUER/oauth2/token"A confidential BFF also authenticates the client with HTTP Basic. Require 200 OK and parse the documented token response.
Validate the ID token before using its claims:
- signature and allowed
RS256algorithm; - exact
iss; - client ID in
aud; - expiration and issuance times;
- stored
nonce; - authorization-code hash when your token library exposes and enforces it.
The email claim is profile data. Resolve or create product state by the exact (iss, sub) tuple. Never attach an existing product account because its email happens to match.
5. Create the product session
Store the access token, rotating refresh token, issuer, subject, granted scopes and token expiry in a server-side session. Return only a random session cookie to the browser.
Use these cookie properties:
Set-Cookie: app_session=RANDOM_SESSION_ID; Path=/; HttpOnly; Secure; SameSite=LaxRegenerate the application session identifier after login. If the product supports multiple organizations, keep the selected product membership in product-owned session state after checking that the subject may use it.
6. Refresh atomically
Before an API call, refresh when the access token is close to expiry. Serialize refresh for one session so concurrent requests cannot reuse the same rotating refresh token.
After a successful refresh:
- persist the replacement access token and refresh token together;
- commit the session update;
- only then continue the product request.
On invalid_grant, terminate the product session and start a new authorization flow. Replaying the previous refresh token is not a recovery strategy.
7. Log out
Product logout performs three separate actions:
- revoke the refresh token at
POST /oauth2/revoke; - delete the server-side product session;
- expire the application cookie.
Identity browser SSO logout is a same-origin POST /identity/session/logout protected by its browser CSRF token and intended for the hosted Identity origin. It is separate from product logout and is not currently advertised as an OIDC RP-initiated end_session_endpoint. A BFF on another origin therefore ends its product session and leaves the Identity browser SSO session intact.
Verify the finished use case
- a first visit redirects to the exact tenant issuer;
- modifying
stateorissmakes the callback fail before token exchange; - the browser never receives an access or refresh token;
- refreshing replaces the stored refresh token atomically;
- the product account lookup uses
issuer + subject; - product logout removes the application session even if remote revocation fails;
- a second login can reuse or re-establish the Identity browser session according to the authorization request.
See browser integration invariants and OAuth errors when hardening the implementation.