Introduction
JSON Web Tokens (JWT) have become the de‑facto standard for stateless authentication in modern web services. Their compact, URL‑safe format makes them ideal for mobile apps, SPAs, and micro‑service ecosystems. However, the convenience of JWT can quickly turn into a security liability if the token lifecycle, storage, and validation strategies are not thoughtfully engineered.
In this article we discuss best‑practice design principles for a JWT authentication system, present a clear architecture diagram, and walk through a production‑grade implementation using Node.js and Express. By the end of the guide you will understand how to mitigate common attacks, manage token revocation, and integrate refresh‑token flows without sacrificing performance.
Pro tip: Treat JWT as a signed claim rather than a magic key. The server must always validate the signature, expiration, audience, and issuer before trusting any claim.
Understanding JWT Fundamentals
What Is a JWT?
A JSON Web Token is a three‑part string separated by periods: header.payload.signature. The header declares the signing algorithm, the payload carries claims (e.g., sub, iat, exp), and the signature is computed using a secret (HMAC) or private key (RSA/ECDSA).
Structure of a JWT
{ "header": { "alg": "HS256", "typ": "JWT" }, "payload": { "sub": "1234567890", "name": "Jane Doe", "iat": 1700000000, "exp": 1700086400, "aud": "my-api", "iss": "auth.mycompany.com" }, "signature": "<base64url‑encoded>" }
The payload is not encrypted; it is only base64url‑encoded.
Common Pitfalls
- Storing tokens in localStorage - susceptible to XSS. Prefer httpOnly cookies when possible.
- Missing
expclaim - leads to perpetual validity. - Using weak signing keys - a 256‑bit secret is the minimum for HS256.
- Ignoring audience (
aud) and issuer (iss) validation - opens token‑substitution attacks.
Understanding these fundamentals sets the stage for a resilient design.
Designing a Secure JWT Authentication System
Threat Model
| Threat | Impact | Mitigation |
|---|---|---|
| Token theft (XSS/CSRF) | Session hijack | httpOnly, SameSite cookies + CSRF double‑submit token |
| Replay attacks | Reuse of a valid token | Short exp, rotating refresh tokens |
| Key compromise | Forged tokens | Rotate signing keys, use key identifier (kid) |
| Token leakage via logs | Credential exposure | Never log the raw token; mask before persisting |
Best Practices Checklist
- Use asymmetric keys (RS256/ECDSA) for services that share verification logic across micro‑services.
- Enforce a short access‑token lifespan (5‑15 minutes) and a separate refresh‑token flow.
- Store refresh tokens securely (httpOnly, Secure, SameSite=strict) and bind them to a device fingerprint.
- Implement token revocation using a server‑side deny‑list or rotating refresh tokens.
- Validate all standard claims (
exp,nbf,iat,aud,iss). - Apply rate limiting on token endpoints to mitigate credential‑stuffing attacks.
Token Lifetime Strategy
- Access Token - 5‑15 minutes, signed with private key, sent in
Authorization: Bearerheader. - Refresh Token - 7‑30 days, stored in an httpOnly cookie, used to obtain a new access token via
/auth/refresh. - Rotation - When a refresh token is exchanged, the old token is revoked and a new pair is issued.
By separating short‑lived access tokens from long‑lived refresh tokens, you limit the damage window if a token is compromised while still delivering a smooth user experience.
Implementation Guide with Code Samples
Below is a minimal yet production‑ready implementation using Node.js, Express, and the jsonwebtoken library.
1. Project Setup
bash npm init -y npm install express jsonwebtoken dotenv cookie-parser uuid
Create a .env file:
JWT_PRIVATE_KEY=-----BEGIN RSA PRIVATE KEY----- ...your RSA private key... -----END RSA PRIVATE KEY----- JWT_PUBLIC_KEY=-----BEGIN PUBLIC KEY----- ...your RSA public key... -----END PUBLIC KEY----- ACCESS_TOKEN_TTL=10m REFRESH_TOKEN_TTL=14d
2. Token Generation
// utils/token.js
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
require('dotenv').config();
const privateKey = process.env.JWT_PRIVATE_KEY; const publicKey = process.env.JWT_PUBLIC_KEY;
function signAccessToken(userId) { return jwt.sign( { sub: userId, jti: uuidv4() }, privateKey, { algorithm: 'RS256', expiresIn: process.env.ACCESS_TOKEN_TTL, audience: 'my-api', issuer: 'auth.mycompany.com' } ); }
function signRefreshToken(userId) { return jwt.sign( { sub: userId, jti: uuidv4() }, privateKey, { algorithm: 'RS256', expiresIn: process.env.REFRESH_TOKEN_TTL } ); }
module.exports = { signAccessToken, signRefreshToken, publicKey };
3. Middleware for Verification
// middleware/auth.js
const jwt = require('jsonwebtoken');
const { publicKey } = require('../utils/token');
function authenticate(req, res, next) { const authHeader = req.headers['authorization']; const token = authHeader && authHeader.split(' ')[1]; if (!token) return res.status(401).json({ error: 'Missing token' });
jwt.verify(token, publicKey, { algorithms: ['RS256'], audience: 'my-api', issuer: 'auth.mycompany.com' }, (err, payload) => { if (err) return res.status(403).json({ error: 'Invalid or expired token' }); req.user = { id: payload.sub, jti: payload.jti }; next(); }); }
module.exports = { authenticate };
4. Login & Refresh Endpoints
// routes/auth.js
const express = require('express');
const router = express.Router();
const { signAccessToken, signRefreshToken } = require('../utils/token');
const cookieParser = require('cookie-parser');
router.use(cookieParser());
// Mock user DB const users = [{ id: '1', username: 'alice', password: 'p@ssw0rd' }];
router.post('/login', (req, res) => { const { username, password } = req.body; const user = users.find(u => u.username === username && u.password === password); if (!user) return res.status(401).json({ error: 'Invalid credentials' });
const accessToken = signAccessToken(user.id); const refreshToken = signRefreshToken(user.id);
// Store refresh token in httpOnly cookie res.cookie('refreshToken', refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 14 * 24 * 60 * 60 * 1000 // 14 days });
res.json({ accessToken }); });
router.post('/refresh', (req, res) => { const token = req.cookies.refreshToken; if (!token) return res.status(401).json({ error: 'Refresh token missing' }); const { verify } = require('jsonwebtoken'); verify(token, publicKey, { algorithms: ['RS256'] }, (err, payload) => { if (err) return res.status(403).json({ error: 'Invalid refresh token' }); const newAccess = signAccessToken(payload.sub); const newRefresh = signRefreshToken(payload.sub); // Rotate cookie res.cookie('refreshToken', newRefresh, { httpOnly: true, secure: true, sameSite: 'strict', maxAge: 14 * 24 * 60 * 60 * 1000 }); res.json({ accessToken: newAccess }); }); });
module.exports = router;
5. Protecting Resources
// routes/profile.js
const express = require('express');
const router = express.Router();
const { authenticate } = require('../middleware/auth');
router.get('/', authenticate, (req, res) => {
// In a real app, fetch user data from DB using req.user.id
res.json({ message: Hello user ${req.user.id} });
});
module.exports = router;
6. Application Bootstrap
// app.js
const express = require('express');
const app = express();
const authRoutes = require('./routes/auth');
const profileRoutes = require('./routes/profile');
app.use(express.json()); app.use('/auth', authRoutes); app.use('/profile', profileRoutes);
app.listen(3000, () => console.log('Server running on http://localhost:3000'));
The code demonstrates signing, verification, cookie‑based refresh, and middleware protection. Adjust key rotation, logging, and error handling to match your production standards.
Architecture Overview
Below is a high‑level diagram that captures the interaction between client, API gateway, authentication service, and downstream micro‑services. The design isolates token issuance from business logic, enabling horizontal scaling and independent key management.
mermaid graph TD A[Client (SPA / Mobile)] -->|Login Request| B[API Gateway] B -->|Forward| C[Auth Service] C -->|Issue Access+Refresh Tokens| A A -->|Bearer Token| B B -->|Validate JWT| C B -->|Forward Authenticated Request| D[Business Service] D -->|Return Data| B B -->|Response| A A -->|Refresh Request (httpOnly cookie)| B B -->|Validate Refresh Token| C C -->|Rotate Tokens| A
Key components:
- API Gateway - performs initial JWT verification, rate limiting, and routing.
- Auth Service - central authority that signs tokens with an asymmetric key pair; manages refresh‑token rotation and revocation lists.
- Business Services - trust the gateway’s verification and only process the
subclaim. - Key Management - private keys reside in a vault (e.g., AWS KMS, HashiCorp Vault). Public keys are exposed via a JWKS endpoint for automatic client verification.
Why this architecture?
- Stateless access - No session store needed for access tokens, improving latency.
- Separation of concerns - Authentication logic is isolated, simplifying audits.
- Scalable revocation - Refresh‑token rotation and a short access‑token timeout keep the attack surface minimal.
- Zero‑trust inter‑service communication - Each service validates the JWT, preventing token substitution across domains.
FAQs
1️⃣ How do I invalidate an access token before its exp expires?
Access tokens are intentionally short‑lived, but you can maintain a deny‑list (e.g., a Redis set) keyed by the token's jti. Middleware checks this list after signature verification. When a user logs out or a breach is detected, push the jti into the deny‑list. Because the deny‑list lives in memory, lookup overhead remains negligible.
2️⃣ Should I store JWTs in localStorage, sessionStorage, or cookies?
Avoid localStorage and sessionStorage for tokens that grant privileged access-both are accessible to JavaScript and vulnerable to XSS. Prefer httpOnly, Secure, SameSite=Strict cookies for refresh tokens and transmit access tokens via the Authorization: Bearer header. If you must store an access token client‑side, encrypt it with a short‑lived key derived from a user‑specific secret.
3️⃣ What is the advantage of asymmetric signing (RS256/ECDSA) over symmetric (HS256)?
With asymmetric keys, only the auth service holds the private key used for signing. All other services verify using the public key, which can be safely distributed. This eliminates the risk of accidental key leakage across micro‑services and enables seamless key rotation: publish a new kid in the JWKS endpoint while continuing to accept older tokens until they expire.
4️⃣ How often should I rotate signing keys?
A common practice is every 30‑90 days for high‑value applications. Rotate by:
- Generating a new key pair and assigning a new
kid. - Publishing the new public key via JWKS.
- Updating the auth service to sign with the new private key while still accepting tokens signed with the previous key until they naturally expire.
5️⃣ Can JWT be used for authorization as well as authentication?
Yes, JWT can carry scopes or roles (e.g., scope: "read:orders write:profile"). However, keep claims minimal. Complex authorization decisions should be performed by the resource service, possibly by consulting a policy engine (OPA) that reads the token’s sub and looks up current permissions.
Conclusion
Designing a JWT authentication system that balances security, scalability, and developer ergonomics requires disciplined adherence to best practices. By employing short‑lived access tokens, rotating refresh tokens, asymmetric signing, and a clear separation between the auth service and business logic, you create a resilient perimeter that withstands token theft, replay, and key‑compromise scenarios.
The implementation example provided demonstrates a production‑ready flow in Node.js / Express, while the architecture diagram illustrates how the components fit into a modern micro‑service landscape. Remember to integrate monitoring, rate‑limiting, and regular key rotation into your operational playbook.
When applied correctly, JWT becomes a powerful tool-not a silver bullet-for stateless, high‑performance authentication across web, mobile, and API‑first applications.
