Skip to main content
Node.js · lesson 11 of 20
Node.js intermediate Lesson 11 of 20

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

nodejs express jwt auth

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.

Frequently Asked Questions

Is a JWT encrypted?
No — a JWT signed with jwt.sign (the common case, HMAC or RSA signature) is base64url-encoded and signed, not encrypted. Anyone who intercepts it can decode and read the payload; the signature only proves it wasn't tampered with and was issued by someone holding the secret. Never put a password or other secret value inside the payload itself.
What does jwt.verify actually check?
It recomputes the signature over the token's header and payload using the secret you pass it, confirms that matches the signature in the token, and (for a token signed with expiresIn) checks the exp claim against the current time — throwing a specific error class for a bad signature versus an expired one.
Why check req.get("authorization") instead of a cookie?
The Authorization: Bearer <token> header is the conventional place for an API token, works the same for any HTTP client (not just a browser), and doesn't need CSRF protection the way a cookie-based session does — at the cost of the client having to store and attach the token itself.
Should JWT_SECRET really be a string like "dev-secret-change-me"?
No — that's a placeholder read from process.env.JWT_SECRET with a fallback purely so this lesson's examples run without extra setup. A real deployment must set JWT_SECRET to a long, random value through its actual secret management, never a string checked into source control.
What library versions does this lesson use?
jsonwebtoken 9.0.3, on Express 5.3.0 and Node 26.12.1.