Skip to main content

How to implement JWT authentication in Express.js?

JWT authentication in Express.js uses signed JSON tokens to verify user identity without storing session state on the server.

Theory

TL;DR

  • JWT is like a tamper-evident stamp: the client carries it, the server checks the seal and expiration without any lookup
  • Core flow: POST /login with credentials, receive a signed token, send it in Authorization: Bearer <token> on every request
  • Use JWT for stateless APIs and horizontal scaling. Use sessions for server-rendered apps with sensitive mutable state
  • Always set expiresIn. Always pass { algorithms: ['HS256'] } to jwt.verify. Store secrets in environment variables, never in code

Quick Example

js
const jwt = require('jsonwebtoken'); const SECRET = process.env.JWT_SECRET; // Issue token on login app.post('/login', async (req, res) => { const user = await User.findOne({ email: req.body.email }); const valid = await bcrypt.compare(req.body.password, user.passwordHash); if (!valid) return res.status(401).json({ error: 'Invalid credentials' }); const token = jwt.sign({ id: user.id, email: user.email }, SECRET, { expiresIn: '15m' }); res.json({ token }); }); // Verify in middleware function authenticate(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; if (!token) return res.status(401).json({ error: 'No token' }); try { req.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] }); next(); } catch { res.status(401).json({ error: 'Invalid or expired token' }); } }

jwt.sign encodes the payload, signs it with your secret, and embeds the expiry. jwt.verify recomputes the signature and throws if anything doesn't match. No database call on either side.

How JWT Works Internally

A token has three dot-separated parts: header (algorithm name), payload (your claims: user id, role, expiry timestamps), and signature. The jsonwebtoken library uses Node's crypto.createHmac with your secret to produce the HS256 signature. Verification recomputes it and compares. If the payload was changed after signing, the signatures won't match and verify throws.

The full request cycle:

  1. Client POST /auth/login with email and password
  2. Server finds the user, bcrypt.compare checks the password hash
  3. Server calls jwt.sign({id, email, role}, SECRET, {expiresIn: '15m'}) and returns the token
  4. Client stores the token (httpOnly cookie beats localStorage for XSS resistance)
  5. Every following request includes Authorization: Bearer <token>
  6. The authenticate middleware calls jwt.verify, attaches req.user, calls next()

The server never stores the token. That's the whole design.

JWT vs Sessions

Sessions store user state in memory or Redis, requiring a lookup on every request. JWT encodes the state inside the token and signs it. The server only needs the secret to verify. No shared store, no sticky sessions when you scale out.

But tokens can't be revoked before expiry. If a user logs out, the token stays technically valid until it expires. Sessions kill that instantly. So: JWT for stateless APIs, mobile backends, microservices. Sessions for traditional server-rendered apps with sensitive, mutable state.

When to Use

  • REST or GraphQL APIs consumed by mobile apps or SPAs
  • Horizontally scaled services where a shared session store adds complexity
  • Microservices passing verified user claims between services (RS256 fits better here)
  • Short-lived access tokens (15m) paired with refresh tokens for longer sessions

Skip JWT if you need immediate token revocation without building a Redis blacklist, or if your app is server-rendered with heavy session logic.

Common Mistakes

No expiration on the token. jwt.sign(payload, secret) without expiresIn creates a token that lives forever. Always add { expiresIn: '15m' } for access tokens.

Skipping algorithms in verify. The default behavior allows the alg: none attack, where an attacker strips the signature and sets the algorithm header to none. A misconfigured server accepts it as valid. Fix:

js
// Wrong: algorithm confusion vulnerability jwt.verify(token, secret); // Right jwt.verify(token, secret, { algorithms: ['HS256'] });

Storing the token in localStorage. XSS can steal it. Use httpOnly cookies:

js
res.cookie('token', token, { httpOnly: true, secure: true, sameSite: 'strict' });

Weak secrets. 'shh' or 'secret' can be brute-forced offline if a token leaks. Generate a proper one:

bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"

No clock skew tolerance in distributed systems. Server clock drift rejects valid tokens. Add { clockTolerance: 30 } to verify options.

Real-World Usage

  • MERN stacks: passport-jwt strategy wraps the verify step in Passport's convention
  • Next.js: next-auth handles JWT sessions via OAuth providers
  • NestJS: @nestjs/jwt module with AuthGuard decorators
  • Supabase and Auth0: both issue JWTs you verify in Express using their public keys (RS256)
  • Strapi: built-in JWT plugin for headless CMS APIs

I've seen teams skip the algorithms option for years until a security audit caught it. It's one line and it matters.

Follow-up Questions

Q: Walk through the full login-to-protected-request flow.
A: Client POST /login with credentials. Server bcrypt.compare the hash. On success, jwt.sign({id: user.id, email}) returns a Bearer token. Client attaches it to every Authorization header. Middleware jwt.verify decodes it, attaches req.user, calls next().

Q: How do you handle token revocation with stateless JWT?
A: You can't revoke a stateless token directly. Short expiry (15m) limits the damage window. For hard revocation, embed a jti UUID in each token and check a Redis blacklist on every verify. On logout, write that jti to Redis with TTL equal to the token's remaining lifetime.

Q: What is the difference between HS256 and RS256?
A: HS256 is symmetric: the same secret signs and verifies. RS256 is asymmetric: a private key signs, a public key verifies. RS256 works better for microservices because other services can verify tokens without ever knowing the private key.

Q: What is the alg: none attack?
A: An attacker strips the signature and sets the algorithm header to none. A misconfigured server accepts the token as valid without checking any signature. Fix: always pass { algorithms: ['HS256'] } to jwt.verify.

Q: How do you implement refresh token rotation securely?
A: Issue a new refresh token on each use and blacklist the old one by its jti UUID in Redis. If the blacklisted token appears again, that signals a replay attack: invalidate all sessions for that user immediately. Store only the token hash in the database, not the raw value.

Examples

Basic Login and Protected Route

js
const express = require('express'); const jwt = require('jsonwebtoken'); const bcrypt = require('bcryptjs'); const app = express(); app.use(express.json()); const SECRET = process.env.JWT_SECRET; const users = [{ id: 1, email: 'alice@example.com', passwordHash: '$2a$10$...' }]; app.post('/auth/login', async (req, res) => { const { email, password } = req.body; const user = users.find(u => u.email === email); if (!user || !await bcrypt.compare(password, user.passwordHash)) { return res.status(401).json({ error: 'Invalid credentials' }); } // Token expires in 15 minutes const token = jwt.sign({ id: user.id, email }, SECRET, { expiresIn: '15m' }); res.json({ token }); }); function authenticate(req, res, next) { const header = req.headers.authorization; if (!header?.startsWith('Bearer ')) return res.status(401).json({ error: 'No token' }); try { // Explicit algorithm prevents alg:none attack req.user = jwt.verify(header.split(' ')[1], SECRET, { algorithms: ['HS256'] }); next(); } catch { res.status(401).json({ error: 'Invalid or expired token' }); } } app.get('/profile', authenticate, (req, res) => { // req.user = { id: 1, email: 'alice@example.com', iat: 1700000000, exp: 1700000900 } res.json({ user: req.user }); });

Credentials are checked with bcrypt, the token is signed with a 15-minute expiry, and authenticate uses the algorithms option. Any protected route just adds authenticate as middleware.

Role-Based Authorization

js
function authorize(...roles) { return (req, res, next) => { if (!roles.includes(req.user.role)) { return res.status(403).json({ error: 'Forbidden' }); } next(); }; } // Include role in the token payload at login const token = jwt.sign( { id: user.id, email: user.email, role: user.role }, // 'admin' | 'user' SECRET, { expiresIn: '15m' } ); // Only admins reach this handler app.delete('/users/:id', authenticate, authorize('admin'), async (req, res) => { await User.deleteById(req.params.id); res.status(204).send(); });

authorize runs after authenticate, so req.user is already populated. The role comes from the JWT payload, not a separate database query. That's fast. But if a user's role changes in the database, the old token still carries the old role until it expires. Short expiry times matter here too.

js
const REFRESH_SECRET = process.env.REFRESH_SECRET; app.post('/auth/refresh', (req, res) => { const refreshToken = req.cookies.refreshToken; if (!refreshToken) return res.status(401).json({ error: 'No refresh token' }); try { const decoded = jwt.verify(refreshToken, REFRESH_SECRET, { algorithms: ['HS256'] }); // New short-lived access token const accessToken = jwt.sign( { id: decoded.id, email: decoded.email }, SECRET, { expiresIn: '15m' } ); // Rotate: issue a new refresh token, old one is discarded const newRefresh = jwt.sign( { id: decoded.id, email: decoded.email }, REFRESH_SECRET, { expiresIn: '7d' } ); res.cookie('refreshToken', newRefresh, { httpOnly: true, // not readable by JavaScript secure: true, // HTTPS only sameSite: 'strict', maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days in ms }); res.json({ accessToken }); } catch { res.status(403).json({ error: 'Invalid refresh token' }); } });

Access token lives 15 minutes and goes in the response body. Refresh token lives 7 days in an httpOnly cookie. XSS can't read the cookie. CSRF can't use the access token because it's not in a cookie. Rotation on every refresh call limits the window if a refresh token is stolen.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?