Back to Blog
JWTAuthenticationSecurityNode.js

JWT Best Practices for Authentication

Implement JWTs securely. From token structure to refresh strategies to common vulnerabilities and mitigations.

B
Bootspring Team
Engineering
October 23, 2021
6 min read

JWTs enable stateless authentication. Here's how to implement them securely.

JWT Structure#

1// JWT has three parts: header.payload.signature 2 3// Header 4{ 5 "alg": "HS256", // Algorithm 6 "typ": "JWT" // Type 7} 8 9// Payload (Claims) 10{ 11 "sub": "user123", // Subject (user ID) 12 "iat": 1609459200, // Issued at 13 "exp": 1609545600, // Expiration 14 "iss": "myapp.com", // Issuer 15 "aud": "myapp.com", // Audience 16 "role": "user", // Custom claim 17 "permissions": ["read"] // Custom claim 18} 19 20// Signature 21HMACSHA256( 22 base64UrlEncode(header) + "." + base64UrlEncode(payload), 23 secret 24)

Token Generation#

1import jwt from 'jsonwebtoken'; 2 3interface TokenPayload { 4 userId: string; 5 email: string; 6 role: string; 7} 8 9const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET!; 10const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET!; 11 12function generateAccessToken(payload: TokenPayload): string { 13 return jwt.sign(payload, ACCESS_TOKEN_SECRET, { 14 expiresIn: '15m', // Short-lived 15 issuer: 'myapp.com', 16 audience: 'myapp.com', 17 }); 18} 19 20function generateRefreshToken(userId: string): string { 21 return jwt.sign( 22 { userId, tokenVersion: 1 }, 23 REFRESH_TOKEN_SECRET, 24 { 25 expiresIn: '7d', // Longer-lived 26 issuer: 'myapp.com', 27 } 28 ); 29} 30 31// Login endpoint 32app.post('/auth/login', async (req, res) => { 33 const { email, password } = req.body; 34 35 const user = await validateCredentials(email, password); 36 if (!user) { 37 return res.status(401).json({ error: 'Invalid credentials' }); 38 } 39 40 const accessToken = generateAccessToken({ 41 userId: user.id, 42 email: user.email, 43 role: user.role, 44 }); 45 46 const refreshToken = generateRefreshToken(user.id); 47 48 // Store refresh token hash in database 49 await db.refreshToken.create({ 50 data: { 51 userId: user.id, 52 tokenHash: hashToken(refreshToken), 53 expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), 54 }, 55 }); 56 57 // Send refresh token as httpOnly cookie 58 res.cookie('refreshToken', refreshToken, { 59 httpOnly: true, 60 secure: process.env.NODE_ENV === 'production', 61 sameSite: 'strict', 62 maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days 63 }); 64 65 res.json({ accessToken }); 66});

Token Verification#

1import jwt, { JwtPayload } from 'jsonwebtoken'; 2 3interface VerifiedPayload extends JwtPayload { 4 userId: string; 5 email: string; 6 role: string; 7} 8 9function verifyAccessToken(token: string): VerifiedPayload { 10 return jwt.verify(token, ACCESS_TOKEN_SECRET, { 11 issuer: 'myapp.com', 12 audience: 'myapp.com', 13 }) as VerifiedPayload; 14} 15 16// Middleware 17function authenticate(req: Request, res: Response, next: NextFunction) { 18 const authHeader = req.headers.authorization; 19 20 if (!authHeader?.startsWith('Bearer ')) { 21 return res.status(401).json({ error: 'Missing token' }); 22 } 23 24 const token = authHeader.slice(7); 25 26 try { 27 const payload = verifyAccessToken(token); 28 req.user = payload; 29 next(); 30 } catch (error) { 31 if (error instanceof jwt.TokenExpiredError) { 32 return res.status(401).json({ error: 'Token expired' }); 33 } 34 if (error instanceof jwt.JsonWebTokenError) { 35 return res.status(401).json({ error: 'Invalid token' }); 36 } 37 return res.status(401).json({ error: 'Authentication failed' }); 38 } 39}

Refresh Token Flow#

1// Refresh endpoint 2app.post('/auth/refresh', async (req, res) => { 3 const refreshToken = req.cookies.refreshToken; 4 5 if (!refreshToken) { 6 return res.status(401).json({ error: 'Refresh token required' }); 7 } 8 9 try { 10 const payload = jwt.verify(refreshToken, REFRESH_TOKEN_SECRET) as { 11 userId: string; 12 tokenVersion: number; 13 }; 14 15 // Verify token exists in database and hasn't been revoked 16 const storedToken = await db.refreshToken.findFirst({ 17 where: { 18 userId: payload.userId, 19 tokenHash: hashToken(refreshToken), 20 revokedAt: null, 21 expiresAt: { gt: new Date() }, 22 }, 23 }); 24 25 if (!storedToken) { 26 return res.status(401).json({ error: 'Invalid refresh token' }); 27 } 28 29 // Get user 30 const user = await db.user.findUnique({ 31 where: { id: payload.userId }, 32 }); 33 34 if (!user) { 35 return res.status(401).json({ error: 'User not found' }); 36 } 37 38 // Check token version (for forced logout) 39 if (user.tokenVersion !== payload.tokenVersion) { 40 return res.status(401).json({ error: 'Token revoked' }); 41 } 42 43 // Generate new tokens 44 const newAccessToken = generateAccessToken({ 45 userId: user.id, 46 email: user.email, 47 role: user.role, 48 }); 49 50 // Optionally rotate refresh token 51 const newRefreshToken = generateRefreshToken(user.id); 52 53 // Revoke old refresh token 54 await db.refreshToken.update({ 55 where: { id: storedToken.id }, 56 data: { revokedAt: new Date() }, 57 }); 58 59 // Store new refresh token 60 await db.refreshToken.create({ 61 data: { 62 userId: user.id, 63 tokenHash: hashToken(newRefreshToken), 64 expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), 65 }, 66 }); 67 68 res.cookie('refreshToken', newRefreshToken, { 69 httpOnly: true, 70 secure: process.env.NODE_ENV === 'production', 71 sameSite: 'strict', 72 maxAge: 7 * 24 * 60 * 60 * 1000, 73 }); 74 75 res.json({ accessToken: newAccessToken }); 76 } catch (error) { 77 return res.status(401).json({ error: 'Invalid refresh token' }); 78 } 79}); 80 81// Logout 82app.post('/auth/logout', async (req, res) => { 83 const refreshToken = req.cookies.refreshToken; 84 85 if (refreshToken) { 86 // Revoke refresh token 87 await db.refreshToken.updateMany({ 88 where: { tokenHash: hashToken(refreshToken) }, 89 data: { revokedAt: new Date() }, 90 }); 91 } 92 93 res.clearCookie('refreshToken'); 94 res.json({ message: 'Logged out' }); 95}); 96 97// Force logout all sessions 98async function revokeAllTokens(userId: string) { 99 // Increment token version 100 await db.user.update({ 101 where: { id: userId }, 102 data: { tokenVersion: { increment: 1 } }, 103 }); 104 105 // Revoke all refresh tokens 106 await db.refreshToken.updateMany({ 107 where: { userId, revokedAt: null }, 108 data: { revokedAt: new Date() }, 109 }); 110}

Client-Side Token Management#

1// Token storage and refresh 2class AuthService { 3 private accessToken: string | null = null; 4 private refreshPromise: Promise<string> | null = null; 5 6 async getAccessToken(): Promise<string | null> { 7 if (this.accessToken && !this.isTokenExpired(this.accessToken)) { 8 return this.accessToken; 9 } 10 11 return this.refreshAccessToken(); 12 } 13 14 private isTokenExpired(token: string): boolean { 15 try { 16 const payload = JSON.parse(atob(token.split('.')[1])); 17 // Refresh 1 minute before expiry 18 return payload.exp * 1000 < Date.now() + 60000; 19 } catch { 20 return true; 21 } 22 } 23 24 async refreshAccessToken(): Promise<string | null> { 25 // Deduplicate concurrent refresh requests 26 if (this.refreshPromise) { 27 return this.refreshPromise; 28 } 29 30 this.refreshPromise = (async () => { 31 try { 32 const response = await fetch('/auth/refresh', { 33 method: 'POST', 34 credentials: 'include', // Include cookies 35 }); 36 37 if (!response.ok) { 38 throw new Error('Refresh failed'); 39 } 40 41 const { accessToken } = await response.json(); 42 this.accessToken = accessToken; 43 return accessToken; 44 } catch (error) { 45 this.accessToken = null; 46 throw error; 47 } finally { 48 this.refreshPromise = null; 49 } 50 })(); 51 52 return this.refreshPromise; 53 } 54 55 async fetch(url: string, options: RequestInit = {}): Promise<Response> { 56 const token = await this.getAccessToken(); 57 58 const response = await fetch(url, { 59 ...options, 60 headers: { 61 ...options.headers, 62 Authorization: token ? `Bearer ${token}` : '', 63 }, 64 }); 65 66 if (response.status === 401) { 67 // Try refresh once 68 const newToken = await this.refreshAccessToken(); 69 70 if (newToken) { 71 return fetch(url, { 72 ...options, 73 headers: { 74 ...options.headers, 75 Authorization: `Bearer ${newToken}`, 76 }, 77 }); 78 } 79 } 80 81 return response; 82 } 83}

Security Considerations#

1// Use strong secrets 2// Generate with: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))" 3 4// Validate algorithm 5const payload = jwt.verify(token, secret, { 6 algorithms: ['HS256'], // Explicitly specify allowed algorithms 7}); 8 9// Never trust client-provided algorithm 10// Always verify signature server-side 11 12// Keep payloads minimal 13// Don't include sensitive data 14const payload = { 15 userId: user.id, 16 role: user.role, 17 // NOT: password, creditCard, etc. 18}; 19 20// Use short expiration for access tokens 21const accessToken = jwt.sign(payload, secret, { 22 expiresIn: '15m', // 15 minutes max 23}); 24 25// Implement token blocklist for immediate revocation 26const revokedTokens = new Set<string>(); 27 28function isTokenRevoked(token: string): boolean { 29 const jti = jwt.decode(token)?.jti; 30 return jti ? revokedTokens.has(jti) : false; 31}

Best Practices#

Token Security: ✓ Use strong, unique secrets ✓ Short access token expiry (15m) ✓ Store refresh tokens securely ✓ Implement token rotation Storage: ✓ httpOnly cookies for refresh tokens ✓ Memory for access tokens ✓ Never localStorage for tokens ✓ Use secure cookie flags Validation: ✓ Verify signature ✓ Check expiration ✓ Validate issuer and audience ✓ Use explicit algorithm Revocation: ✓ Implement token blocklist ✓ Store refresh tokens in DB ✓ Support forced logout ✓ Handle token rotation

Conclusion#

JWTs enable scalable authentication when implemented securely. Use short-lived access tokens with refresh token rotation, store tokens appropriately, and implement proper revocation. Never store sensitive data in tokens and always validate on the server side.

Share this article

Help spread the word about Bootspring