Skip to content

Integrate

Node.js server (BFF)

A complete Express server with openid-client. Tokens stay on the server, the session is derived from the IdP, access is granted by (iss, sub). The suite runs this exact code against the IdP.

A confidential client in the backend-for-frontend shape: the server does the login, keeps every token in its own session store, and gives the browser only an HttpOnly cookie. One file, site/examples/node-bff/server.ts. The IdP’s test suite runs it against a real IdP on every change: login, refresh, a blocked account, logout, invitations.

Stack: Node.js 24, Express 5, express-session, openid-client v6.

Register a development client (Registering an app):

clients/my-app-dev.json
{
"clientId": "my-app-dev",
"name": "My App (local development)",
"profile": "confidential",
"redirectUris": ["http://localhost:3000/callback"],
"webOrigins": [],
"postLogoutRedirectUris": ["http://localhost:3000/"],
"secretSsmPath": "/idp/prod/clients/my-app-dev",
"notes": "One client per environment: localhost never shares a descriptor with production."
}

Install and configure:

package.json
{
"name": "urupema-idp-node-bff",
"private": true,
"type": "module",
"engines": { "node": ">=24" },
"scripts": { "start": "node --env-file=.env server.ts", "check": "tsc -p ." },
"dependencies": { "express": "5.2.1", "express-session": "1.19.0", "openid-client": "6.8.8" },
"devDependencies": { "@types/express": "5.0.6", "@types/express-session": "1.19.0", "@types/node": "26.6.4", "typescript": "7.0.2" }
}
.env
IDP_ISSUER=https://id.institutourupema.com.br/realms/urupema
IDP_CLIENT_ID=my-app-dev
# Delivered over a secure channel; the same value the IdP keeps in SSM. Never commit it.
IDP_CLIENT_SECRET=
APP_URL=http://localhost:3000
# openssl rand -hex 32
SESSION_SECRET=
# Demo only: access granted before first login, "email=permission,..."
APP_INVITES=

Run node --env-file=.env server.ts and open http://localhost:3000/login.

The app knows one address: the issuer. discovery fetches the endpoints and the signing keys, and fails if the document’s issuer differs from the configured one. enableNonRepudiationChecks makes openid-client verify the ID token signature against the published keys (off by default).

server.ts
import express from "express";
import session from "express-session";
import * as client from "openid-client";
const { IDP_ISSUER, IDP_CLIENT_ID, IDP_CLIENT_SECRET, APP_URL, SESSION_SECRET } =
process.env as Record<string, string>;
// Discovery at boot: endpoints and keys come from the issuer, never written by hand.
// openid-client checks that the discovered `issuer` equals IDP_ISSUER.
const config = await client.discovery(
new URL(IDP_ISSUER),
IDP_CLIENT_ID,
IDP_CLIENT_SECRET,
undefined,
// Also verify the ID token signature against the discovered JWKS.
{ execute: [client.enableNonRepudiationChecks] },
);

Tokens live in the session store on the server. The cookie carries only the session id. Its maxAge equals the IdP’s 10-hour session ceiling and is renewed whenever the session changes (each refresh); the ceiling is the IdP refusing the refresh once the central session reaches 10 hours, which ends the local session at the next request. Production: a persistent store (Redis, Postgres); MemoryStore loses every session on restart.

server.ts
type Person = { iss: string; sub: string; name?: string; email?: string };
declare module "express-session" {
interface SessionData {
login?: { state: string; nonce: string; verifier: string };
tokens?: { access: string; refresh: string; idToken: string; expiresAt: number };
person?: Person;
}
}
const app = express();
app.set("trust proxy", 1); // behind a TLS proxy, so the `secure` cookie is set
app.use(
session({
secret: SESSION_SECRET,
resave: false,
saveUninitialized: false,
// MemoryStore is for development only: in production use a store that survives
// restarts (Redis, Postgres). The tokens live there, never in the cookie.
cookie: {
httpOnly: true,
sameSite: "lax",
secure: APP_URL.startsWith("https://"),
maxAge: 36_000_000, // ceiling: 10 h, the IdP's ssoSessionMaxLifespan
},
}),
);

Each login gets a fresh PKCE verifier, state and nonce, kept in the session. Scope exactly openid profile email; any other scope is answered invalid_scope.

server.ts
app.get("/login", async (req, res) => {
const verifier = client.randomPKCECodeVerifier();
const state = client.randomState();
const nonce = client.randomNonce();
req.session.login = { state, nonce, verifier };
const url = client.buildAuthorizationUrl(config, {
redirect_uri: `${APP_URL}/callback`,
scope: "openid profile email", // exactly this; any other scope gets invalid_scope
code_challenge: await client.calculatePKCECodeChallenge(verifier),
code_challenge_method: "S256",
state,
nonce,
});
res.redirect(url.href);
});

authorizationCodeGrant checks state, PKCE, the iss response parameter (RFC 9207) and the ID token: signature, iss, aud, exp and nonce. Any failure throws and nobody is signed in. After success the session id is regenerated (no session fixation).

server.ts
app.get("/callback", async (req, res, next) => {
const login = req.session.login;
if (!login) return res.status(400).send("Login expired. Try again.");
delete req.session.login; // single-use state
let tokens: client.TokenEndpointResponse & client.TokenEndpointResponseHelpers;
try {
// Checks state, PKCE, the `iss` response parameter and the ID token (signature, iss,
// aud, exp, nonce). Any failure throws: never sign the person in.
tokens = await client.authorizationCodeGrant(config, new URL(req.originalUrl, APP_URL), {
pkceCodeVerifier: login.verifier,
expectedState: login.state,
expectedNonce: login.nonce,
});
} catch (err) {
return next(err);
}
const person = signIn(tokens.claims()!);
// A new session id after login (prevents session fixation).
req.session.regenerate((err) => {
if (err) return next(err);
req.session.person = person;
req.session.tokens = {
access: tokens.access_token,
refresh: tokens.refresh_token!,
idToken: tokens.id_token!,
expiresAt: Date.now() + (tokens.expiresIn() ?? 0) * 1000,
};
res.redirect("/");
});
});

Signing in creates or updates the person’s record, keyed by (iss, sub), and grants nothing. Zero permissions and invitations by verified email: Identity and access.

server.ts
// Your app's own records. In a real app these are tables (see "Identity and access");
// Maps keep the example self-contained.
const people = new Map<string, Person & { permissions: string[] }>(); // key: (iss, sub)
// Access granted before a person's first login, addressed to an email. The seed comes from
// APP_INVITES ("email=permission,…") only to make the example runnable.
const invitations = new Map<string, string[]>(
(process.env.APP_INVITES ?? "").split(",").filter(Boolean).map((pair) => {
const [email, permission] = pair.split("=");
return [email.toLowerCase(), [permission]];
}),
);
/** Called after every login. Authentication is not authorization: nothing is granted here. */
function signIn(claims: client.IDToken): Person {
const key = JSON.stringify([claims.iss, claims.sub]); // the person is (iss, sub), never the email
const person: Person = {
iss: claims.iss,
sub: claims.sub,
name: claims.name as string | undefined,
email: claims.email as string | undefined,
};
// A new person starts with zero permissions; a known one keeps theirs, with fresh attributes.
const record = { ...person, permissions: people.get(key)?.permissions ?? [] };
// An invitation binds to (iss, sub) once, and only to a verified email. After that the
// email is just an attribute: changing it later moves no access.
const email = person.email?.toLowerCase();
if (email && claims.email_verified === true && invitations.has(email)) {
record.permissions = [...new Set([...record.permissions, ...invitations.get(email)!])];
invitations.delete(email);
}
people.set(key, record);
return person;
}

The IdP decides whether a session is still valid. While the access token (300 s) is fresh, requests pass without calling the IdP. On expiry the server refreshes. A refused refresh (account blocked, session ended, idle or maximum lifetime reached) or an unreachable IdP destroys the local session and the request gets 401. A block takes effect within 5 minutes.

server.ts
// The derived session: the IdP decides whether it is still valid.
async function requireSession(req: express.Request, res: express.Response, next: express.NextFunction) {
const t = req.session.tokens;
if (!t) return res.status(401).json({ error: "login_required" });
if (Date.now() < t.expiresAt) return next();
try {
const fresh = await client.refreshTokenGrant(config, t.refresh);
req.session.tokens = {
access: fresh.access_token,
refresh: fresh.refresh_token ?? t.refresh,
idToken: fresh.id_token ?? t.idToken,
expiresAt: Date.now() + (fresh.expiresIn() ?? 0) * 1000,
};
next();
} catch {
// Refused (account blocked, session ended, idle or max lifetime) or the IdP is
// unreachable: fail closed. The person signs in again.
req.session.destroy(() => res.status(401).json({ error: "session_ended" }));
}
}
app.get("/api/me", requireSession, (req, res) => {
const { iss, sub } = req.session.person!;
const record = people.get(JSON.stringify([iss, sub]));
// A session that outlived the app's records (e.g. an in-memory store after a restart): sign in again.
if (!record) return req.session.destroy(() => res.status(401).json({ error: "login_required" }));
res.json({ iss, sub, name: record.name, email: record.email, permissions: record.permissions });
});

Destroy the local session, then redirect the browser to the IdP’s end_session_endpoint with id_token_hint, which ends the central session. See Session and logout.

server.ts
app.get("/logout", (req, res) => {
const idToken = req.session.tokens?.idToken;
req.session.destroy(() => {
if (!idToken) return res.redirect("/");
// Ends the central (SSO) session too; the URI must be in postLogoutRedirectUris.
const url = client.buildEndSessionUrl(config, {
id_token_hint: idToken,
post_logout_redirect_uri: `${APP_URL}/`,
});
res.redirect(url.href);
});
});

Next: with an API, validate the access token there; then Before production.