Node.js cookbook · Intermediate

Add JWT authentication to an Express API

Protect Express routes with bearer tokens and expose the authenticated user to handlers.

Approach

Verify tokens with a maintained library (jose) using a pinned algorithm, issuer and audience — ideally against your identity provider's JWKS. Put the verified claims on req (typed via declaration merging) and return 401 with WWW-Authenticate: Bearer on failure. Express 5 forwards rejected promises from async middleware to the error handler automatically.

Solution

import express, { type NextFunction, type Request, type Response } from "express";
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose";

declare global {
  namespace Express {
    interface Request { user?: { id: string; roles: string[] } }
  }
}

const JWKS = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));

export async function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization ?? "";
  if (!header.startsWith("Bearer ")) {
    return res.status(401).set("WWW-Authenticate", "Bearer").json({ title: "Missing token" });
  }
  try {
    const { payload } = await jwtVerify(header.slice(7), JWKS, {
      issuer: "https://auth.example.com/",
      audience: "orders-api",
      algorithms: ["RS256"],
    });
    req.user = { id: String(payload.sub), roles: (payload as JWTPayload & { roles?: string[] }).roles ?? [] };
    next();
  } catch {
    res.status(401).set("WWW-Authenticate", 'Bearer error="invalid_token"').json({ title: "Invalid token" });
  }
}

export const requireRole = (role: string) => (req: Request, res: Response, next: NextFunction) =>
  req.user?.roles.includes(role) ? next() : res.status(403).json({ title: "Forbidden" });

const app = express();
app.get("/api/orders", requireAuth, async (req, res) => {
  res.json(await orders.forUser(req.user!.id));
});
app.delete("/api/admin/users/:id", requireAuth, requireRole("admin"), async (req, res) => { /* ... */ });

Pitfalls

  • jwt.decode() instead of verify.
  • Not pinning algorithms/audience.
  • Long-lived tokens without refresh/revocation.

Variations

  • Passport.js strategies for many providers.
  • Fastify: @fastify/jwt with onRequest hooks.

Further reading: jose library

Reference: https://github.com/panva/jose

More Node.js recipes