JWT Authentication
Sessions work very well for normal websites. But many APIs are called by mobile apps, single-page React apps on another domain, or other servers, where cookies are awkward. A popular alternative is a token: after login the server hands the client a signed piece of text, and the client sends it back with every request. The most common token format is the JSON Web Token, or JWT (often pronounced "jot").
In this lesson you will see what is inside a JWT, create and verify tokens with the jsonwebtoken package, protect routes with middleware, allow some routes only for certain roles, and learn when sessions are the better choice.
What is inside a JWT?
Install the package and create a token:
npm install jsonwebtokenimport jwt from "jsonwebtoken";
const SECRET = "a-long-random-secret-from-env";
const token = jwt.sign({ sub: "42", name: "Kabir", role: "student" }, SECRET, { expiresIn: "15m" });
console.log(token);
const [header, payload] = token.split(".");
console.log(JSON.parse(Buffer.from(header, "base64url").toString()));
console.log(JSON.parse(Buffer.from(payload, "base64url").toString()));eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJLYWJpciIsInJvbGUiOiJzdHVkZW50IiwiaWF0IjoxNzkzMzU1NDY1LCJleHAiOjE3OTMzNTYzNjV9.ytrgNHhnj1HsYKmLs2dzkOQK4CzmAMt51iEjM4-zoVg
{ alg: 'HS256', typ: 'JWT' }
{
sub: '42',
name: 'Kabir',
role: 'student',
iat: 1793355465,
exp: 1793356365
}A JWT has three parts separated by dots:
- Header: the signing algorithm, here
HS256(HMAC with SHA-256 and a shared secret). - Payload: the claims, the data about the user.
sub(subject) is the standard name for the user ID.jwt.sign()addediat(issued at) andexp(expires), both in seconds since 1970. - Signature: a hash of the header and payload made with your secret.
The header and payload are only base64url-encoded, not encrypted. Anyone holding the token can read them, as the code above did. So never put secrets in a JWT: no passwords, no Aadhaar or bank numbers. What the signature gives you is integrity: nobody can change the payload without the secret.
Verifying a token
jwt.verify() checks the signature and the expiry time, and returns the payload if both are fine:
const data = jwt.verify(token, SECRET, { algorithms: ["HS256"] });
console.log(`Verified: user ${data.sub}, role ${data.role}`);
// Change the payload and keep the old signature
const fakePayload = Buffer.from(JSON.stringify({ sub: "42", name: "Kabir", role: "admin" })).toString("base64url");
const forged = [header, fakePayload, token.split(".")[2]].join(".");
try {
jwt.verify(forged, SECRET, { algorithms: ["HS256"] });
} catch (err) {
console.log(`${err.name}: ${err.message}`);
}
// An expired token
const old = jwt.sign({ sub: "42", exp: Math.floor(Date.now() / 1000) - 60 }, SECRET);
try {
jwt.verify(old, SECRET, { algorithms: ["HS256"] });
} catch (err) {
console.log(`${err.name}: ${err.message}`);
}Verified: user 42, role student JsonWebTokenError: invalid signature TokenExpiredError: jwt expired
Kabir tried to make himself an admin by editing the payload, but the signature no longer matched. Always pass the algorithms option, so the library accepts only the algorithm you actually use; some past attacks tricked servers into accepting a different algorithm.
Protecting an Express API
The client sends the token in the Authorization header using the Bearer scheme: Authorization: Bearer eyJhbGci.... A middleware checks it and stores the claims in req.user. A second middleware factory checks the role:
import express from "express";
import jwt from "jsonwebtoken";
import argon2 from "argon2";
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET;
const users = [
{ id: 1, email: "priya@example.in", role: "teacher", passwordHash: await argon2.hash("Jaipur#Pink77") },
{ id: 2, email: "rohan@example.in", role: "student", passwordHash: await argon2.hash("Rohtak#Wrestle5") },
];
app.post("/api/login", async (req, res) => {
const { email, password } = req.body;
const user = users.find((u) => u.email === email);
if (!user || !(await argon2.verify(user.passwordHash, password ?? ""))) {
return res.status(401).json({ error: "Wrong email or password" });
}
const token = jwt.sign({ sub: String(user.id), role: user.role }, JWT_SECRET, { expiresIn: "1h" });
res.json({ token });
});
function requireAuth(req, res, next) {
const header = req.get("Authorization") ?? "";
const [scheme, token] = header.split(" ");
if (scheme !== "Bearer" || !token) {
return res.status(401).json({ error: "Missing token" });
}
try {
req.user = jwt.verify(token, JWT_SECRET, { algorithms: ["HS256"] });
next();
} catch (err) {
const message = err.name === "TokenExpiredError" ? "Token expired" : "Invalid token";
res.status(401).json({ error: message });
}
}
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) return res.status(403).json({ error: `Only a ${role} can do this` });
next();
};
}
app.get("/api/me", requireAuth, (req, res) => {
res.json({ id: req.user.sub, role: req.user.role });
});
app.post("/api/marks", requireAuth, requireRole("teacher"), (req, res) => {
res.status(201).json({ saved: req.body, by: req.user.sub });
});
app.listen(3000);A client that logs in as a student, then as a teacher, sees:
POST /login -> 200 { token: 'eyJhbGciOiJIUzI1NiIs...' }
GET /me -> 401 {"error":"Missing token"}
GET /me -> 200 {"id":"2","role":"student"}
POST /marks -> 403 {"error":"Only a teacher can do this"}
POST /login -> 200 { token: 'eyJhbGciOiJIUzI1NiIs...' }
POST /marks -> 201 {"saved":{"roll":7,"maths":91},"by":"1"}
GET /me -> 401 {"error":"Invalid token"}The last request used a token with two characters changed, so verification failed. On the client side, the front end keeps the token after login and adds the header to every call:
const res = await fetch("/api/me", {
headers: { Authorization: `Bearer ${token}` },
});Expiry, logout and refresh tokens
A JWT is valid until it expires, and the server keeps no record of it. That is the big trade-off: you cannot easily log a user out or block a stolen token before it expires. Common solutions:
- Keep access tokens short-lived, such as 10 to 15 minutes.
- Issue a second, long-lived refresh token at login, stored in an
httpOnlycookie and recorded in the database. When the access token expires, the client calls a/refreshroute to get a new one. Logging out deletes the refresh token from the database. - Put a version number in the token and in the user's database row; changing the password increases it, which makes old tokens invalid.
Where should the browser keep the token? localStorage is easy but any cross-site scripting (XSS) bug can read it. An httpOnly, secure, sameSite cookie cannot be read by scripts, which is why many teams store tokens in cookies for browser apps and use the Authorization header for mobile apps and server-to-server calls.
Sessions or JWT?
| Sessions | JWT | |
|---|---|---|
| Where user data lives | Server (session store) | Inside the token, on the client |
| Logout and blocking | Easy: delete the session | Hard until expiry; needs extra design |
| Scaling to many servers | Needs a shared store (Redis, database) | Any server with the secret can verify |
| Best for | Traditional websites on one domain | Mobile apps, APIs, many services |
| Size per request | Small cookie | Larger header (the whole token) |
Neither is "more secure" by itself. For a normal website, sessions are simpler and safer. Use JWT when you need stateless verification across apps or services.
Common mistakes
- Putting sensitive data in the payload. It is readable by anyone. Store only an ID and a role.
- A short or guessable secret. Use at least 32 random bytes, for example from
node -p "require('node:crypto').randomBytes(32).toString('hex')", kept in an environment variable. - No expiry. Always set
expiresIn. - Using
jwt.decode()instead ofjwt.verify(). Decoding does not check anything. - Leaving out the
algorithmsoption inverify(). - Trusting the role in the token forever. If roles change often, check the database for important actions.
Key points
- A JWT is
header.payload.signature; the payload is readable, the signature prevents changes. jwt.sign(payload, secret, { expiresIn })creates a token;jwt.verify(token, secret, { algorithms })checks it.- Clients send tokens in the
Authorization: Bearer <token>header; middleware verifies and setsreq.user. - Use
401for missing or invalid tokens and403for logged-in users without permission. - Keep access tokens short-lived; use refresh tokens or sessions when you need reliable logout.
Exercise
Add a requireAnyRole(...roles) middleware factory to the JWT example that allows the request if req.user.role is in the list. Use it so that GET /api/timetable is open to both teachers and students, while DELETE /api/timetable is allowed only for an "admin". Test it with the student token.
Show solution
function requireAnyRole(...roles) {
return (req, res, next) => {
if (!roles.includes(req.user.role)) {
return res.status(403).json({ error: "You are not allowed to do this" });
}
next();
};
}
app.get("/api/timetable", requireAuth, requireAnyRole("teacher", "student"), (req, res) => {
res.json({ monday: ["Maths", "Hindi", "Science"] });
});
app.delete("/api/timetable", requireAuth, requireAnyRole("admin"), (req, res) => {
res.sendStatus(204);
});GET /timetable -> 200 {"monday":["Maths","Hindi","Science"]}
DELETE /timetable -> 403 {"error":"You are not allowed to do this"}The rest parameter ...roles collects any number of role names, and requireAuth always runs first so req.user exists when the role check runs.
Quick Quiz
1. Which part of a JWT stops users from changing their role?
The signature is computed with the server's secret, so any change to the payload breaks it.
2. Is the payload of a JWT encrypted?
Never put secrets in a JWT payload.
3. How should a client send a JWT to an API?
The Bearer scheme in the Authorization header is the standard way; tokens in URLs leak into logs.
4. What is a main drawback of JWTs compared with server-side sessions?
The server keeps no record of issued tokens, so revoking one needs extra work.
Learn Node.js with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
