JWT Authentication with TypeScript — Secure Implementation 2026
Advertisement
Introduction
Why JWT — and When Not to Use It
JWT (JSON Web Tokens) enable stateless authentication: the server issues a signed token; clients present it on every request; no server-side session lookup is needed. This scales horizontally without shared session stores.
Use JWT when: building REST or GraphQL APIs, microservices with shared authentication, or mobile clients.
Avoid JWT when: you need instant revocation (log out everywhere now) — JWTs are valid until expiry unless you maintain a denylist. For that, use server-side sessions.
Setup
npm install jsonwebtoken bcrypt cookie-parser
npm install --save-dev @types/jsonwebtoken @types/bcrypt @types/cookie-parserToken Generation
import jwt from 'jsonwebtoken';
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;
// Use different secrets for access and refresh tokens
interface TokenPayload {
userId: number;
email: string;
}
function generateAccessToken(payload: TokenPayload): string {
return jwt.sign(payload, ACCESS_SECRET, {
expiresIn: '15m',
algorithm: 'HS256',
});
}
function generateRefreshToken(payload: Pick<TokenPayload, 'userId'>): string {
return jwt.sign(payload, REFRESH_SECRET, {
expiresIn: '7d',
algorithm: 'HS256',
});
}
function verifyAccessToken(token: string): TokenPayload {
return jwt.verify(token, ACCESS_SECRET) as TokenPayload;
}
function verifyRefreshToken(token: string): Pick<TokenPayload, 'userId'> {
return jwt.verify(token, REFRESH_SECRET) as Pick<TokenPayload, 'userId'>;
}Registration and Login
import bcrypt from 'bcrypt';
import { Request, Response } from 'express';
import crypto from 'crypto';
// Registration
export async function register(req: Request, res: Response) {
const { email, password } = req.body;
const existing = await db.users.findOne({ where: { email } });
if (existing) return res.status(409).json({ error: 'Email already registered' });
const hashed = await bcrypt.hash(password, 12);
const user = await db.users.create({ email, password: hashed });
const { accessToken, refreshToken } = issueTokens(user.id, user.email);
await storeRefreshToken(user.id, refreshToken);
setRefreshCookie(res, refreshToken);
return res.status(201).json({ accessToken, user: { id: user.id, email } });
}
// Login
export async function login(req: Request, res: Response) {
const { email, password } = req.body;
const user = await db.users.findOne({ where: { email } });
if (!user || !(await bcrypt.compare(password, user.password))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const { accessToken, refreshToken } = issueTokens(user.id, user.email);
await storeRefreshToken(user.id, refreshToken);
setRefreshCookie(res, refreshToken);
return res.json({ accessToken });
}
function issueTokens(userId: number, email: string) {
return {
accessToken: generateAccessToken({ userId, email }),
refreshToken: generateRefreshToken({ userId }),
};
}
// Store SHA-256 hash of refresh token — raw token never hits the DB
async function storeRefreshToken(userId: number, token: string) {
const hash = crypto.createHash('sha256').update(token).digest('hex');
await db.refreshTokens.upsert({ userId, tokenHash: hash, expiresAt: new Date(Date.now() + 7 * 864e5) });
}
function setRefreshCookie(res: Response, token: string) {
res.cookie('refreshToken', token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/auth/refresh',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
}Auth Middleware
import { Request, Response, NextFunction } from 'express';
export interface AuthRequest extends Request {
user?: { userId: number; email: string };
}
export function authenticate(req: AuthRequest, res: Response, next: NextFunction) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing authorization header' });
}
const token = authHeader.slice(7);
try {
req.user = verifyAccessToken(token);
next();
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}Token Refresh with Rotation
import crypto from 'crypto';
export async function refresh(req: Request, res: Response) {
const token = req.cookies['refreshToken'];
if (!token) return res.status(401).json({ error: 'No refresh token' });
try {
const { userId } = verifyRefreshToken(token);
// Verify token hash exists in DB — rotation revokes old tokens
const hash = crypto.createHash('sha256').update(token).digest('hex');
const stored = await db.refreshTokens.findOne({ where: { userId, tokenHash: hash } });
if (!stored || stored.expiresAt < new Date()) {
return res.status(401).json({ error: 'Refresh token invalid or expired' });
}
const user = await db.users.findById(userId);
const { accessToken, refreshToken: newRefreshToken } = issueTokens(userId, user!.email);
// Rotate: delete old, store new
await db.refreshTokens.delete({ where: { userId } });
await storeRefreshToken(userId, newRefreshToken);
setRefreshCookie(res, newRefreshToken);
return res.json({ accessToken });
} catch {
return res.status(401).json({ error: 'Invalid refresh token' });
}
}Logout and Token Revocation
export async function logout(req: AuthRequest, res: Response) {
if (req.user) {
await db.refreshTokens.delete({ where: { userId: req.user.userId } });
}
res.clearCookie('refreshToken', { path: '/api/auth/refresh' });
return res.json({ ok: true });
}Role-Based Access Control
export function authorize(...roles: string[]) {
return (req: AuthRequest, res: Response, next: NextFunction) => {
if (!req.user) return res.status(401).json({ error: 'Unauthenticated' });
if (!roles.includes(req.user.role ?? 'user')) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
// Usage
router.delete('/users/:id', authenticate, authorize('admin'), deleteUser);Common Mistakes
- Storing JWT secrets in code — always load from
process.envand fail fast if missing - Using the same secret for access and refresh tokens — a leaked access secret should not compromise refresh tokens
- Storing raw refresh tokens in the database — hash with SHA-256; a DB leak should not yield usable tokens
- Setting access token expiry to 24h+ — short-lived tokens (15m) limit the damage from token theft
- Putting sensitive data (PII, passwords) in JWT payload — tokens are base64 encoded, not encrypted
Best Practices
- Access tokens: 15 minutes; Refresh tokens: 7 days with rotation on each use
- Store refresh tokens as SHA-256 hashes in the database, not raw values
- Use
httpOnly,secure,sameSite: strictcookies for refresh tokens on web clients - Return
code: 'TOKEN_EXPIRED'in 401 responses so clients know to attempt a refresh - Rate-limit
/login,/register, and/refreshendpoints (e.g., 10 req/min per IP) - Validate env vars (
JWT_ACCESS_SECRET,JWT_REFRESH_SECRET) at application startup
Key Takeaways
- JWTs are signed but not encrypted — never put secrets or sensitive PII in the payload
- Use separate secrets for access and refresh tokens to limit blast radius of a key leak
- Access tokens should expire in 15 minutes; refresh tokens in 7 days with rotation
- Store refresh token SHA-256 hashes in the DB — raw tokens never touch persistent storage
httpOnlycookies prevent JavaScript access;sameSite: strictblocks CSRF for cookie-based tokens- Rotating refresh tokens invalidates old tokens immediately after each refresh cycle
- Always implement logout by deleting the refresh token from the database
- Return typed error codes (
TOKEN_EXPIRED,INVALID_TOKEN) so clients can handle each case correctly
Advertisement
Related reading
Clock Skew Breaking Tokens — When Servers Disagree on What Time It Is6 min readAPI Security Guide 2026 — OWASP Top 10, JWT, CORS, and Rate Limiting5 min readWeb Security Best Practices Every Developer Must Know5 min readAbuse of Public Endpoints — Protecting Your Free Tier From Exploitation9 min readNode.js API Best Practices 2026 — Build Production-Ready REST APIs5 min readPrisma ORM Guide 2026 — Type-Safe Database Access with PostgreSQL5 min read