v0.0.1 · system status
Open APIOpenAPI 3.1Zero token storage

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

Three settings that matter

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:

  1. ISSUER, AUDIENCES, and ALGORITHMS are constants imported from a single source of truth — never read from req, env without validation, or feature flags.
  2. 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.
  3. The middleware refuses tokens with no required claims — sub and scope shown 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_URL same-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 ^Bearer and rejects.
  • Tokens missing the segment count (a.b). Your library’s error code varies; don’t leak it to the client.
  • Tokens whose aud is an array containing your value plus a fork. Your policy passes; the request’s intent may not. Audit per route.

See also