Skip to Content
TutorialsWeb login with a BFF

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 Code and S256 PKCE;
  • scopes openid email plus API scopes needed by the product;
  • a default resource when the app calls a protected API;
  • offline_access only 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:

ValuePurposeLifetime
statebinds the callback to this browser attemptuntil callback, usually a few minutes
noncebinds the ID token to this loginuntil ID-token validation
PKCE verifierproves the callback belongs to the initiating BFFuntil code exchange
login attempt IDfinds the server-side record without exposing its contentsuntil 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=S256

Allow 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:

  1. load and atomically consume the login attempt from its cookie;
  2. reject a missing, expired or already consumed attempt;
  3. compare state with the stored value;
  4. require iss to equal the stored issuer;
  5. 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 RS256 algorithm;
  • exact iss;
  • client ID in aud;
  • expiration and issuance times;
  • stored nonce;
  • authorization-code hash when your token library exposes and enforces it.
Security boundary

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=Lax

Regenerate 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:

  1. persist the replacement access token and refresh token together;
  2. commit the session update;
  3. 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:

  1. revoke the refresh token at POST /oauth2/revoke;
  2. delete the server-side product session;
  3. 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 state or iss makes 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.