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.
- 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.
texteyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 <- header { "alg": "HS256", "typ": "JWT" } .eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4ifQ <- payload { "sub": "123", "role": "admin" } .dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk <- signature
Warning
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.
javascriptimport 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.
javascriptimport 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 } }) })
Pick a strong secret
JWT_SECRET with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" and store it in .env. A weak secret makes HS256 signatures forgeable.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.
javascriptfunction 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.
javascriptfunction 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)
Warning
401 Unauthorized when the token is missing or invalid, and 403 Forbidden when the token is valid but the role is not allowed. Mixing them up confuses clients.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.
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 })Why two secrets?
cookie-parser middleware (app.use(cookieParser())).Common Pitfalls
Verify the algorithm explicitly
{ algorithms: ["HS256"] } to jwt.verify in production so an attacker cannot downgrade the signature check.Next Steps
Next Lesson
Database Integration