Express JWT middleware in 2026: why express-jwt still has footguns and what to wire instead
express-jwt's defaults still let alg=none through on some configurations. Here is the safe handcrafted middleware in 60 lines, and the one-line replacement that off-loads verification to a service.
express-jwt is fine until you look at how it’s configured in real
codebases. The library’s defaults don’t cause bugs; the surrounding code
does. Below is the minimum middleware you can ship today — handwritten,
audited — and the one-line replacement that takes verification out of
your runtime entirely.
TL;DR
Pin algorithms. Pin audience. Pin
issuer. If any of those reads from the request, your
middleware is broken regardless of which library it uses.
Why express-jwt still bites
express-jwt v8 requires algorithms to be passed explicitly. Good. But
several patterns in the wild walk back the safety:
// Pattern A — algorithms read from env, default to 'HS256,RS256'
app.use(jwt({
secret: process.env.JWT_SECRET,
algorithms: (process.env.JWT_ALGS || 'HS256,RS256').split(','),
}));
The mixed list re-opens the HS/RS key-confusion class (CVE-2016-10555). When your secret is your RSA public key in PEM form, an attacker signs with HMAC using that PEM and you accept the forgery.
// Pattern B — audience read from request
app.use(jwt({
secret: jwksRsa.expressJwtSecret({ ... }),
audience: req => req.hostname,
issuer: req => req.hostname,
algorithms: ['RS256'],
}));
Now an attacker who can influence the Host header (proxy mis-config,
rebinding) controls which audience and issuer pass. Verifiers must not
read identity claims from the request.
// Pattern C — JWKS without rate-limit on rotation
app.use(jwt({
secret: jwksRsa.expressJwtSecret({ jwksUri }),
algorithms: ['RS256'],
}));
No rateLimit, no cache TTL → at every cold path, a fresh JWKS fetch.
Auth0 tightens its rate limit; your verifier starts 503-ing during rotation.
A safe handwritten middleware
Sixty lines, no surprises. Verifies signature against a cached JWKS,
enforces a fixed allowlist, refuses tokens that drift on iss or aud.
Drop into your codebase, audit it, run it.
// auth.js
import { createRemoteJWKSet, jwtVerify } from 'jose';
const ISSUER = 'https://acme.auth0.com/'; // exact, with trailing slash
const AUDIENCES = ['https://api.acme.com/']; // your API identifier(s)
const ALGORITHMS = ['RS256']; // single algorithm
const CLOCK_SKEW_SECONDS = 60;
const JWKS = createRemoteJWKSet(
new URL(ISSUER + '.well-known/jwks.json'),
{ cooldownDuration: 30_000, cacheMaxAge: 6 * 60 * 60 * 1000 },
);
export async function requireAuth(req, res, next) {
const auth = req.get('authorization') || '';
const m = auth.match(/^Bearer (.+)$/i);
if (!m) return res.status(401).json({ error: 'missing_bearer' });
try {
const { payload, protectedHeader } = await jwtVerify(m[1], JWKS, {
issuer: ISSUER,
audience: AUDIENCES,
algorithms: ALGORITHMS,
clockTolerance: CLOCK_SKEW_SECONDS,
});
// Required claims — extend per route as needed.
if (!payload.sub) return res.status(401).json({ error: 'missing_sub' });
if (!payload.scope) return res.status(401).json({ error: 'missing_scope' });
req.auth = { payload, header: protectedHeader };
next();
} catch (err) {
return res.status(401).json({ error: 'invalid_token', code: err.code });
}
}
Use it in routes:
import express from 'express';
import { requireAuth } from './auth.js';
const app = express();
app.get('/me', requireAuth, (req, res) => {
res.json({ sub: req.auth.payload.sub });
});
// Per-route scope guard composes with the middleware:
function requireScope(name) {
return (req, res, next) => {
const scopes = String(req.auth.payload.scope || '').split(' ');
if (!scopes.includes(name)) {
return res.status(403).json({ error: 'missing_scope', need: name });
}
next();
};
}
app.post('/billing', requireAuth, requireScope('write:billing'), (req, res) => {
res.json({ ok: true });
});
Three things to check before merging this:
ISSUER,AUDIENCES, andALGORITHMSare constants imported from a single source of truth — never read fromreq, env without validation, or feature flags.- The JWKS cache has both a max-age and a cooldown. Without a cooldown you stampede on rotation; without max-age you stale-cache forever.
- The middleware refuses tokens with no required claims —
subandscopeshown above; extend per your IdP’s custom claims.
When to off-load verification entirely
Self-hosting verification is fine when your team has the time to keep up
with disclosures, JWKS rotation incidents, and the slow CVE drip. When you
don’t, /v1/validate/jwt is the same logic at one HTTP call:
// auth-jwtshield.js
const URL = process.env.JWTSHIELD_URL; // e.g. https://api.jwtshield.com
const PROFILE = process.env.JWTSHIELD_PROFILE; // e.g. acme-auth0
export async function requireAuth(req, res, next) {
const auth = req.get('authorization') || '';
const m = auth.match(/^Bearer (.+)$/i);
if (!m) return res.status(401).json({ error: 'missing_bearer' });
const r = await fetch(`${URL}/v1/validate/jwt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: m[1], issuer_profile_id: PROFILE }),
});
if (!r.ok) return res.status(502).json({ error: 'verifier_unreachable' });
const body = await r.json();
if (!body.valid) {
return res.status(401).json({
error: 'invalid_token',
findings: body.findings,
});
}
req.auth = { payload: body.metadata.payload }; // populated when audit middleware runs
next();
}
Two notes:
- Keep
JWTSHIELD_URLsame-origin in production to avoid a CORS preflight on every request, or terminate at your gateway. - The verifier is on the request path now. Cache the verifier’s response
for the token’s
jti(or its hash) inside your own process for the remaining lifetime of the token if latency budget matters. JWTShield’s own verifier ships in the 12ms p50 range; the network hop dominates.
Edge cases worth a regression test
- Tokens with
Bearer Bearer eyJ...(the prefix doubled by an upstream proxy). Your regex anchors with^Bearerand rejects. - Tokens missing the segment count (
a.b). Your library’s error code varies; don’t leak it to the client. - Tokens whose
audis an array containing your value plus a fork. Your policy passes; the request’s intent may not. Audit per route.
See also
- API — Validate JWT —
VerifyPolicyschema - API — Inspect token — when you want decode-only
- Errors —
ALGORITHM_INVALID - Errors —
AUDIENCE_MISMATCH - Blog — Validating Auth0 JWTs in production