Introduction
Authentication is the gatekeeper of every application. Get it right and your users barely notice it exists—get it wrong and you're facing account takeovers, data breaches, and a loss of trust that takes years to rebuild. JSON Web Tokens (JWTs) have become the de facto standard for API authentication in Node.js applications, offering a stateless, scalable approach that works seamlessly across web frontends, mobile apps, and third-party integrations.
In this guide, we'll build a production-grade JWT authentication system in Express.js that handles the complete lifecycle: user registration with bcrypt password hashing, login with access token issuance, silent token refresh using refresh tokens, secure logout with token revocation, and role-based access control. We'll cover the security pitfalls that trip up even experienced developers—like storing tokens insecurely, failing to rotate refresh tokens, and neglecting token expiration strategies.
Understanding JWT: Core Concepts and Security Model
A JWT consists of three Base64URL-encoded parts separated by dots: the header, the payload, and the signature. The header specifies the algorithm (typically RS256 or HS256) and token type. The payload contains claims—standard claims like sub (subject), iat (issued at), and exp (expiration), plus custom claims for user roles and permissions. The signature ensures the token hasn't been tampered with; it's computed over the header and payload using a secret (HS256) or private key (RS256).
Access Tokens vs Refresh Tokens
The two-token pattern is critical for security. Access tokens are short-lived (15-60 minutes) and carry the user's identity and permissions. They're sent with every API request in the Authorization header. Refresh tokens are long-lived (7-30 days) and exist solely to obtain new access tokens. They're stored securely in HTTP-only cookies and sent only to a dedicated /auth/refresh endpoint.
This separation means that if an access token is intercepted (through XSS, network sniffing, or a compromised client), the attacker has a limited window before it expires. The refresh token, stored in an HTTP-only cookie inaccessible to JavaScript, remains protected even if the page has an XSS vulnerability.
Why RS256 Over HS256
HS256 uses a shared secret for both signing and verification—every server that needs to verify tokens must have the secret. RS256 uses a private key for signing and a public key for verification. This means only the authentication server holds the private key, while any microservice can verify tokens using only the public key. In a microservices architecture, this dramatically reduces the blast radius if a service is compromised.
Architecture and Design Patterns
Token Lifecycle
The authentication flow follows a well-defined lifecycle that balances security with user experience. On registration, the password is hashed with bcrypt (cost factor 12) and stored. On login, credentials are verified, and a pair of tokens is issued: a short-lived access token and a long-lived refresh token. The access token is returned in the response body for the client to store in memory. The refresh token is set as an HTTP-only, secure, SameSite=Strict cookie.
On each API request, the client includes the access token in the Authorization: Bearer <token> header. The auth middleware decodes and verifies the token, attaching the user context to the request. When the access token expires, the client calls /auth/refresh which automatically sends the refresh cookie. The server validates the refresh token against a database of valid tokens, issues a new access token, and rotates the refresh token (invalidating the old one).
Database Schema for Token Management
Refresh tokens must be stored in the database to enable revocation. The schema tracks which tokens are valid, when they expire, and which device they belong to:
CREATE TABLE refresh_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
device_info JSONB,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
revoked_at TIMESTAMP,
replaced_by UUID REFERENCES refresh_tokens(id)
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);Storing the hash of the refresh token (not the token itself) follows the same principle as password storage—if the database is compromised, the actual tokens aren't exposed.
Step-by-Step Implementation
Core Authentication Service
// src/services/auth.service.ts
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { db } from '../database';
import { AppError } from '../middleware/error-handler';
const ACCESS_TOKEN_TTL = '15m';
const REFRESH_TOKEN_TTL_DAYS = 30;
const BCRYPT_ROUNDS = 12;
// Generate RSA key pair for RS256 (run once, store securely)
const PRIVATE_KEY = process.env.JWT_PRIVATE_KEY!;
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;
export class AuthService {
async register(email: string, password: string, name: string) {
const existing = await db.users.findOne({ where: { email } });
if (existing) {
throw AppError.conflict('Email already registered');
}
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS);
const user = await db.users.create({
email,
name,
password_hash: passwordHash,
});
return this.generateTokenPair(user);
}
async login(email: string, password: string, deviceInfo?: object) {
const user = await db.users.findOne({ where: { email } });
if (!user) {
throw AppError.unauthorized('Invalid credentials');
}
const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) {
throw AppError.unauthorized('Invalid credentials');
}
return this.generateTokenPair(user, deviceInfo);
}
async refresh(refreshToken: string) {
const tokenHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
const stored = await db.refreshTokens.findOne({
where: { token_hash: tokenHash, revoked_at: null },
});
if (!stored || new Date(stored.expires_at) < new Date()) {
throw AppError.unauthorized('Invalid refresh token');
}
// Revoke the used refresh token (rotation)
await db.refreshTokens.update(stored.id, {
revoked_at: new Date(),
});
const user = await db.users.findById(stored.user_id);
if (!user) {
throw AppError.unauthorized('User not found');
}
// Generate new token pair
const tokens = await this.generateTokenPair(user, stored.device_info);
// Link new token to old one for token family detection
await db.refreshTokens.update(tokens.refreshTokenId, {
replaced_by: stored.id,
});
return tokens;
}
async logout(userId: string, refreshTokenId?: string) {
if (refreshTokenId) {
// Revoke specific token (single device logout)
await db.refreshTokens.update(refreshTokenId, {
revoked_at: new Date(),
});
} else {
// Revoke all tokens (logout everywhere)
await db.refreshTokens.updateMany(
{ user_id: userId, revoked_at: null },
{ revoked_at: new Date() }
);
}
}
private async generateTokenPair(user: any, deviceInfo?: object) {
const accessToken = jwt.sign(
{
sub: user.id,
email: user.email,
roles: user.roles,
},
PRIVATE_KEY,
{
algorithm: 'RS256',
expiresIn: ACCESS_TOKEN_TTL,
issuer: 'myapp-auth',
audience: 'myapp-api',
}
);
const refreshToken = crypto.randomBytes(64).toString('hex');
const tokenHash = crypto
.createHash('sha256')
.update(refreshToken)
.digest('hex');
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + REFRESH_TOKEN_TTL_DAYS);
const storedToken = await db.refreshTokens.create({
user_id: user.id,
token_hash: tokenHash,
device_info: deviceInfo || {},
expires_at: expiresAt,
});
return {
accessToken,
refreshToken,
refreshTokenId: storedToken.id,
expiresIn: 900, // 15 minutes in seconds
};
}
}Authentication Middleware
// src/middleware/auth.ts
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { AppError } from './error-handler';
const PUBLIC_KEY = process.env.JWT_PUBLIC_KEY!;
export interface AuthRequest extends Request {
user?: {
id: string;
email: string;
roles: string[];
};
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
throw AppError.unauthorized('Missing access token');
}
const token = authHeader.slice(7);
try {
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: 'myapp-auth',
audience: 'myapp-api',
}) as jwt.JwtPayload;
req.user = {
id: payload.sub!,
email: payload.email,
roles: payload.roles,
};
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
throw AppError.unauthorized('Access token expired');
}
throw AppError.unauthorized('Invalid access token');
}
}
export function authorize(...requiredRoles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) {
throw AppError.unauthorized('Authentication required');
}
const hasRole = requiredRoles.some(role => req.user!.roles.includes(role));
if (!hasRole) {
throw AppError.forbidden('Insufficient permissions');
}
next();
};
}Route Handlers
// src/routes/auth.ts
import { Router } from 'express';
import { AuthService } from '../services/auth.service';
import { authenticate, AuthRequest } from '../middleware/auth';
import { AppError } from '../middleware/error-handler';
const router = Router();
const authService = new AuthService();
router.post('/register', async (req, res) => {
const { email, password, name } = req.body;
if (!email || !password || !name) {
throw AppError.validation('Email, password, and name are required');
}
if (password.length < 8) {
throw AppError.validation('Password must be at least 8 characters');
}
const tokens = await authService.register(email, password, name);
res
.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
path: '/api/auth',
})
.status(201)
.json({
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
});
});
router.post('/login', async (req, res) => {
const { email, password } = req.body;
const deviceInfo = {
userAgent: req.headers['user-agent'],
ip: req.ip,
};
const tokens = await authService.login(email, password, deviceInfo);
res
.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/api/auth',
})
.json({
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
});
});
router.post('/refresh', async (req, res) => {
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) {
throw AppError.unauthorized('No refresh token provided');
}
const tokens = await authService.refresh(refreshToken);
res
.cookie('refreshToken', tokens.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000,
path: '/api/auth',
})
.json({
accessToken: tokens.accessToken,
expiresIn: tokens.expiresIn,
});
});
router.post('/logout', authenticate, async (req: AuthRequest, res) => {
await authService.logout(req.user!.id);
res
.clearCookie('refreshToken', { path: '/api/auth' })
.json({ message: 'Logged out successfully' });
});
export default router;Real-World Use Cases and Case Studies
Use Case 1: Multi-Device Session Management
A social media app needed to track active sessions per device. Each login created a new refresh token with device metadata (browser, OS, location). The user could view active sessions in settings and revoke individual devices. When a suspicious login was detected (new location, unusual time), the app required re-authentication before issuing tokens. This approach caught 94% of account takeover attempts before any damage occurred.
Use Case 2: Microservice Authentication
An e-commerce platform split into 12 microservices used RS256 JWTs for inter-service authentication. The auth service held the private key; all other services had only the public key. Each service verified the token signature locally without network calls to the auth service. Custom claims like tenant_id and permissions were embedded in the token, allowing services to make authorization decisions without database lookups. This reduced authentication latency from 50ms (centralized verification) to 2ms (local verification).
Use Case 3: Mobile App with Biometric Auth
A banking app combined JWT authentication with device-level biometric locks. The refresh token was stored in the device's secure enclave (Keychain on iOS, Keystore on Android), accessible only after Face ID or fingerprint verification. Access tokens were kept in memory and never persisted to disk. If the user switched biometric enrollment, all refresh tokens were automatically revoked, requiring full re-authentication.
Use Case 4: Third-Party API Integration
A SaaS platform issued JWTs to third-party integrations using a custom grant type. Integration tokens had a specific aud (audience) claim limiting which API endpoints they could access. Token lifetimes were configurable per integration (1 hour to 90 days). A revocation webhook notified integrations when their tokens were invalidated, giving them time to request new ones before their service was interrupted.
Best Practices for Production
-
Use RS256 instead of HS256 for microservices: RS256's public/private key separation means only the auth server holds the secret. Any compromised service can only verify tokens, not forge them. Generate keys with
openssl genrsa -out private.pem 2048and extract the public key withopenssl rsa -in private.pem -pubout -out public.pem. -
Implement refresh token rotation: Each time a refresh token is used, revoke it and issue a new one. If a revoked refresh token is reused (indicating theft), revoke the entire token family—all refresh tokens descended from the original. This detects and contains token theft automatically.
-
Store refresh tokens as hashes: Never store refresh tokens in plaintext in your database. Hash them with SHA-256 before storage, just like passwords. This way, a database breach doesn't immediately compromise all active sessions.
-
Set appropriate token lifetimes: Access tokens should expire in 15-60 minutes. Refresh tokens should expire in 7-30 days depending on your security requirements. High-security applications (banking, healthcare) should use shorter refresh token lifetimes with re-authentication requirements.
-
Use HTTP-only cookies for refresh tokens: Setting the
httpOnly,secure, andsameSiteattributes on the refresh token cookie prevents JavaScript access (blocking XSS token theft), ensures transmission only over HTTPS, and blocks cross-site request forgery. -
Implement token family detection: Track the lineage of refresh tokens. If a refresh token from a family is used after another token in the same family was already used, the entire family is compromised. Revoke all tokens in the family and force re-authentication.
-
Include minimal claims in access tokens: Only include what downstream services need to make authorization decisions (user ID, roles, tenant ID). Never include sensitive data like email addresses, phone numbers, or internal system identifiers that could be useful if the token is decoded.
-
Add rate limiting to authentication endpoints: Limit login attempts to 5 per email per 15 minutes and refresh requests to 10 per minute per user. Use exponential backoff for repeated failures from the same IP address.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Storing JWTs in localStorage | Vulnerable to XSS attacks—any injected script can steal tokens | Store access tokens in memory (JavaScript variable), refresh tokens in HTTP-only cookies |
| Not rotating refresh tokens | A stolen refresh token grants indefinite access | Issue a new refresh token on every use, revoke the old one, detect token family reuse |
| Using HS256 in microservices | Every service needs the shared secret, expanding the blast radius | Use RS256 with private key on auth server, public key distributed to all services |
| No token revocation mechanism | Users can't log out, compromised tokens remain valid | Store refresh token hashes in DB with revocation support; for immediate access token revocation, use a short-lived token blacklist in Redis |
| Including sensitive data in JWT payload | JWT payload is base64-encoded (readable), not encrypted | Only include non-sensitive authorization data; use JWE if you must include sensitive claims |
| Skipping token audience and issuer validation | Tokens from other applications or environments could be accepted | Always validate iss and aud claims match your application's expected values |
Performance Optimization
JWT verification is CPU-intensive due to RSA cryptographic operations. In high-throughput applications, cache verification results:
import NodeCache from 'node-cache';
import crypto from 'crypto';
const verificationCache = new NodeCache({ stdTTL: 60, checkperiod: 120 });
function cachedVerify(token: string): jwt.JwtPayload {
const tokenHash = crypto.createHash('sha256').update(token).digest('hex');
const cached = verificationCache.get<jwt.JwtPayload>(tokenHash);
if (cached) return cached;
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
issuer: 'myapp-auth',
audience: 'myapp-api',
}) as jwt.JwtPayload;
// Cache until token expires (but max 60 seconds for security)
const ttl = Math.min(payload.exp! - Math.floor(Date.now() / 1000), 60);
if (ttl > 0) {
verificationCache.set(tokenHash, payload, ttl);
}
return payload;
}For extremely high throughput (10,000+ requests/second), consider using EdDSA (Ed25519) instead of RSA. Ed25519 signatures are faster to verify and produce smaller tokens. Libraries like jose support Ed25519 natively. Additionally, use connection pooling for your database when validating refresh tokens, and consider Redis-backed token blacklisting instead of database queries for immediate revocation checks.
Comparison with Alternatives
| Feature | JWT | Session Cookies | OAuth 2.0 | API Keys |
|---|---|---|---|---|
| Statelessness | Fully stateless | Stateful (server-side store) | Depends on grant type | Stateless |
| Scalability | Excellent (no server state) | Requires shared session store | Good with proper design | Excellent |
| Revocation | Complex (needs blacklist or short TTL) | Easy (delete session) | Built-in (token revocation endpoint) | Easy (delete key) |
| Cross-domain | Works everywhere | Domain-restricted | Designed for cross-domain | Works everywhere |
| Mobile support | Native support | Limited | Native support | Native support |
| Best for | API authentication, microservices | Traditional web apps | Third-party authorization | Machine-to-machine |
JWT shines for API authentication where statelessness and cross-service token verification are priorities. Session cookies remain excellent for traditional server-rendered applications where the server controls both rendering and authentication. OAuth 2.0 is the right choice when you're delegating authorization to third parties.
Advanced Patterns and Techniques
Token Introspection Endpoint
For services that need to validate tokens without the signing key (legacy systems, third-party integrations), implement an introspection endpoint:
router.post('/introspect', async (req, res) => {
const { token } = req.body;
try {
const payload = jwt.verify(token, PUBLIC_KEY, {
algorithms: ['RS256'],
}) as jwt.JwtPayload;
res.json({
active: true,
sub: payload.sub,
exp: payload.exp,
roles: payload.roles,
});
} catch {
res.json({ active: false });
}
});Scoped Tokens for Fine-Grained Access
Issue tokens with specific scopes for limited operations:
function generateScopedToken(userId: string, scopes: string[], ttl: string) {
return jwt.sign(
{ sub: userId, scopes },
PRIVATE_KEY,
{ algorithm: 'RS256', expiresIn: ttl }
);
}
// Usage: generate a token that can only read user data
const readOnlyToken = generateScopedToken(userId, ['users:read'], '1h');
// Middleware to check scopes
function requireScope(scope: string) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user?.scopes?.includes(scope)) {
throw AppError.forbidden(`Missing required scope: ${scope}`);
}
next();
};
}Testing Strategies
import request from 'supertest';
import app from '../src/app';
describe('Authentication', () => {
let refreshToken: string;
describe('POST /api/auth/register', () => {
it('returns access token and sets refresh cookie', async () => {
const res = await request(app)
.post('/api/auth/register')
.send({
email: 'test@example.com',
password: 'SecureP@ss123',
name: 'Test User',
})
.expect(201);
expect(res.body.accessToken).toBeDefined();
expect(res.body.expiresIn).toBe(900);
const cookies = res.headers['set-cookie'];
const refreshCookie = cookies.find((c: string) =>
c.startsWith('refreshToken=')
);
expect(refreshCookie).toContain('HttpOnly');
expect(refreshCookie).toContain('Secure');
expect(refreshCookie).toContain('SameSite=Strict');
});
});
describe('POST /api/auth/refresh', () => {
it('issues new tokens and rotates refresh token', async () => {
// First, get tokens by logging in
const loginRes = await request(app)
.post('/api/auth/login')
.send({ email: 'test@example.com', password: 'SecureP@ss123' });
const cookies = loginRes.headers['set-cookie'];
// Use refresh token
const refreshRes = await request(app)
.post('/api/auth/refresh')
.set('Cookie', cookies)
.expect(200);
expect(refreshRes.body.accessToken).toBeDefined();
expect(refreshRes.body.accessToken).not.toBe(loginRes.body.accessToken);
// Old refresh token should be revoked
const oldRefreshRes = await request(app)
.post('/api/auth/refresh')
.set('Cookie', cookies);
expect(oldRefreshRes.status).toBe(401);
});
});
});Future Outlook
Passkeys (WebAuthn/FIDO2) are rapidly gaining adoption and may eventually replace JWT-based password authentication entirely. Passkeys use public-key cryptography at the device level, eliminating passwords and phishing attacks simultaneously. Major browsers and platforms now support passkeys natively.
For applications that still need token-based authentication, the IETF's DPoP (Demonstrating Proof-of-Possession) specification adds sender-constraint to tokens, preventing token theft and replay attacks. DPoP binds tokens to a specific client key pair, making stolen tokens useless without the corresponding private key.
OAuth 2.1 consolidates the best practices from OAuth 2.0 into a single specification, deprecating the implicit grant and requiring PKCE for all grant types. Applications building new authentication systems should target OAuth 2.1 compliance from the start.
Conclusion
Building secure JWT authentication in Node.js requires far more than signing a token and sending it to the client. Production-grade authentication demands access/refresh token separation, refresh token rotation with family detection, secure storage in HTTP-only cookies, RS256 for microservice architectures, and comprehensive revocation mechanisms.
Key takeaways:
- Always use the two-token pattern: short-lived access tokens in memory, long-lived refresh tokens in HTTP-only cookies
- Implement refresh token rotation with token family detection to catch and contain token theft
- Use RS256 with key pair separation so only the auth server can issue tokens
- Hash refresh tokens before database storage and support immediate revocation
- Validate
iss,aud, andexpclaims on every token verification - Rate-limit authentication endpoints to prevent brute-force attacks
- Plan for the future by implementing passkey support alongside JWT authentication
Security is not a feature you add at the end—it's a fundamental architectural decision that shapes every endpoint, every middleware, and every response in your API. Invest in getting it right from the start, and your users will trust you with their data.
For further study, explore the OWASP Authentication Cheat Sheet, the JWT RFC 7519, and the Auth0 JWT Handbook for advanced implementation patterns.