@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, andnonce - callback token exchange and
id_tokenvalidation - 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
- Yarn
- pnpm
- Bun
npm install @web-ts-toolkit/express-oidc-vault express
yarn add @web-ts-toolkit/express-oidc-vault express
pnpm add @web-ts-toolkit/express-oidc-vault express
bun add @web-ts-toolkit/express-oidc-vault express
For local development and tests, also install the memory store:
- npm
- Yarn
- pnpm
- Bun
npm install @web-ts-toolkit/express-oidc-vault-memory-store
yarn add @web-ts-toolkit/express-oidc-vault-memory-store
pnpm add @web-ts-toolkit/express-oidc-vault-memory-store
bun add @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_PATHandOIDC_VAULT_ROUTE_PATHS - public types for sessions, hooks, token issuing, validators, config, and store-provider interfaces
Frontend Storage Policy
Default browser-side transport:
- mirror
sessionIdintosessionStorage - keep
accessTokenin memory only - do not store either value in
localStorage
Why:
sessionIdneeds to survive page refresh so the frontend can callPOST /auth/oidc/refreshduring app bootstrapaccessTokenis the normal API credential and should remain non-persistent in the browsersessionStoragenarrows persistence compared withlocalStorage, but it is still readable by JavaScript, so XSS prevention remains critical
Optional alternative:
- set
sessionTransport: 'cookie' - store
sessionIdin anHttpOnlybrowser cookie instead ofsessionStorage - keep
accessTokenin 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.
exchangeandrefreshresponses includesessionId- the frontend stores
sessionId, typically insessionStorage - the frontend sends
sessionIdback in the JSON body forrefreshandlogout
sessionTransport: 'cookie'
This mode stores sessionId in a backend-managed cookie.
exchangesets the session cookie and does not need to returnsessionIdin the JSON bodyrefreshreads the cookie, rotates the session, and updates the cookielogoutreads the cookie and clears it- the frontend does not need to keep
sessionIdinsessionStorage
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.namecookie.deploymentMode:'same-origin' | 'same-site' | 'cross-site'cookie.sameSite:'lax' | 'strict' | 'none'cookie.securecookie.domaincookie.pathcookie.httpOnly
Endpoints
The middleware exposes these routes under a configurable base path such as /auth/oidc:
GET /auth/oidc/loginGET /auth/oidc/callbackPOST /auth/oidc/exchangePOST /auth/oidc/refreshPOST /auth/oidc/logoutPOST /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:
accessTokenstays in memorysessionIdis mirrored intosessionStorage- refresh calls are deduplicated so concurrent
401responses 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;
}
Cookie transport frontend example
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=NoneandSecure
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
sidwhen 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'),
}),
});
Cookie Transport
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_ENDPOINTOIDC_TOKEN_ENDPOINTOIDC_USERINFO_ENDPOINTOIDC_JWKS_URIOIDC_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:
authorizationEndpointtokenEndpointjwksUriclientId
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.subjectsid->auth.sessionIdscope->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
sessionIdinsessionStorageand keepaccessTokenin 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
sessionStorageis still readable by JavaScript - enable a strict Content Security Policy and avoid unsafe inline scripts
- rotate
sessionIdon refresh and overwrite the mirroredsessionStoragevalue immediately - clear in-memory auth state and
sessionStorageon logout, even if upstream logout fails - set
postLogoutRedirectUriexplicitly 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
@web-ts-toolkit/express-oidc-vault-memory-store@web-ts-toolkit/express-oidc-vault-redis-store@web-ts-toolkit/express-oidc-vault-mongodb-store