Express sessions

@keenauth/node/bff adds sign-in to an Express 4 or 5 application. Your server is the OAuth client and keeps every token on the server. The browser gets only an encrypted, HttpOnly session cookie and never sees an access, refresh or ID token. This is the backend-for-frontend (BFF) pattern. The package does not import Express, so it adds no dependency. It is not published to npm yet.

To protect an API that receives bearer tokens instead, see Protecting your API. To call the management API, see the Node.js management SDK.

Before you start

  1. In the dashboard, create a web application in your environment.
  2. Choose client authentication: a client secret (client_secret_basic) or private_key_jwt with a key pair whose public key you register on the application. private_key_jwt is recommended, because no shared secret leaves your server.
  3. Add the redirect URI https://app.acme.example/auth/callback and the post-logout redirect URI https://app.acme.example/.
  4. Generate a cookie secret of at least 32 characters, for example with openssl rand -base64 32. Keep it in your secret store, not in the repository.

Set up

import express from "express";
import { createBff, expressRequireAuth, expressSession } from "@keenauth/node/bff";

const bff = createBff({
  issuer: "https://acme.eu.keenauth.app", // your environment's issuer, exactly
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  clientAuth: { method: "client_secret_basic", clientSecret: process.env.KEENAUTH_CLIENT_SECRET! },
  appBaseUrl: "https://app.acme.example",
  secret: process.env.KEENAUTH_COOKIE_SECRET!,
});

const app = express();
app.use(expressSession(bff));

app.get("/orders", expressRequireAuth(bff), (req, res) => {
  const { session } = req.keenauth!;
  res.send(`Signed in as ${session!.user.email}`);
});

With private_key_jwt, pass the private key instead of the secret. The SDK signs a new client assertion for every request to the token and PAR endpoints (aud is the issuer, jti is random, valid for 120 seconds, kid is the key's RFC 7638 thumbprint). The algorithm follows the key: RS256 for RSA (or alg: "PS256"), ES256 for P-256 and EdDSA for Ed25519.

import { readFileSync } from "node:fs";

const bff = createBff({
  issuer: "https://acme.eu.keenauth.app",
  clientId: "app_01j9zzzzzzzzzzzzzzzzzzzz",
  clientAuth: { method: "private_key_jwt", privateKey: readFileSync("bff-key.pem", "utf8") },
  appBaseUrl: "https://app.acme.example",
  secret: process.env.KEENAUTH_COOKIE_SECRET!,
});

If the key lives in a KMS or HSM, pass clientAuth: { method: "private_key_jwt", assertion } instead, where assertion() returns a new signed client assertion each time. Pass either privateKey or assertion, not both.

appBaseUrl is the public address of your app. The SDK builds the redirect URI and every redirect from it, never from the request's Host or X-Forwarded-* headers, so the trust proxy setting does not affect it. Plain http is accepted only for localhost.

The options are checked when createBff runs, so a configuration error stops the app at start-up.

Routes

expressSession serves three routes and reads the session on every other request:

Route What it does
GET /auth/login Starts sign-in. Optional query parameters: returnTo (where the user lands) and organization (sign in to an organization).
GET /auth/callback Finishes sign-in and sets the session cookie.
GET or POST /auth/logout Signs out at KeenAuth and returns to your app.

Sign-in uses the authorization code flow with PKCE (S256). The request is pushed to the server (PAR) when your environment offers it. On the callback the SDK checks state, iss and the ID token, including its nonce. Each successful sign-in creates a new session; the old one is discarded.

returnTo must be a path of your own app, such as /orders?tab=open. Any other value, such as //evil.example or a full URL, is ignored and the user lands on /.

Protect routes

expressRequireAuth(bff) lets a request through only with a session:

For POST, PUT, PATCH, DELETE and other unsafe methods, the browser must say the request comes from your app: Sec-Fetch-Site: same-origin, or an Origin header equal to your app's origin. Other requests get 403. This protects against cross-site request forgery in addition to SameSite=Lax.

Use the session and call your API

req.keenauth holds:

app.get("/api/orders", expressRequireAuth(bff), async (req, res) => {
  const upstream = await fetch("https://api.acme.example/v1/orders", {
    headers: { Authorization: `Bearer ${req.keenauth!.accessToken}` },
  });
  res.status(upstream.status).json(await upstream.json());
});

In TypeScript, declare the property once:

import type { SessionContext } from "@keenauth/node/bff";

declare module "express-serve-static-core" {
  interface Request {
    keenauth?: SessionContext | null;
  }
}

When the access token expires within 60 seconds, the SDK refreshes it before your handler runs. Each refresh rotates the refresh token and sets the new session cookie on the response. If the server refuses the refresh token, the session ends. A temporary server error keeps the session unchanged.

Do not send the access token to the browser.

Switch organization

bff.switchOrganizationPath("org_01j9…", "/dashboard") returns a link to /auth/login that signs the user in again in that organization. KeenAuth checks the membership. After sign-in, session.user.orgId holds the organization.

Sign out

Send a POST to /auth/logout from a form. The SDK:

  1. deletes the session cookie (and the stored session, if you use a store);
  2. revokes the refresh token;
  3. sends the browser to your environment's logout endpoint, which returns it to your app.

A request that the browser marks as coming from another site is refused. Browsers that do not send Sec-Fetch-Site can sign out only with a POST from your own pages.

Session storage

By default the whole session is in the cookie, encrypted and authenticated with AES-256-GCM using a key derived from secret. A changed or copied cookie is rejected. The cookie is named __Host-keenauth_session.0 and set with Secure, HttpOnly, SameSite=Lax and Path=/. On http://localhost the __Host- prefix and Secure are left out. Large sessions are split into up to three cookies.

To keep tokens on the server, pass a store:

import { createBff, type SessionStore } from "@keenauth/node/bff";

const store: SessionStore = {
  get: async (id) => JSON.parse((await redis.get(`sess:${id}`)) ?? "null"),
  set: async (id, session, ttl) => void (await redis.set(`sess:${id}`, JSON.stringify(session), { EX: ttl })),
  delete: async (id) => void (await redis.del(`sess:${id}`)),
};
const bff = createBff({ /* … */ store });

The cookie then holds only an encrypted random session ID. Every sign-in creates a new ID and deletes the previous record. The store holds tokens, so restrict access to it. MemorySessionStore is for development and tests only.

A session lasts at most 7 days (sessionMaxAge); refreshing does not extend it. To change the secret, pass a list: secret: [newSecret, oldSecret]. Remove the old one after sessionMaxAge.

Current limits