BFF Pattern (Express)
See the Applications overview for prerequisites, configuration endpoints, and available scopes.
The Backend-for-Frontend (BFF) pattern keeps all OAuth tokens on the server. The browser never sees access tokens or ID tokens – it authenticates via HttpOnly, SameSite=Strict session cookies instead. This eliminates token theft from XSS and removes the need for secure token storage in the browser.
PKCE is required. The server generates a code verifier and challenge for every authorization request, even though a client secret is also used.
openid-client (v6) provides a certified OpenID Connect client for Node.js.
Install the dependencies:
npm install express express-session openid-client
Server
Create the Express BFF server (app.js):
import express from "express";
import session from "express-session";
import * as client from "openid-client";
const issuer = process.env.VOUCH_ISSUER || "https://us.vouch.sh";
const clientId = process.env.VOUCH_CLIENT_ID;
const clientSecret = process.env.VOUCH_CLIENT_SECRET;
const callbackUrl =
process.env.VOUCH_REDIRECT_URI || "http://localhost:3000/auth/callback";
const config = await client.discovery(
new URL(issuer),
clientId,
clientSecret,
);
const app = express();
app.use(
session({
secret: process.env.SECRET_KEY || "dev-secret-change-in-production",
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
sameSite: "strict",
secure: process.env.NODE_ENV === "production",
},
}),
);
app.use(express.static("public"));
Login route
Generates a PKCE code verifier, builds the authorization URL, and redirects the user to Vouch:
app.get("/auth/login", async (req, res) => {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge =
await client.calculatePKCECodeChallenge(codeVerifier);
const state = client.randomState();
const nonce = client.randomNonce();
req.session.oidc = { codeVerifier, state, nonce };
const redirectTo = client.buildAuthorizationUrl(config, {
redirect_uri: callbackUrl,
scope: "openid email",
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
nonce,
});
res.redirect(redirectTo.href);
});
Callback route
Exchanges the authorization code for tokens and stores the claims in the session:
app.get("/auth/callback", async (req, res) => {
const { codeVerifier, state, nonce } = req.session.oidc || {};
delete req.session.oidc;
const currentUrl = new URL(req.url, `http://${req.headers.host}`);
const tokens = await client.authorizationCodeGrant(
config,
currentUrl,
{
pkceCodeVerifier: codeVerifier,
expectedState: state,
expectedNonce: nonce,
},
);
const claims = tokens.claims();
req.session.user = {
id: claims.sub,
email: claims.email,
hardwareVerified: claims.hardware_verified || false,
};
req.session.tokens = {
accessToken: tokens.access_token,
expiresAt: tokens.expires_in
? Date.now() + tokens.expires_in * 1000
: null,
};
res.redirect("/");
});
API endpoints
Expose the session data and proxy the UserInfo endpoint so the frontend never handles tokens directly:
app.get("/api/me", (req, res) => {
if (!req.session.user) {
return res.json({ authenticated: false });
}
res.json({ authenticated: true, user: req.session.user });
});
app.get("/api/userinfo", async (req, res) => {
if (!req.session.tokens?.accessToken) {
return res.status(401).json({ error: "Not authenticated" });
}
const response = await fetch(`${issuer}/oauth/userinfo`, {
headers: {
Authorization: `Bearer ${req.session.tokens.accessToken}`,
},
});
if (!response.ok) {
return res
.status(response.status)
.json({ error: `UserInfo request failed: ${response.status}` });
}
res.json(await response.json());
});
app.get("/auth/logout", (req, res) => {
req.session.destroy(() => res.redirect("/"));
});
app.listen(3000);
Frontend
The static frontend calls the BFF API endpoints – it never touches tokens (public/index.html):
<div id="app">Loading...</div>
<script>
const app = document.getElementById("app");
async function checkAuth() {
const res = await fetch("/api/me");
const data = await res.json();
app.textContent = "";
if (data.authenticated) {
const p = document.createElement("p");
p.textContent = "Signed in as " + data.user.email;
app.appendChild(p);
if (data.user.hardwareVerified) {
const hw = document.createElement("p");
hw.innerHTML = "<strong>Hardware Verified</strong>";
app.appendChild(hw);
}
const logoutLink = document.createElement("a");
logoutLink.href = "/auth/logout";
logoutLink.textContent = "Sign out";
app.appendChild(logoutLink);
} else {
const loginLink = document.createElement("a");
loginLink.href = "/auth/login";
loginLink.textContent = "Sign in with Vouch";
app.appendChild(loginLink);
}
}
checkAuth();
</script>
Rich Authorization Requests
To request structured permissions, add authorization_details when building the authorization URL:
const redirectTo = client.buildAuthorizationUrl(config, {
// ... other params
authorization_details: JSON.stringify([
{ type: "account_access", actions: ["read", "transfer"] },
]),
});
See the Rich Authorization Requests section for the full authorization_details format.