Skip to main content

@web-ts-toolkit/express-oidc-vault

Cookie-free OIDC session middleware for Express with server-side storage of upstream refresh tokens and logout-capable id_tokens.

What It Handles

  • OIDC login redirect with PKCE, state, and nonce
  • callback token exchange and id_token validation
  • server-side storage of upstream refresh tokens and id_tokens
  • one-time local exchange codes for the frontend callback handoff
  • session refresh with session ID rotation
  • upstream logout URL generation using stored id_token
  • OIDC backchannel logout handling via logout_token

Installation

npm install @web-ts-toolkit/express-oidc-vault express

For local development and tests, also install the memory store:

npm install @web-ts-toolkit/express-oidc-vault-memory-store

What It Exposes

Main exports:

  • createOidcVaultMiddleware(...)
  • createOidcVaultAccessTokenMiddleware(...)
  • createOidcVaultJwtAccessTokenValidator(...)
  • route-path and default-value constants such as DEFAULT_OIDC_VAULT_BASE_PATH and OIDC_VAULT_ROUTE_PATHS
  • public types for sessions, hooks, token issuing, validators, config, and store-provider interfaces

Frontend Storage Policy

Default browser-side transport:

  • mirror sessionId into sessionStorage
  • keep accessToken in memory only
  • do not store either value in localStorage

Why:

  • sessionId needs to survive page refresh so the frontend can call POST /auth/oidc/refresh during app bootstrap
  • accessToken is the normal API credential and should remain non-persistent in the browser
  • sessionStorage narrows persistence compared with localStorage, but it is still readable by JavaScript, so XSS prevention remains critical

Optional alternative:

  • set sessionTransport: 'cookie'
  • store sessionId in an HttpOnly browser cookie instead of sessionStorage
  • keep accessToken in memory only

This mode simplifies the frontend and keeps the session pointer out of JavaScript-visible storage, but it reintroduces cookie deployment concerns such as SameSite, Secure, and cross-origin credential handling.

Session Transport Modes

sessionTransport: 'body'

This is the default mode.

  • exchange and refresh responses include sessionId
  • the frontend stores sessionId, typically in sessionStorage
  • the frontend sends sessionId back in the JSON body for refresh and logout

This mode stores sessionId in a backend-managed cookie.

  • exchange sets the session cookie and does not need to return sessionId in the JSON body
  • refresh reads the cookie, rotates the session, and updates the cookie
  • logout reads the cookie and clears it
  • the frontend does not need to keep sessionId in sessionStorage

Backchannel logout is separate from both transport modes because it is a server-to-server request from the IdP and does not rely on browser storage at all.

Available cookie options:

  • cookie.name
  • cookie.deploymentMode: 'same-origin' | 'same-site' | 'cross-site'
  • cookie.sameSite: 'lax' | 'strict' | 'none'
  • cookie.secure
  • cookie.domain
  • cookie.path
  • cookie.httpOnly

Endpoints

The middleware exposes these routes under a configurable base path such as /auth/oidc:

  • GET /auth/oidc/login
  • GET /auth/oidc/callback
  • POST /auth/oidc/exchange
  • POST /auth/oidc/refresh
  • POST /auth/oidc/logout
  • POST /auth/oidc/backchannel-logout

Quick Start

import express from 'express';
import { createOidcVaultMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';

const app = express();

app.use(
createOidcVaultMiddleware({
basePath: '/auth/oidc',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createMemoryOidcVaultStore(),
}),
);

Use the memory store for local development and tests. For production deployments, use the Redis or MongoDB store package.

Frontend Integration Example

The intended frontend model is:

  • accessToken stays in memory
  • sessionId is mirrored into sessionStorage
  • refresh calls are deduplicated so concurrent 401 responses do not race session rotation
type AuthState = {
accessToken: string | null;
sessionId: string | null;
};

const authState: AuthState = {
accessToken: null,
sessionId: sessionStorage.getItem('sessionId'),
};

let refreshPromise: Promise<void> | null = null;

function persistSessionId(sessionId: string | null): void {
authState.sessionId = sessionId;

if (sessionId) {
sessionStorage.setItem('sessionId', sessionId);
} else {
sessionStorage.removeItem('sessionId');
}
}

function setAuthState(payload: { accessToken?: string; sessionId: string }): void {
authState.accessToken = payload.accessToken ?? null;
persistSessionId(payload.sessionId);
}

function clearAuthState(): void {
authState.accessToken = null;
persistSessionId(null);
}

async function refreshAuthState(): Promise<void> {
if (!authState.sessionId) {
clearAuthState();
return;
}

const response = await fetch('/auth/oidc/refresh', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ sessionId: authState.sessionId }),
});

if (!response.ok) {
clearAuthState();
throw new Error('OIDC refresh failed.');
}

setAuthState(await response.json());
}

async function ensureFreshAccessToken(): Promise<void> {
if (!refreshPromise) {
refreshPromise = refreshAuthState().finally(() => {
refreshPromise = null;
});
}

await refreshPromise;
}

When sessionTransport is 'cookie', the frontend no longer needs to store sessionId.

type AuthState = {
accessToken: string | null;
};

const authState: AuthState = {
accessToken: null,
};

let refreshPromise: Promise<void> | null = null;

function setAuthState(payload: { accessToken?: string }): void {
authState.accessToken = payload.accessToken ?? null;
}

function clearAuthState(): void {
authState.accessToken = null;
}

async function refreshAuthState(): Promise<void> {
const response = await fetch('/auth/oidc/refresh', {
method: 'POST',
credentials: 'include',
});

if (!response.ok) {
clearAuthState();
throw new Error('OIDC refresh failed.');
}

setAuthState(await response.json());
}

async function ensureFreshAccessToken(): Promise<void> {
if (!refreshPromise) {
refreshPromise = refreshAuthState().finally(() => {
refreshPromise = null;
});
}

await refreshPromise;
}

For cross-origin cookie deployments, also remember:

  • the frontend requests must use credentials: 'include'
  • the backend CORS policy must allow credentials
  • the cookie typically needs SameSite=None and Secure

Backchannel Logout

The package supports OIDC backchannel logout at:

  • POST /auth/oidc/backchannel-logout

Expected request shape:

  • application/x-www-form-urlencoded
  • field: logout_token=<provider-signed-jwt>

The middleware validates the logout_token against the provider JWKS and then revokes matching local sessions by:

  • upstream sid when present
  • otherwise sub

Example request:

await fetch('/auth/oidc/backchannel-logout', {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
logout_token: '<provider-signed-logout-token>',
}),
});

Example response:

{
"loggedOut": true,
"revokedSessions": 1
}

Notes:

  • this route is intended for the IdP to call directly, not the browser
  • cookie transport does not change how backchannel logout works
  • after a successful backchannel logout, the next browser refresh will fail because the local session is gone; in cookie mode the package clears the stale session cookie on that failed refresh

Backend Wiring

Memory Store

import { createMemoryOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-memory-store';

createOidcVaultMiddleware({
basePath: '/auth/oidc',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createMemoryOidcVaultStore(),
});

Redis Store

import { createClient } from 'redis';
import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

createOidcVaultMiddleware({
basePath: '/auth/oidc',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createRedisOidcVaultStore({
client: redis,
keyPrefix: 'oidc-vault',
}),
});

MongoDB Store

import { MongoClient } from 'mongodb';
import { createMongoOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-mongodb-store';

const mongo = new MongoClient(process.env.MONGODB_URI!);
await mongo.connect();

createOidcVaultMiddleware({
basePath: '/auth/oidc',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
storeProvider: createMongoOidcVaultStore({
db: mongo.db('app-auth'),
}),
});
import { createClient } from 'redis';
import { createRedisOidcVaultStore } from '@web-ts-toolkit/express-oidc-vault-redis-store';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

createOidcVaultMiddleware({
basePath: '/auth/oidc',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
postLogoutRedirectUri: 'https://frontend.example.com/logged-out',
sessionTransport: 'cookie',
cookie: {
deploymentMode: 'same-site',
domain: '.example.com',
secure: true,
},
storeProvider: createRedisOidcVaultStore({
client: redis,
keyPrefix: 'oidc-vault',
}),
});

Config Modes

The package supports issuer discovery and manual endpoint configuration.

Issuer mode

If OIDC_ISSUER is set, discovery mode wins and these endpoint-specific variables are ignored:

  • OIDC_AUTHORIZATION_ENDPOINT
  • OIDC_TOKEN_ENDPOINT
  • OIDC_USERINFO_ENDPOINT
  • OIDC_JWKS_URI
  • OIDC_END_SESSION_ENDPOINT

OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, and OIDC_SCOPES still apply.

Manual mode

If OIDC_ISSUER is not set, configure the endpoints directly.

createOidcVaultMiddleware({
basePath: '/auth/oidc',
config: {
authorizationEndpoint: process.env.OIDC_AUTHORIZATION_ENDPOINT,
tokenEndpoint: process.env.OIDC_TOKEN_ENDPOINT,
userInfoEndpoint: process.env.OIDC_USERINFO_ENDPOINT,
jwksUri: process.env.OIDC_JWKS_URI,
endSessionEndpoint: process.env.OIDC_END_SESSION_ENDPOINT,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
scopes: process.env.OIDC_SCOPES,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
storeProvider: createMemoryOidcVaultStore(),
});

Minimum required manual config:

  • authorizationEndpoint
  • tokenEndpoint
  • jwksUri
  • clientId

Local Access Token Example

Provide tokenIssuer if you want exchange and refresh to return an app-issued local access token.

import { SignJWT } from 'jose';

const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');

createOidcVaultMiddleware({
basePath: '/auth/oidc',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
storeProvider: createMemoryOidcVaultStore(),
tokenIssuer: {
async issue({ session }) {
const accessToken = await new SignJWT({
sub: session.subject,
sid: session.sessionId,
scope: session.scope,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('15m')
.sign(jwtSecret);

return {
accessToken,
expiresIn: 900,
tokenType: 'Bearer',
};
},
},
});

That local access token is separate from the upstream IdP token. The upstream refresh token stays only in the server-side vault.

Access Token Validation Middleware

Use a separate middleware for validating the app-issued local access token on normal API routes.

import express from 'express';
import { createOidcVaultAccessTokenMiddleware } from '@web-ts-toolkit/express-oidc-vault';
import { jwtVerify } from 'jose';

const app = express();
const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');

app.get(
'/api/me',
createOidcVaultAccessTokenMiddleware({
validator: {
async validate(token) {
const result = await jwtVerify(token, jwtSecret, {
algorithms: ['HS256'],
});

return {
subject: String(result.payload.sub),
sessionId: typeof result.payload.sid === 'string' ? result.payload.sid : undefined,
scope: typeof result.payload.scope === 'string' ? result.payload.scope : undefined,
claims: result.payload as Record<string, unknown>,
};
},
},
}),
(req, res) => {
res.json({
subject: req.auth?.subject,
sessionId: req.auth?.sessionId,
scope: req.auth?.scope,
});
},
);

This middleware:

  • reads Authorization: Bearer ...
  • delegates token validation to your validator
  • attaches req.auth
  • rejects missing, malformed, invalid, or expired tokens with 401

The package augments Express request typing so req.auth is available without casting in TypeScript route handlers.

JWT validator helper

If your local access token is a JWT, you can use a built-in helper instead of writing the same jwtVerify(...) adapter manually.

import {
createOidcVaultAccessTokenMiddleware,
createOidcVaultJwtAccessTokenValidator,
} from '@web-ts-toolkit/express-oidc-vault';

const jwtSecret = new TextEncoder().encode(process.env.APP_JWT_SECRET ?? 'dev-secret-change-me');

app.get(
'/api/me',
createOidcVaultAccessTokenMiddleware({
validator: createOidcVaultJwtAccessTokenValidator({
key: jwtSecret,
issuer: 'https://api.example.com',
audience: 'api-audience',
algorithms: ['HS256'],
}),
}),
(req, res) => {
res.json({
subject: req.auth?.subject,
sessionId: req.auth?.sessionId,
scope: req.auth?.scope,
});
},
);

Default JWT claim mapping:

  • sub -> auth.subject
  • sid -> auth.sessionId
  • scope -> auth.scope
  • full verified payload -> auth.claims

Hook Examples

Hooks let the app observe or extend the OIDC flow without forking the middleware.

createOidcVaultMiddleware({
basePath: '/auth/oidc',
config: {
issuer: process.env.OIDC_ISSUER,
clientId: process.env.OIDC_CLIENT_ID,
clientSecret: process.env.OIDC_CLIENT_SECRET,
},
frontendRedirectUri: 'https://frontend.example.com/callback',
storeProvider: createMemoryOidcVaultStore(),
hooks: {
async onLoginStart({ req }) {
console.log('OIDC login started', {
ip: req.ip,
userAgent: req.get('user-agent'),
});
},
async onSessionCreated({ session }) {
if (!session?.user) {
return;
}

await upsertLocalUser({
oidcSubject: session.subject,
email: typeof session.user.email === 'string' ? session.user.email : undefined,
displayName: typeof session.user.name === 'string' ? session.user.name : undefined,
});
},
async onSessionRefreshed({ session, metadata }) {
console.log('OIDC session rotated', {
previousSessionId: metadata?.previousSessionId,
nextSessionId: session?.sessionId,
});
},
async onLogout({ session, metadata }) {
console.log('OIDC logout completed', {
subject: session?.subject,
upstreamLogoutUrl: metadata?.upstreamLogoutUrl,
});
},
async onError({ error, route, req }) {
console.error('OIDC vault error', {
route,
path: req.originalUrl,
error,
});
},
},
});

async function upsertLocalUser(input: { oidcSubject: string; email?: string; displayName?: string }): Promise<void> {
console.log('upsertLocalUser', input);
}

Security Checklist

  • keep sessionId in sessionStorage and keep accessToken in memory only
  • never store the upstream refresh token in the browser
  • use HTTPS end-to-end for frontend, backend, and IdP communication
  • treat XSS prevention as critical because sessionStorage is still readable by JavaScript
  • enable a strict Content Security Policy and avoid unsafe inline scripts
  • rotate sessionId on refresh and overwrite the mirrored sessionStorage value immediately
  • clear in-memory auth state and sessionStorage on logout, even if upstream logout fails
  • set postLogoutRedirectUri explicitly so logout destinations stay predictable
  • keep any local app-issued access token short-lived, such as 5 to 15 minutes
  • use Redis or MongoDB, not the memory store, for production or multi-instance deployments

Store Packages