Node.js · lesson 11 of 20
Beginner
6Intermediate
10- 7 Structured Logging with Pino
- 8 Async Route Handlers and Errors in Express 5
- 9 Testing an HTTP API with node:test and fetch
- 10 The Same API in Fastify
- 11 JWT Auth Middleware
- 12 Graceful Shutdown and Health Checks
- 13 File Uploads with multer
- 14 A WebSocket Server with ws
- 15 OpenAPI Docs from Fastify Schemas
- 16 PostgreSQL with pg: Pools and Transactions
Advanced
4Also part of Node.js
10- + Files, Streams, and the Command Line in Node.js from JavaScript
- + JavaScript Event Loop Explained from JavaScript
- + JavaScript Modules: ESM and CommonJS from JavaScript
- + Introduction to Docker from Docker
- + Managing Docker Containers from Docker
- + Multi-Stage Builds from Docker
- + Docker Compose from Docker
- + Node.js with TypeScript from TypeScript
- + Unsafe File Uploads: secure_filename, Allowlists and Size Limits from Web Security (OWASP Top 10, fix-side)
- + Decorators in TypeScript from TypeScript
JWT Auth Middleware
Issue a signed JWT on login, verify it in Express middleware, and see the exact errors for a missing token, a malformed one, and a genuinely expired one.
By SkillByExample · Updated
A JWT proves who signed it, not who’s holding it — jwt.sign creates one at login, and middleware calling jwt.verify is what actually protects a route.
Signing on Login, Verifying in Middleware
// auth.js
import jwt from "jsonwebtoken";
const SECRET = process.env.JWT_SECRET ?? "dev-secret-change-me";
export function signToken(user) {
return jwt.sign({ sub: user.id, name: user.name }, SECRET, { expiresIn: "15m" });
}
export function requireAuth(req, res, next) {
const header = req.get("authorization") ?? "";
const [scheme, token] = header.split(" ");
if (scheme !== "Bearer" || !token) {
res.set("WWW-Authenticate", "Bearer");
res.status(401).json({ error: "missing bearer token" });
return;
}
try {
req.user = jwt.verify(token, SECRET, { algorithms: ["HS256"] });
next();
} catch (err) {
// Log the real reason server-side; never echo jwt.verify's raw err.message
// to the client — it can describe verification internals (the exact
// algorithm checked, why a signature failed) that help a forgery attempt
// more than a legitimate caller. A client that genuinely needs "expired"
// told apart from "invalid" can still get that through a deliberate,
// distinct error code of this API's own choosing — just not jwt's own text.
console.error(`auth failed: ${err.name}: ${err.message}`);
res.set("WWW-Authenticate", "Bearer");
res.status(401).json({ error: "invalid token" });
}
}
// server.js
import express from "express";
import { signToken, requireAuth } from "./auth.js";
const app = express();
app.use(express.json());
const accounts = [{ id: 1, username: "ada", password: "lovelace", name: "Ada Lovelace" }];
app.post("/login", (req, res) => {
// A real login compares a hash (bcrypt/argon2), never a plaintext password like this.
const account = accounts.find(
(a) => a.username === req.body.username && a.password === req.body.password
);
if (!account) {
res.status(401).json({ error: "invalid username or password" });
return;
}
res.json({ token: signToken(account) });
});
app.get("/me", requireAuth, (req, res) => {
res.json({ userId: req.user.sub, name: req.user.name });
});
app.listen(3000, () => console.log("listening on http://localhost:3000"));
$ node server.js &
$ curl -s -i http://localhost:3000/me
HTTP/1.1 401 Unauthorized
X-Powered-By: Express
WWW-Authenticate: Bearer
Content-Type: application/json; charset=utf-8
Content-Length: 32
ETag: W/"20-yUQSypjVHxHJsiZ5cJjgqN31gKg"
Date: Wed, 28 Oct 2026 18:51:41 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"missing bearer token"}
$ curl -s -X POST http://localhost:3000/login -H 'content-type: application/json' -d '{"username":"ada","password":"wrong"}'
{"error":"invalid username or password"}
$ curl -s -X POST http://localhost:3000/login -H 'content-type: application/json' -d '{"username":"ada","password":"lovelace"}'
{"token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJpYXQiOjE3OTMyMTM1MTIsImV4cCI6MTc5MzIxNDQxMn0.r0bqoqqJJ82uVTdj00JjlEHXwk3jbNJGQ6ofIFKn9wk"}
$ curl -s -H 'authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOjEsIm5hbWUiOiJBZGEgTG92ZWxhY2UiLCJpYXQiOjE3OTMyMTM1MTIsImV4cCI6MTc5MzIxNDQxMn0.r0bqoqqJJ82uVTdj00JjlEHXwk3jbNJGQ6ofIFKn9wk' http://localhost:3000/me
{"userId":1,"name":"Ada Lovelace"}
$ curl -s -i -H 'authorization: Bearer not-a-real-token' http://localhost:3000/me
HTTP/1.1 401 Unauthorized
X-Powered-By: Express
WWW-Authenticate: Bearer
Content-Type: application/json; charset=utf-8
Content-Length: 25
ETag: W/"19-nIoxUTr5+ybmaEhtpQWNIScZDB0"
Date: Wed, 28 Oct 2026 18:52:04 GMT
Connection: keep-alive
Keep-Alive: timeout=5
{"error":"invalid token"}
Four distinct outcomes: no Authorization header at all fails in requireAuth before jwt.verify is even called, and sets WWW-Authenticate: Bearer — the standard header telling a client which auth scheme to use — on every 401 this middleware sends; a wrong password fails in the route handler’s own lookup; a real token decodes sub/name straight back out in req.user; and a string that isn’t a real JWT fails jwt.verify, but the client only ever sees the generic {"error":"invalid token"} — the specific reason (JsonWebTokenError: jwt malformed, visible below in the server’s own log) is logged server-side with console.error, never echoed back, because jwt’s own error text describes verification internals (which algorithm, why the signature failed) that help someone forging a token more than someone who legitimately lost theirs. That’s different from refusing to ever distinguish “expired” from “invalid” at all — a caller that needs that distinction can still get it through a deliberate error code this API chooses to expose, as the next example’s err.name check shows, just not through jwt’s raw message. The payload (sub, name) is visible in the token itself — paste it into any JWT decoder and you’ll read "name":"Ada Lovelace" without a secret, which is exactly why a JWT should never carry something the holder shouldn’t be able to read.
listening on http://localhost:3000
auth failed: JsonWebTokenError: jwt malformed
A Token That Actually Expires
// expiry-demo.js
import jwt from "jsonwebtoken";
const SECRET = "dev-secret-change-me";
const token = jwt.sign({ sub: 1 }, SECRET, { expiresIn: "1s" });
setTimeout(() => {
try {
jwt.verify(token, SECRET);
console.log("verified ok");
} catch (err) {
console.log(`verify failed: ${err.name}: ${err.message}`);
}
}, 1500);
$ node expiry-demo.js
verify failed: TokenExpiredError: jwt expired
expiresIn: "1s" bakes an exp claim into the token at sign time; jwt.verify 1.5 real seconds later throws a specifically-named TokenExpiredError, distinct from the generic JsonWebTokenError a malformed or tampered token raises — code that needs to tell “expired, please log in again” apart from “this token is garbage” can check err.name or err instanceof jwt.TokenExpiredError.
Practice
1. Sign a token with { algorithm: "HS384" } instead of the default HS256, and confirm requireAuth’s algorithms: ["HS256"] option rejects it even though the secret is correct.
Solution
// mismatched-algorithm.js
import jwt from "jsonwebtoken";
const SECRET = "dev-secret-change-me";
const token = jwt.sign({ sub: 1 }, SECRET, { algorithm: "HS384" });
try {
jwt.verify(token, SECRET, { algorithms: ["HS256"] });
console.log("verified ok");
} catch (err) {
console.log(`${err.name}: ${err.message}`);
}
$ node mismatched-algorithm.js
JsonWebTokenError: invalid algorithm
The secret matches and the payload is fine — only the signing algorithm differs — but pinning algorithms: ["HS256"] on jwt.verify rejects it anyway. Without that option, jwt.verify doesn’t accept literally whatever the token claims — a string secret restricts it to the HMAC family already, and an unsigned alg: "none" token is rejected regardless — but it does accept any HMAC algorithm (HS256, HS384, or HS512), which is exactly the gap pinning algorithms: ["HS256"] closes. That still matters: some algorithm-confusion attacks rely on a verifier accepting more than one algorithm for the same key.
2. Add an issuer claim at sign time ({ issuer: "users-api" }) and verify it at check time ({ issuer: "users-api" }), then try verifying a token signed with a different issuer.
Solution
// auth.js (issuer claim)
export function signToken(user) {
return jwt.sign({ sub: user.id, name: user.name }, SECRET, {
expiresIn: "15m",
issuer: "users-api",
});
}
export function requireAuth(req, res, next) {
const header = req.get("authorization") ?? "";
const [scheme, token] = header.split(" ");
if (scheme !== "Bearer" || !token) {
res.set("WWW-Authenticate", "Bearer");
res.status(401).json({ error: "missing bearer token" });
return;
}
try {
req.user = jwt.verify(token, SECRET, { algorithms: ["HS256"], issuer: "users-api" });
next();
} catch (err) {
console.error(`auth failed: ${err.name}: ${err.message}`);
res.set("WWW-Authenticate", "Bearer");
res.status(401).json({ error: "invalid token" });
}
}
// mismatched-issuer.js
import jwt from "jsonwebtoken";
const SECRET = "dev-secret-change-me";
const token = jwt.sign({ sub: 1 }, SECRET, { issuer: "other-service" });
try {
jwt.verify(token, SECRET, { algorithms: ["HS256"], issuer: "users-api" });
} catch (err) {
console.log(`${err.name}: ${err.message}`);
}
$ node mismatched-issuer.js
JsonWebTokenError: jwt issuer invalid. expected: users-api
A token this API’s own /login never issued (signed with a different issuer) fails jwt.verify even with the correct secret — issuer lets one service reject tokens meant for a different one without changing the signing secret.
3. Decode (not verify) the token from the main example with jwt.decode() instead of jwt.verify(), using the wrong secret, and compare.
Solution
// decode-vs-verify.js
import jwt from "jsonwebtoken";
const token = jwt.sign({ sub: 1, name: "Ada Lovelace" }, "real-secret");
console.log("decode (no secret needed):", jwt.decode(token));
try {
jwt.verify(token, "wrong-secret");
} catch (err) {
console.log(`verify with wrong secret: ${err.name}: ${err.message}`);
}
$ node decode-vs-verify.js
decode (no secret needed): { sub: 1, name: 'Ada Lovelace', iat: 1793184610 }
verify with wrong secret: JsonWebTokenError: invalid signature
jwt.decode() reads the payload with no secret and no signature check at all — useful for inspecting a token, never for deciding whether to trust it. jwt.verify() with the wrong secret throws invalid signature, which is the actual security boundary.
4. Change signToken to use expiresIn: "0s" and verify it immediately (no setTimeout). Does it expire instantly?
Solution
// instant-expiry.js
import jwt from "jsonwebtoken";
const SECRET = "dev-secret-change-me";
const token = jwt.sign({ sub: 1 }, SECRET, { expiresIn: "0s" });
try {
jwt.verify(token, SECRET);
console.log("verified ok");
} catch (err) {
console.log(`verify failed: ${err.name}: ${err.message}`);
}
$ node instant-expiry.js
verify failed: TokenExpiredError: jwt expired
It fails immediately — jwt.verify checks exp as “less than or equal to now,” not “strictly in the future,” so exp equal to the current second already counts as expired. expiresIn: "0s" is really a token born already expired, not a token that’s valid for an instant.