JWT Authentication
A JSON Web Token (JWT) is a signed, self-contained credential that carries a user’s identity inside the token itself, so your Express server can authenticate a request by verifying a signature instead of looking anything up in a database. This statelessness is what makes JWTs the default choice for JSON APIs, single-page apps, and mobile backends that need to scale horizontally. In this page you’ll issue a JWT on login with the jsonwebtoken library, send it as a Bearer token, verify it in middleware, attach req.user, and handle expiry — along with the trade-offs that come with going stateless.
Anatomy of a JWT
A JWT is three Base64URL-encoded segments joined by dots — header.payload.signature. The header names the signing algorithm, the payload holds claims (arbitrary JSON about the user plus standard fields like sub, iat, and exp), and the signature is an HMAC or RSA signature over the first two parts using your secret. Crucially, the payload is encoded, not encrypted — anyone can decode and read it. The signature only guarantees the claims haven’t been tampered with, so never put passwords or secrets inside a token.
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.eyJzdWIiOiJ1XzEyMyIsInJvbGUiOiJ1c2VyIn0 ← payload (claims)
.3y8K2f...signature... ← signature
Installing jsonwebtoken
npm install jsonwebtoken
Load your signing secret from the environment — never hardcode it. A leaked secret lets anyone forge a valid token for any user.
# .env
JWT_SECRET=replace-with-a-long-random-string
JWT_EXPIRES_IN=15m
Issuing a token on login
When a user authenticates successfully, sign a token containing the minimal claims your API needs to identify them later. Put the user id in the standard sub (subject) claim and add anything cheap and stable, such as a role. The expiresIn option sets the exp claim automatically.
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");
const router = express.Router();
router.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: process.env.JWT_EXPIRES_IN, algorithm: "HS256" }
);
res.json({ token, tokenType: "Bearer", expiresIn: 900 });
});
module.exports = router;
Output:
$ curl -X POST http://localhost:3000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"hunter2"}'
{"token":"eyJhbGciOiJIUzI1Ni...","tokenType":"Bearer","expiresIn":900}
The client stores this token and sends it back on every subsequent request.
Sending the token as a Bearer credential
The convention for APIs is the HTTP Authorization header with the Bearer scheme:
Authorization: Bearer eyJhbGciOiJIUzI1Ni...
Avoid localStorage for tokens in browsers when you can — it is readable by any injected script (XSS). An HttpOnly cookie or in-memory storage is safer; if you do use a cookie, add CSRF protection.
Verifying the token in middleware
Authentication should live in one reusable middleware that runs before protected routes. It extracts the token, verifies the signature and expiry with jwt.verify, and attaches the decoded claims to req.user. Because jwt.verify throws on a bad or expired token, wrap it in try/catch and translate failures into a 401.
const jwt = require("jsonwebtoken");
function requireAuth(req, res, next) {
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : null;
if (!token) {
return res.status(401).json({ error: "Missing bearer token" });
}
try {
const claims = jwt.verify(token, process.env.JWT_SECRET);
req.user = { id: claims.sub, email: claims.email, role: claims.role };
next();
} catch (err) {
if (err.name === "TokenExpiredError") {
return res.status(401).json({ error: "Token expired" });
}
return res.status(401).json({ error: "Invalid token" });
}
}
module.exports = requireAuth;
Apply it per route or, better, at the router level so every endpoint under a prefix is protected:
const express = require("express");
const requireAuth = require("./middleware/require-auth");
const api = express.Router();
api.use(requireAuth); // every route below is now authenticated
api.get("/me", (req, res) => {
res.json({ id: req.user.id, email: req.user.email, role: req.user.role });
});
app.use("/api", api);
Output:
$ curl -H "Authorization: Bearer eyJhbGci..." http://localhost:3000/api/me
{"id":"u_123","email":"[email protected]","role":"user"}
$ curl http://localhost:3000/api/me
{"error":"Missing bearer token"}
Tip on Express 5: In Express 4 a synchronous
throwinside middleware is caught automatically, but rejected promises are not. Express 5 forwards rejected async handlers to the error middleware too. TherequireAuthabove is synchronous, so it is safe in both — but if you add anawait(for example to check a token denylist), let the rejection propagate or callnext(err).
Handling expiry and revocation
Short expiry is the cornerstone of JWT security. Keep access tokens minutes-long so a stolen one is useless almost immediately. When jwt.verify raises TokenExpiredError, the client should silently obtain a new access token using a long-lived refresh token rather than forcing a re-login.
The hard limit of statelessness is revocation: because the server holds no session, you cannot un-issue a token before its exp. Logout simply discards it client-side. To force-invalidate before expiry — a stolen token, a banned user — you need extra machinery such as a Redis denylist of token ids (jti) or a per-user tokenVersion checked on each request, which reintroduces a lookup and trades away some of the statelessness you started with.
| Concern | Stateless JWT | Mitigation |
|---|---|---|
| Per-request DB lookup | None | — |
| Horizontal scaling | Trivial | — |
| Instant revocation | Not possible | Short expiry + refresh tokens |
| Force logout / ban | Not possible | jti denylist or tokenVersion |
| Token theft window | Until exp | Keep access tokens short-lived |
Best Practices
- Sign tokens with a long, random secret loaded from an environment variable, and rotate it if a leak is suspected.
- Keep access tokens short-lived (5–15 minutes) and pair them with rotating refresh tokens for longevity.
- Put only non-sensitive identity claims in the payload — it is readable by anyone, never encrypted.
- Always pin and check the
algorithm(HS256,RS256) to prevent algorithm-confusion attacks; never acceptnone. - Centralize verification in one middleware and apply it at the router level instead of repeating it per route.
- Distinguish
401(missing/invalid/expired token) from403(valid token, insufficient role). - Serve every auth endpoint over HTTPS so tokens are never transmitted in cleartext.