Two Approaches: DIY or Package
Express JWT authentication breaks down into two paths. The DIY approach wraps thejsonwebtoken package in a small custom middleware — full control, obvious failure modes, minimal dependency footprint. The package approach uses express-jwt, which handles edge cases (JWKS fetching, token refresh hints, scope validation) that you probably do not want to re-implement.
| Scenario | DIY | express-jwt |
|---|---|---|
| Internal API, HS256 secret | ✅ Simple | ✅ Also fine |
| Auth0 / Cognito RS256 + JWKS | ⚠️ Lots of code | ✅ Built-in via jwks-rsa |
| Multiple audiences / issuers | ⚠️ Manual | ✅ Option flags |
| Custom claim validation | ✅ Full control | ⚠️ Requires callback |
| Learning / tutorial | ✅ Transparent | ⚠️ Hides the mechanism |
DIY Pattern with jsonwebtoken
// middleware/auth.ts
import jwt, { JsonWebTokenError, TokenExpiredError } from 'jsonwebtoken';
import type { Request, Response, NextFunction } from 'express';
export interface JwtUser {
sub: string;
role: 'admin' | 'user' | 'viewer';
tenant_id?: string;
}
// TypeScript augmentation so req.user is typed
declare global {
namespace Express {
interface Request {
user?: JwtUser;
}
}
}
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'missing_token' });
}
const token = auth.slice(7);
try {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
issuer: 'https://auth.myapp.com',
audience: 'https://api.myapp.com',
algorithms: ['HS256'], // IMPORTANT — never allow 'none'
}) as JwtUser;
req.user = payload;
next();
} catch (err) {
if (err instanceof TokenExpiredError) {
return res.status(401).json({ error: 'token_expired' });
}
if (err instanceof JsonWebTokenError) {
return res.status(401).json({ error: 'invalid_token' });
}
return res.status(500).json({ error: 'internal_error' });
}
}Role-Based Middleware Factory
// middleware/role.ts
import type { Request, Response, NextFunction } from 'express';
export function requireRole(...roles: string[]) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'not_authenticated' });
}
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: 'insufficient_role' });
}
next();
};
}
// Usage — chain requireAuth first, then requireRole
app.get('/admin/users',
requireAuth,
requireRole('admin'),
(req, res) => { res.json({ users: [] }); }
);
app.get('/billing',
requireAuth,
requireRole('admin', 'finance'), // either role is fine
billingHandler
);Using the express-jwt Package
npm install express-jwt
# npm install jwks-rsa (only if you need RS256 / JWKS)
// app.ts
import express from 'express';
import { expressjwt as jwt } from 'express-jwt';
const app = express();
// HS256 example
app.use(
jwt({
secret: process.env.JWT_SECRET!,
algorithms: ['HS256'],
issuer: 'https://auth.myapp.com',
audience: 'https://api.myapp.com',
}).unless({ path: ['/login', '/signup', /^\/public\//] })
);
// req.auth is populated after verification (NOT req.user)
app.get('/me', (req, res) => res.json(req.auth));
// Global error handler for 401s
app.use((err, req, res, next) => {
if (err.name === 'UnauthorizedError') {
return res.status(401).json({ error: err.code }); // 'invalid_token', 'credentials_required', ...
}
next(err);
});RS256 + Auth0 / Cognito / Firebase JWKS
import { expressjwt as jwt } from 'express-jwt';
import jwksRsa from 'jwks-rsa';
app.use(
jwt({
secret: jwksRsa.expressJwtSecret({
jwksUri: 'https://YOUR_TENANT.auth0.com/.well-known/jwks.json',
cache: true,
rateLimit: true,
cacheMaxAge: 10 * 60 * 1000, // 10 minutes
}) as any,
audience: 'https://api.myapp.com',
issuer: 'https://YOUR_TENANT.auth0.com/',
algorithms: ['RS256'],
}).unless({ path: ['/health', '/metrics'] })
);Async / Promise-Based Verification
import { promisify } from 'util';
import jwt from 'jsonwebtoken';
const verifyAsync = promisify(jwt.verify) as (t: string, s: string, o?: object) => Promise<any>;
export async function requireAuthAsync(req, res, next) {
const token = req.headers.authorization?.slice(7);
if (!token) return res.status(401).json({ error: 'missing_token' });
try {
const payload = await verifyAsync(token, process.env.JWT_SECRET!);
// You can now await DB calls here, e.g. check a revocation list
// const revoked = await isTokenRevoked(payload.jti);
// if (revoked) return res.status(401).json({ error: 'revoked' });
req.user = payload;
next();
} catch (err) {
next(err); // let global handler respond
}
}Common Pitfalls
- Not whitelisting algorithms.
jwt.verify(token, secret)withoutalgorithms: [...]accepts any algorithm the token claims — includingnone. Always passalgorithms: ['HS256'](or your actual algorithm). - Trusting the
issoraudclaim without checking.jwt.verifyonly validates these if you pass them in options. Otherwise any iss/aud is accepted. - Storing JWT_SECRET in source control. Use environment variables and a secrets manager; see our JWT secret key generator.
- Returning 500 for expired tokens. Expired tokens are 401 — not server errors. Catch
TokenExpiredErrorspecifically. - Attaching the raw token to req. Attach the decoded payload. If downstream handlers need the raw token (e.g. to proxy to another service), name it
req.rawTokento avoid confusion. - Mounting globally when only some routes need auth. Use
.unless()(express-jwt) or mount only on protected routes.
Testing JWT Middleware
// Example with supertest + jest
import request from 'supertest';
import jwt from 'jsonwebtoken';
import app from './app';
const token = jwt.sign({ sub: 'user_1', role: 'admin' }, process.env.JWT_SECRET!);
test('GET /dashboard — authorised', async () => {
const res = await request(app)
.get('/dashboard')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
});
test('GET /dashboard — missing token', async () => {
const res = await request(app).get('/dashboard');
expect(res.status).toBe(401);
});
test('GET /dashboard — expired token', async () => {
const expired = jwt.sign({ sub: 'u' }, process.env.JWT_SECRET!, { expiresIn: '-1h' });
const res = await request(app).get('/dashboard').set('Authorization', `Bearer ${expired}`);
expect(res.status).toBe(401);
expect(res.body.error).toBe('token_expired');
});Related JWT Tools
- JWT Decoder Online — inspect tokens hitting your API
- Verify JWT Signature — confirm signatures end-to-end
- JWT Decoder in Node.js — client-side counterpart
- JWT Next.js Middleware — Edge Runtime variant
- JWT Secret Key Generator — create JWT_SECRET
- JWT Debugger — full decode and verify tool