intermediate
backend

JWT Authentication

Implement secure authentication with JSON Web Tokens and middleware guards

express fundamentals
28 min read

JWT Authentication in Express

Most REST APIs are stateless: the server keeps no session in memory between requests. A JSON Web Token (JWT) solves the "who is this user?" problem by handing the client a signed, self-contained credential after login. On every subsequent request the client sends that token, and the server verifies the signature to trust the claims inside it — no database lookup and no shared session store required. This makes JWTs a natural fit for horizontally scaled Express services, mobile back ends, and any API consumed by a SPA. In this lesson you'll build the full flow: hashing passwords, issuing tokens on login, guarding routes with middleware, and layering role-based access on top.

Learning Objectives
  • Explain the three parts of a JWT and how signatures prevent tampering
  • Hash and verify passwords with bcrypt
  • Sign and verify tokens with the jsonwebtoken library
  • Protect routes with reusable auth and role middleware
  • Apply access + refresh token patterns and avoid common security mistakes

Anatomy of a Token

A JWT is three Base64URL-encoded segments joined by dots: header.payload.signature. The header names the algorithm, the payload holds claims (arbitrary JSON like the user id), and the signature is an HMAC (with HS256) or asymmetric signature (with RS256) over the first two parts.

text
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 <- header { "alg": "HS256", "typ": "JWT" } .eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ <- payload { "sub": "123", "role": "admin" } .dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk <- signature

Registration: Hashing Passwords

Never store raw passwords. Use bcrypt, which is deliberately slow and salts each hash automatically, so identical passwords produce different digests. A cost factor of 10–12 is a sensible default for 2020s hardware.

javascript
import bcrypt from "bcryptjs" app.post("/api/auth/register", async (req, res, next) => { try { const { email, password } = req.body if (!email || !password || password.length < 8) { return res.status(400).json({ error: "Email and 8+ char password required" }) } const passwordHash = await bcrypt.hash(password, 12) const user = await prisma.user.create({ data: { email, passwordHash, role: "user" }, }) res.status(201).json({ id: user.id, email: user.email }) } catch (err) { next(err) // e.g. unique-constraint violation on email } })

Login: Issuing a Token

On login, verify the password with bcrypt.compare, then sign a token. Keep the payload small — just the identifiers you need for authorization. Use sub (subject) for the user id, per the JWT spec, and always set expiresIn.

javascript
import jwt from "jsonwebtoken" app.post("/api/auth/login", async (req, res) => { const { email, password } = req.body const user = await prisma.user.findUnique({ where: { email } }) // Compare even when user is missing to avoid leaking which emails exist. const ok = user && (await bcrypt.compare(password, user.passwordHash)) if (!ok) { return res.status(401).json({ error: "Invalid credentials" }) } const token = jwt.sign( { sub: user.id, role: user.role }, process.env.JWT_SECRET, { expiresIn: "15m", issuer: "portfolio-api" } ) res.json({ token, user: { id: user.id, email: user.email } }) })

Protecting Routes with Middleware

Verification belongs in a single reusable middleware. It pulls the token from the Authorization: Bearer <token> header, verifies it, and attaches the decoded claims to req.user so downstream handlers can read them.

javascript
function requireAuth(req, res, next) { const header = req.headers.authorization if (!header?.startsWith("Bearer ")) { return res.status(401).json({ error: "Missing or malformed token" }) } try { req.user = jwt.verify(header.slice(7), process.env.JWT_SECRET) next() } catch (err) { // jwt.verify throws TokenExpiredError or JsonWebTokenError const expired = err.name === "TokenExpiredError" res.status(401).json({ error: expired ? "Token expired" : "Invalid token" }) } } app.get("/api/me", requireAuth, (req, res) => { res.json({ id: req.user.sub, role: req.user.role }) })

Role-Based Authorization

Authentication answers who you are; authorization answers what you may do. Because the role is baked into the token, a small higher-order middleware can gate routes by role without another database round trip.

javascript
function requireRole(...roles) { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ error: "Insufficient permissions" }) } next() } } // Chain guards: must be authenticated AND an admin. app.delete("/api/users/:id", requireAuth, requireRole("admin"), handler)

Access + Refresh Tokens

Short-lived access tokens limit the damage of a leak, but forcing a re-login every 15 minutes is poor UX. The standard fix is a long-lived refresh token stored in an httpOnly cookie, exchanged for a fresh access token when the old one expires.

javascript
const accessToken = jwt.sign({ sub: user.id, role: user.role },
process.env.JWT_SECRET, { expiresIn: "15m" })

const refreshToken = jwt.sign({ sub: user.id },
process.env.JWT_REFRESH_SECRET, { expiresIn: "7d" })

res
.cookie("refreshToken", refreshToken, {
  httpOnly: true,
  secure: true,       // HTTPS only
  sameSite: "strict",
  path: "/api/auth/refresh",
})
.json({ accessToken })

Common Pitfalls


Next Steps

Continue Learning
Connect Express to PostgreSQL with Prisma.

Next Lesson

Database Integration