Skip to main content

Node.js (non-React)

For Express / Fastify / Koa / NestJS / Hono backends. Same pattern as the Python guide: proxy auth flows, validate JWTs locally.

Setup

npm install express axios jose cookie-parser

Express example

import express from 'express';
import axios from 'axios';
import cookieParser from 'cookie-parser';
import { createRemoteJWKSet, jwtVerify } from 'jose';

const AUTH_URL = process.env.AUTH_URL ?? 'http://auth:4000';
const app = express();
app.use(express.json());
app.use(cookieParser());

// ── proxy auth flows ────────────────────────────────────────
app.post('/auth/login', async (req, res) => {
const upstream = await axios.post(`${AUTH_URL}/v1/auth/login`, req.body, {
validateStatus: () => true,
});
// forward Set-Cookie headers
const setCookies = upstream.headers['set-cookie'];
if (setCookies) res.setHeader('Set-Cookie', setCookies);
res.status(upstream.status).json(upstream.data);
});

// ── validate JWT on protected routes ────────────────────────
const jwks = createRemoteJWKSet(new URL(`${AUTH_URL}/.well-known/jwks.json`));

async function requireAuth(req: any, res: any, next: any) {
const token =
req.cookies?.identsphere_at ??
req.headers.authorization?.replace(/^Bearer /, '');
if (!token) return res.status(401).json({ error: 'no token' });
try {
const { payload } = await jwtVerify(token, jwks);
req.user = payload;
next();
} catch (e) {
res.status(401).json({ error: 'invalid token' });
}
}

app.get('/api/me', requireAuth, (req: any, res) => {
res.json({ user_id: req.user.sub, org_id: req.user.org });
});

app.listen(3000);

Fastify

Same shape; Fastify's plugin model lets you wrap requireAuth as a fastify-plugin for cleaner composition.

import Fastify from 'fastify';

const app = Fastify();
app.decorate('authenticate', requireAuth);
app.get('/api/me', { preHandler: app.authenticate }, async (req: any) => ({
user_id: req.user.sub,
}));

NestJS

Wrap as a CanActivate guard:

@Injectable()
export class AuthGuard implements CanActivate {
async canActivate(ctx: ExecutionContext): Promise<boolean> {
const req = ctx.switchToHttp().getRequest();
const token = req.cookies?.identsphere_at;
if (!token) return false;
const { payload } = await jwtVerify(token, jwks);
req.user = payload;
return true;
}
}

Calling auth endpoints from the backend

To make calls on behalf of the user, forward the cookies:

async function fetchProfile(req: express.Request) {
return axios.get(`${AUTH_URL}/v1/users/me`, {
headers: { Cookie: req.headers.cookie ?? '' },
});
}

Caveats

Same revocation lag as Pattern 2 (~JWKS cache TTL + access token TTL). Shorten IDENTSPHERE_ACCESS_EXPIRY_SECS if you need tighter revocation.

Verify tokens in your backend

This is Path 2 — per-request authorization. Your backend validates the RS256 access token locally against the cached JWKS. No network hop per request.

The published helper @identsphere/verify wraps this. It fetches and caches the JWKS, pins RS256, and checks iss / exp (and aud when you set one) for you.

npm install @identsphere/verify
import { createTokenVerifier } from '@identsphere/verify';

const verify = createTokenVerifier({
jwksUri: `${process.env.IDENTSPHERE_URL}/.well-known/jwks.json`,
issuer: process.env.IDENTSPHERE_ISSUER ?? 'IdentSphere',
// Only set if the server runs with IDENTSPHERE_TOKEN_AUDIENCE:
audience: process.env.IDENTSPHERE_TOKEN_AUDIENCE,
// algorithms default to ['RS256']; never widen to include 'none'/'HS256'
});

const claims = await verify(token);
// claims.sub, claims.org, claims.email, claims.aal, claims.exp, claims.iss

≤5s session revocation (optional)

By default a verified token is honored until it expires, even if the session was revoked server-side meanwhile (bounded by the access TTL, default 15 min). To honor revocations within ~5s while staying stateless, set revocation: true — the verifier consults the revocation feed and rejects a token (reason: 'revoked') once its session is revoked:

const verify = createTokenVerifier({
issuer: process.env.IDENTSPHERE_ISSUER!,
revocation: true, // or { cacheMaxAgeMs: 5000, onError: 'allow' | 'deny' }
});

The feed is cached (5s) and fetched single-flight, so this adds one cheap, cacheable request per cache window — never a per-request hop. It soft-fails open by default (OCSP-style: a feed outage degrades revocation latency back to token-expiry rather than failing all auth); set onError: 'deny' to fail closed.

Mount the Express middleware via the subpath export:

import express from 'express';
import { requireAuth } from '@identsphere/verify/express';

const app = express();

app.use(
requireAuth({
jwksUri: `${process.env.IDENTSPHERE_URL}/.well-known/jwks.json`,
issuer: process.env.IDENTSPHERE_ISSUER ?? 'IdentSphere',
audience: process.env.IDENTSPHERE_TOKEN_AUDIENCE, // optional
}),
);

app.get('/api/me', (req: any, res) => {
// req.auth holds the verified claims
res.json({ user_id: req.auth.sub, org_id: req.auth.org, aal: req.auth.aal });
});

The underlying approach

@identsphere/verify is built on jose. If you prefer to wire it yourself, the verifier is createRemoteJWKSet + jwtVerify — pin algorithms to ['RS256'], pass issuer, and jwtVerify checks exp for you:

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
new URL(`${process.env.IDENTSPHERE_URL}/.well-known/jwks.json`),
);

async function verifyToken(token: string) {
const { payload } = await jwtVerify(token, JWKS, {
algorithms: ['RS256'], // never accept 'none' / HS256
issuer: process.env.IDENTSPHERE_ISSUER ?? 'IdentSphere',
audience: process.env.IDENTSPHERE_TOKEN_AUDIENCE, // omit if unset
});
return payload; // exp already verified; signature already verified
}

createRemoteJWKSet caches the key set and refreshes it on a kid miss, so this runs in-process with no per-request fetch.

Full Express starter

A complete example will live in examples/express-nodejs-starter/ in the repo (coming v1.1).