How to Implement Authentication with JWT in Node.js | ZextOverse
How to Implement Authentication with JWT in Node.js
Stateless, scalable, and — when done right — secure. JWT authentication is the backbone of modern API design. Here's everything you need to build it properly.
JSON Web Tokens (JWT) are a compact, URL-safe way to represent claims between two parties. Rather than storing session data server-side and issuing a session ID cookie, JWTs encode the session state directly into a signed token that travels with every request.
The flow is elegantly simple:
User logs in with credentials
Server validates credentials and issues a signed JWT
Client stores the token and sends it with every subsequent request
Server verifies the token's signature — no database lookup required
This statelessness is what makes JWTs so appealing in distributed systems, microservices, and horizontally-scaled APIs. There's no shared session store to synchronize across instances.
That said, JWTs aren't a silver bullet. They come with trade-offs — especially around token revocation — that you need to understand before shipping to production. This guide covers both the implementation and the nuance.
Anatomy of a JWT
A JWT is three Base64URL-encoded strings joined by dots:
Declares the token type and signing algorithm. HS256 is HMAC-SHA256 (symmetric). RS256 is RSA-SHA256 (asymmetric) — preferred for production when multiple services need to verify tokens.
JWT ID — unique token identifier (useful for revocation)
Share this article:
⚠️ The payload is NOT encrypted — it's only encoded. Anyone with the token can read its contents. Never put passwords, SSNs, or sensitive PII in JWT claims.
const { verifyToken, requireRole } = require('./middleware/auth.middleware');
// Only admins can access this route
app.delete('/api/users/:id', verifyToken, requireRole('admin'), deleteUser);
// Both users and admins can access this
app.get('/api/posts', verifyToken, requireRole('user', 'admin'), getPosts);
Risk: Vulnerable to XSS (Cross-Site Scripting). If an attacker injects malicious JavaScript into your page, they can steal tokens from localStorage.
Option B: httpOnly Cookie
// Server sets the cookie — JS cannot access it
res.cookie('accessToken', token, {
httpOnly: true, // Not accessible via document.cookie
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 3600000, // 1 hour in ms
});
Risk: Cookies are automatically sent with requests, which creates exposure to CSRF (Cross-Site Request Forgery). Mitigate with sameSite: 'strict' and CSRF tokens.
The Recommendation
For most web applications: httpOnly cookies for the access token, with sameSite: 'strict' and a CSRF token pattern. This makes XSS attacks unable to steal the token, which is the more common threat vector.
For mobile apps and pure API clients: store in secure storage (iOS Keychain, Android Keystore), not in local storage equivalents.
The Token Revocation Problem
This is JWT's most significant weakness. Because the server doesn't store session state, there's no built-in way to invalidate a token before it expires.
If a user logs out, changes their password, or is banned — their old access token remains valid until exp.
Mitigation Strategies
Short-lived access tokens + long-lived refresh tokens is the canonical approach. Keep access tokens to 15 minutes. When they expire, the client uses the refresh token to get a new one. Logout invalidates the refresh token (stored server-side in a database or Redis).
Access Token: expires in 15 minutes (stateless, not stored)
Refresh Token: expires in 7 days (stored in DB, can be revoked)
Token blocklist (denylist) for immediate revocation. When a user logs out or is compromised, add their jti (JWT ID) to a Redis set with a TTL matching the token's remaining lifetime.
// On logout — add jti to blocklist
await redis.setEx(`blocklist:${decoded.jti}`, tokenRemainingTtl, '1');
// In verifyToken middleware — check blocklist
const isBlocked = await redis.get(`blocklist:${decoded.jti}`);
if (isBlocked) {
return res.status(401).json({ error: 'Token has been revoked' });
}
Version-based invalidation: Store a tokenVersion integer on the user record. Embed it in the JWT payload. On logout or password change, increment the version. The middleware rejects tokens with outdated versions.
Production Security Checklist
Before going live, verify each of these:
Use strong, random secrets (min 32 bytes, generated with crypto.randomBytes)
Store secrets in environment variables, never hardcoded
Use RS256 in multi-service architectures — private key signs, public key verifies
Set short access token expiry (15 min–1 hour max)
Implement refresh token rotation — issue a new refresh token on each use and invalidate the old one
Store refresh tokens in a database so they can be revoked
Use httpOnly, secure, sameSite cookies for browser clients
Never put sensitive data in the payload — it's only encoded, not encrypted
Validate all incoming claims — check exp, iat, iss, aud as appropriate
Rate-limit your /login endpoint to prevent brute-force attacks
Log auth events (logins, failures, refreshes) for anomaly detection
Use HTTPS everywhere — JWTs in transit must be encrypted
Upgrading to RS256 (Asymmetric Signing)
In microservice architectures, multiple services may need to verify tokens without having access to the signing secret. RS256 solves this elegantly:
The auth service holds the private key and signs tokens
Every other service holds only the public key and can verify tokens
Compromise of a downstream service doesn't expose the signing key
const crypto = require('crypto');
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
});
// Sign with private key
const token = jwt.sign(payload, privateKey, { algorithm: 'RS256', expiresIn: '1h' });
// Verify with public key (safe to distribute to other services)
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });
In production, load keys from environment variables or a secrets manager (AWS Secrets Manager, HashiCorp Vault):
JWT authentication is a powerful pattern — but its simplicity on the surface conceals real complexity underneath. The happy path (sign a token, verify a token) takes 20 lines of code. The production-ready path requires careful thought about token storage, revocation strategies, secret management, and the security model of your specific architecture.
The implementation in this guide gives you a solid foundation:
Stateless access tokens for scalable, zero-database-lookup verification
Refresh token rotation for session continuity without security compromise
Role-based middleware for clean, composable authorization
Bcrypt password hashing for credential security at rest
A clear upgrade path to RS256 when you're ready to scale across services
Authentication is not the place to cut corners. Build it deliberately, understand the trade-offs, and keep your secrets actually secret.