JWT Authentication with TypeScript — Secure Implementation 2026

Sanjeev SharmaSanjeev Sharma
6 min read

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-parser

Token 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.env and 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: strict cookies 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 /refresh endpoints (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
  • httpOnly cookies prevent JavaScript access; sameSite: strict blocks 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

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading