JWT Authentication

Node.js · Lesson 22 of 30 7 min read Updated

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:

Terminal
npm install jsonwebtoken
JavaScript
import 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()));
Output
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:

  1. Header: the signing algorithm, here HS256 (HMAC with SHA-256 and a shared secret).
  2. Payload: the claims, the data about the user. sub (subject) is the standard name for the user ID. jwt.sign() added iat (issued at) and exp (expires), both in seconds since 1970.
  3. 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:

JavaScript
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}`);
}
Output
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:

JavaScript
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:

Output
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:

JavaScript
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 httpOnly cookie and recorded in the database. When the access token expires, the client calls a /refresh route 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?

SessionsJWT
Where user data livesServer (session store)Inside the token, on the client
Logout and blockingEasy: delete the sessionHard until expiry; needs extra design
Scaling to many serversNeeds a shared store (Redis, database)Any server with the secret can verify
Best forTraditional websites on one domainMobile apps, APIs, many services
Size per requestSmall cookieLarger 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 of jwt.verify(). Decoding does not check anything.
  • Leaving out the algorithms option in verify().
  • 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 sets req.user.
  • Use 401 for missing or invalid tokens and 403 for 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
JavaScript
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);
});
Output
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?

2. Is the payload of a JWT encrypted?

3. How should a client send a JWT to an API?

4. What is a main drawback of JWTs compared with server-side sessions?

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