By AndyPublished

JWT Authentication in Node.js and Express: Middleware, jose and JWKS

JWT authentication in Node.js comes down to two functions: sign a token when the user logs in, and verify it in an Express middleware on every protected request. Use jsonwebtoken orjose, and always pass an explicit algorithms list plus the expected issuer and audience to the verify call. Keep access tokens short-lived (5 to 15 minutes), load the secret from the environment, and return 401 for any failure. All code below was run against Node.js 22.22, Express 5.2.1, jsonwebtoken 9.0.3, jose 6.2.12, express-jwt 8.5.1 and jwks-rsa 4.1.0.

Best JWT Library for Node.js

There is no single right answer, but the choice is narrower than npm search results suggest. Two libraries do the cryptography; the rest are wrappers around them.

LibraryBest forNotes
jsonwebtoken 9.xExpress APIs that sign and verify their own HS256/RS256 tokensSynchronous API, CommonJS-friendly, Node.js only. No JWKS support; pair with jwks-rsa for that.
jose 6.xNew code, JWKS/OIDC, Edge and serverless runtimesPromise-based, zero dependencies, runs on Node.js, Deno, Bun, browsers and edge runtimes. Built-in createRemoteJWKSet. Also handles JWE.
express-jwt 8.xDrop-in Express middlewareWraps jsonwebtoken. Puts claims on req.auth. Use with jwks-rsa for provider tokens.
passport-jwtApps already built around Passport strategiesAdds a layer of indirection; only worth it if Passport is already in the stack.

For a self-contained Express API that issues its own tokens, jsonwebtoken is fine and has the most tutorials behind it. For anything that consumes tokens from an identity provider, or code that may later run on an edge runtime, start with jose. Whichever you pick, install a current major: jsonwebtoken below 9.0.0 has known security advisories (covered below).

jwt.sign Options and expiresIn

Install the packages and generate a signing secret of at least 256 bits. Never hard-code it; a secret in the repository is a secret on every laptop and CI runner that has cloned it.

npm install express jsonwebtoken
export JWT_SECRET="$(openssl rand -base64 48)"

jwt.sign(payload, secret, options) sets registered claims from options rather than from the payload. The options you should always set are algorithm,expiresIn, issuer, audience andsubject. iat is added automatically. The claims themselves are described in JWT claims explained.

⚠
expiresIn takes a number of seconds or avercel/ms string such as '15m' or '1h'. A numeric string without a unit is read as milliseconds: expiresIn: '120'produces a token that expires 0.12 seconds after issue. Also, setting exp in the payload and expiresIn in options throws Bad "options.expiresIn" option the payload already has an "exp" property.

JWT Express Middleware with jsonwebtoken

Here is a complete, runnable server: a login route that issues a 15-minute access token and arequireAuth middleware that protects /me. It uses ES modules ("type": "module" in package.json).

// server.js
import express from 'express';
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET || JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET must be set to at least 32 random characters');
}

const ISSUER = 'https://api.example.com';
const AUDIENCE = 'https://api.example.com';

function issueAccessToken(user) {
  return jwt.sign({ role: user.role }, JWT_SECRET, {
    algorithm: 'HS256',
    expiresIn: '15m',
    issuer: ISSUER,
    audience: AUDIENCE,
    subject: String(user.id),
  });
}

function requireAuth(req, res, next) {
  const [scheme, token] = (req.get('authorization') ?? '').split(' ');
  if (scheme !== 'Bearer' || !token) {
    return res.status(401).set('WWW-Authenticate', 'Bearer').json({ error: 'missing_token' });
  }
  try {
    req.auth = jwt.verify(token, JWT_SECRET, {
      algorithms: ['HS256'],      // never let the token choose
      issuer: ISSUER,
      audience: AUDIENCE,
      clockTolerance: 30,         // seconds of clock skew
    });
    return next();
  } catch (err) {
    const error = err.name === 'TokenExpiredError' ? 'token_expired' : 'invalid_token';
    return res.status(401)
      .set('WWW-Authenticate', 'Bearer error="invalid_token"')
      .json({ error });
  }
}

const app = express();
app.use(express.json());

// Replace with a real user lookup and an argon2 or bcrypt password check.
async function checkCredentials(email, password) {
  return email === 'ada@example.com' && password === 'correct horse'
    ? { id: 42, role: 'admin' }
    : null;
}

app.post('/login', async (req, res) => {
  const { email, password } = req.body ?? {};
  const user = await checkCredentials(email, password);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });
  res.json({ access_token: issueAccessToken(user), token_type: 'Bearer', expires_in: 900 });
});

app.get('/me', requireAuth, (req, res) => {
  res.json({ userId: req.auth.sub, role: req.auth.role });
});

app.listen(3000);
TOKEN=$(curl -s localhost:3000/login -H 'content-type: application/json' \
  -d '{"email":"ada@example.com","password":"correct horse"}' | jq -r .access_token)
curl -s localhost:3000/me -H "Authorization: Bearer $TOKEN"
# {"userId":"42","role":"admin"}

In testing, a missing header, a tampered signature, a wrong aud, an expired token and analg: none token all returned 401. The error names and messages you will see fromjwt.verify are: TokenExpiredError: jwt expired,JsonWebTokenError: invalid signature, JsonWebTokenError: jwt malformed,jwt audience invalid. expected: ... and jwt issuer invalid. expected: .... Log the specific error server-side, but keep the client response generic. If one of those messages is unfamiliar, the error lookup table maps each to its cause.

Checking roles after authentication

Keep authentication and authorisation as separate middleware. requireAuth answers "who is this?" and returns 401 when it cannot tell; a second middleware answers "may they do this?" and returns 403. Mixing the two makes clients retry a login that will never help.

const requireRole = (role) => (req, res, next) =>
  req.auth?.role === role ? next() : res.status(403).json({ error: 'forbidden' });

app.delete('/admin/cache', requireAuth, requireRole('admin'), (req, res) => {
  res.json({ clearedBy: req.auth.sub });
});

A role claim is only as fresh as the token. If you demote a user, their existing access token still saysadmin until it expires, which is one more reason to keep lifetimes short. How roles and OAuth scopes differ is covered in JWT scopes vs roles.

JWT Express Middleware in TypeScript

In TypeScript, augment Express's Request interface once so req.authis typed everywhere. Install @types/express and @types/jsonwebtoken.jwt.verify is typed as returning string | JwtPayload, so narrow it before use. This compiled under strict with TypeScript 7.0 and@types/express 5.0.6.

// auth.ts
import type { Request, Response, NextFunction } from 'express';
import jwt, { type JwtPayload } from 'jsonwebtoken';

declare global {
  namespace Express {
    interface Request {
      auth?: JwtPayload & { role?: string };
    }
  }
}

const JWT_SECRET = process.env.JWT_SECRET!;

export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const token = req.get('authorization')?.match(/^Bearer (.+)$/)?.[1];
  if (!token) {
    res.status(401).json({ error: 'missing_token' });
    return;
  }
  try {
    const payload = jwt.verify(token, JWT_SECRET, {
      algorithms: ['HS256'],
      issuer: 'https://api.example.com',
      audience: 'https://api.example.com',
    });
    if (typeof payload === 'string') throw new Error('unexpected string payload');
    req.auth = payload;
    next();
  } catch {
    res.status(401).json({ error: 'invalid_token' });
  }
}

jwt.verify vs jwt.decode

jwt.decode(token) base64url-decodes the payload and returns it. It does not check the signature, the expiry or anything else, so anyone can hand you a token with "role":"admin"and decode will return it faithfully. jwt.verify(token, key, options)checks the signature against your key and the algorithm list, then validates exp,nbf, iss and aud. The rule is simple: authorisation decisions only ever use the output of verify.

decode has legitimate uses: reading the kid header to pick a key, logging, or showing a UI when a token expires. jose has the same split withdecodeJwt and decodeProtectedHeader. The distinction is covered in depth in JWT decoder vs validator. When debugging, you can paste a token into the jwtdecode.app decoder, which decodes and optionally verifies it locally in the browser.

The jose Alternative

jose uses Web Crypto-style keys and a builder for signing. HMAC secrets are passed asUint8Array. jose 6 is published as an ES module; on Node.js 22,require('jose') also works because Node.js can load ES modules synchronously.

import { SignJWT, jwtVerify, errors } from 'jose';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

const token = await new SignJWT({ role: 'admin' })
  .setProtectedHeader({ alg: 'HS256', typ: 'JWT' })
  .setSubject('42')
  .setIssuer('https://api.example.com')
  .setAudience('https://api.example.com')
  .setIssuedAt()
  .setExpirationTime('15m')
  .sign(secret);

try {
  const { payload } = await jwtVerify(token, secret, {
    algorithms: ['HS256'],
    issuer: 'https://api.example.com',
    audience: 'https://api.example.com',
  });
  console.log(payload.sub, payload.role);
} catch (err) {
  // err.code e.g. ERR_JWT_EXPIRED, ERR_JWS_SIGNATURE_VERIFICATION_FAILED,
  // ERR_JWT_CLAIM_VALIDATION_FAILED
  if (err instanceof errors.JWTExpired) { /* 401 token_expired */ }
}

Verifying RS256 Tokens from an Identity Provider (JWKS)

If Auth0, Okta, Entra ID, Cognito, Keycloak or any other OpenID Connect provider issues the tokens, your API never sees a secret. It fetches the provider's public keys from a JWKS endpoint and picks the one matching the token'skid header. See what is JWKS and HS256 vs RS256 for the background. With jose,createRemoteJWKSet handles fetching, caching and re-fetching when an unknownkid appears after key rotation:

import { jwtVerify, createRemoteJWKSet, errors } from 'jose';

const ISSUER = process.env.OIDC_ISSUER;      // e.g. https://login.example.com/
const AUDIENCE = process.env.API_AUDIENCE;   // the API identifier registered with the IdP
const JWKS = createRemoteJWKSet(new URL(process.env.JWKS_URI));

export async function requireAuth(req, res, next) {
  const match = /^Bearer (.+)$/.exec(req.get('authorization') ?? '');
  if (!match) return res.status(401).json({ error: 'missing_token' });
  try {
    const { payload } = await jwtVerify(match[1], JWKS, {
      algorithms: ['RS256'],
      issuer: ISSUER,
      audience: AUDIENCE,
      clockTolerance: '30s',
    });
    req.auth = payload;
    next();
  } catch (err) {
    const error = err instanceof errors.JWTExpired ? 'token_expired' : 'invalid_token';
    res.status(401).json({ error });
  }
}

Create the JWKS object once at module level, not per request, or you lose the cache. Matchissuer exactly, including any trailing slash, and set audienceto your API's identifier: without it, an access token minted for a different API at the same provider would be accepted. Also make sure the client is sending an access token rather than an ID token; the difference is explained in JWT in OAuth 2.0 and OpenID Connect.

Using the express-jwt Package

express-jwt 8 exports a named expressjwt function (older tutorials use a default export and req.user, both of which were removed in version 7). Verified claims land on req.auth, and failures are passed to Express's error handler as anUnauthorizedError, with codes such as credentials_required orinvalid_token.

import { expressjwt } from 'express-jwt';
import jwksRsa from 'jwks-rsa';

app.use('/api', expressjwt({
  secret: jwksRsa.expressJwtSecret({
    jwksUri: process.env.JWKS_URI,
    cache: true,
    rateLimit: true,
  }),
  algorithms: ['RS256'],
  issuer: process.env.OIDC_ISSUER,
  audience: process.env.API_AUDIENCE,
}));

app.use((err, req, res, next) => {
  if (err.name === 'UnauthorizedError') {
    return res.status(401).json({ error: err.code });
  }
  next(err);
});

For your own HS256 tokens, pass secret: process.env.JWT_SECRET andalgorithms: ['HS256'] instead. The algorithms option is mandatory; that requirement exists because of the vulnerability described next.

Express JWT Vulnerabilities to Know About

  • ·express-jwt before 6.0.0 (CVE-2020-15084): the algorithms option was optional. Combined with jwks-rsa and no allow-list, this allowed an authorisation bypass. Versions 6.0.0 and later require the option.
  • ·jsonwebtoken before 9.0.0 (CVE-2022-23539, CVE-2022-23540, CVE-2022-23541): insecure key types were accepted, verify could fall back to the none algorithm when no algorithms were given and the key was falsy, and a flawed key-retrieval path allowed RSA-to-HMAC confusion. Version 9 also rejects RSA keys under 2048 bits unless allowInsecureKeySizes is set.
  • ·Algorithm confusion in your own code: if a verifier accepts both RS256 and HS256 with the same key material, an attacker can sign an HS256 token using the public key as the HMAC secret. One key, one algorithm. See the none algorithm vulnerability and JWT attacks and vulnerabilities.
  • ·Weak HMAC secrets: a short or guessable secret can be brute-forced offline from a single captured token. Use 32 or more random bytes.

Run npm audit and check that no transitive dependency pins an oldjsonwebtoken.

Refresh Tokens and Logout

A 15-minute access token needs a way to renew without asking for the password again. The standard answer is a long-lived, opaque refresh token stored server-side (hashed) and sent to the browser in anHttpOnly, Secure, SameSite cookie scoped to the refresh path. A POST /refresh route checks it, rotates it, and returns a new access token. Because the refresh token lives in your database, deleting it is how logout and "sign out everywhere" work. The full design, including reuse detection, is in the refresh token pattern guide, and the storage trade-offs are in localStorage vs cookie.

Production Checklist

  • ·Explicit algorithms allow-list on every verify call.
  • ·issuer and audience set on both sign and verify.
  • ·Access token lifetime of 15 minutes or less; small clockTolerance (30 to 60 seconds).
  • ·Secret or private key loaded from the environment or a secrets manager, checked at startup.
  • ·Every verification failure returns 401 with a WWW-Authenticate: Bearer header (RFC 6750); authenticated-but-not-allowed returns 403.
  • ·No personal data in the payload: it is only base64url-encoded, not encrypted.
  • ·HTTPS everywhere. More in JWT security best practices.

Where each check lives

Signature and alg: the algorithms option. Expiry and not-before: automatic in both libraries. Issuer and audience: the issuer andaudience options, which are skipped entirely if you leave them out. Roles and scopes: your own code, after verification.

Summary

JWT authentication in Node.js and Express is a login route that calls jwt.sign with a shortexpiresIn, and a middleware that calls jwt.verify with a fixed algorithm list, issuer and audience, returning 401 on any error. Use jsonwebtoken 9 for self-issued tokens, jose with createRemoteJWKSet for tokens from an identity provider, and express-jwt 8 if you want the middleware pre-built. Never usedecode for authorisation, and keep renewal and logout in a server-side refresh token.

Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder