Follow Us

CodeWithSabir

  • Contact Us
  • Privacy Policy
  • About
  • Terms & Conditions

All Rights Reserved © 2026

  • Light
  • Dark
Web Development

Understanding JWT Authentication in Modern Web Apps

Sabir Soft
Sabir Lkhaloufi
  • December 10, 2025
  • 4 min read

Understanding JWT Authentication in Modern Web Apps

JWT (JSON Web Token) is everywhere — but it's also frequently misimplemented in ways that create real security vulnerabilities. Understanding how JWTs work and where the pitfalls are is essential for any developer building authenticated applications.

What Is a JWT?

A JWT is a base64-encoded string with three parts separated by dots:

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiIxMjMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJleHAiOjE3MDAwMDAwMDB9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • Header — algorithm and token type
  • Payload — the claims (your data)
  • Signature — cryptographic proof that the token wasn't tampered with

Decode the payload and you get:

{
  "userId": "123",
  "email": "user@example.com",
  "exp": 1700000000
}

The critical point: JWTs are not encrypted by default — they're just signed. Anyone who has the token can read the payload. Never put passwords, credit card numbers, or sensitive data in a JWT.

Generating and Verifying Tokens

// lib/jwt.ts
import jwt from 'jsonwebtoken'
 
const JWT_ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!
const JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!
 
export interface TokenPayload {
  userId: string
  email: string
  role: string
}
 
export function generateAccessToken(payload: TokenPayload): string {
  return jwt.sign(payload, JWT_ACCESS_SECRET, {
    expiresIn: '15m',  // Short-lived — 15 minutes
    issuer: 'myapp',
    audience: 'myapp-users',
  })
}
 
export function generateRefreshToken(userId: string): string {
  return jwt.sign({ userId }, JWT_REFRESH_SECRET, {
    expiresIn: '7d',   // Long-lived — 7 days
  })
}
 
export function verifyAccessToken(token: string): TokenPayload {
  return jwt.verify(token, JWT_ACCESS_SECRET, {
    issuer: 'myapp',
    audience: 'myapp-users',
  }) as TokenPayload
}
 
export function verifyRefreshToken(token: string): { userId: string } {
  return jwt.verify(token, JWT_REFRESH_SECRET) as { userId: string }
}

The Access + Refresh Token Pattern

Never issue a single long-lived token. Use short-lived access tokens with long-lived refresh tokens:

  • Access token: expires in 15-60 minutes. Used on every API request.
  • Refresh token: expires in 7-30 days. Used only to get new access tokens.
// controllers/auth.controller.ts
export async function login(req: Request, res: Response) {
  const { email, password } = req.body
  
  const user = await db.user.findUnique({ where: { email } })
  if (!user || !await bcrypt.compare(password, user.password)) {
    return res.status(401).json({ error: 'Invalid credentials' })
  }
 
  const accessToken = generateAccessToken({
    userId: user.id,
    email: user.email,
    role: user.role,
  })
 
  const refreshToken = generateRefreshToken(user.id)
 
  // Store refresh token in DB so we can invalidate it
  await db.refreshToken.create({
    data: {
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
    },
  })
 
  // Send refresh token as HTTP-only cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,   // Not accessible via JavaScript
    secure: true,     // HTTPS only
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  })
 
  // Send access token in response body
  res.json({ accessToken, user: { id: user.id, email: user.email, role: user.role } })
}
// Refresh endpoint — get a new access token using the refresh token
export async function refresh(req: Request, res: Response) {
  const refreshToken = req.cookies.refreshToken
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' })
 
  try {
    const { userId } = verifyRefreshToken(refreshToken)
 
    // Verify token exists in DB (allows revocation)
    const stored = await db.refreshToken.findFirst({
      where: { token: refreshToken, userId, expiresAt: { gt: new Date() } },
      include: { user: true },
    })
 
    if (!stored) return res.status(401).json({ error: 'Invalid refresh token' })
 
    const accessToken = generateAccessToken({
      userId: stored.user.id,
      email: stored.user.email,
      role: stored.user.role,
    })
 
    res.json({ accessToken })
  } catch {
    res.status(401).json({ error: 'Invalid refresh token' })
  }
}

Auth Middleware

// middleware/auth.ts
export function requireAuth(req: Request, res: Response, next: NextFunction) {
  const header = req.headers.authorization
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'No token provided' })
  }
 
  const token = header.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' })
    }
    res.status(401).json({ error: 'Invalid token' })
  }
}

Client-Side: Automatic Token Refresh

// lib/apiClient.ts
import axios from 'axios'
 
const api = axios.create({ baseURL: '/api' })
 
// Attach access token to every request
api.interceptors.request.use(config => {
  const token = localStorage.getItem('accessToken')
  if (token) config.headers.Authorization = `Bearer ${token}`
  return config
})
 
let isRefreshing = false
let failedQueue: Array<{ resolve: Function; reject: Function }> = []
 
// Automatically refresh expired tokens
api.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config
 
    if (error.response?.status === 401 && 
        error.response?.data?.code === 'TOKEN_EXPIRED' &&
        !originalRequest._retry) {
      
      if (isRefreshing) {
        // Queue requests while refresh is in progress
        return new Promise((resolve, reject) => {
          failedQueue.push({ resolve, reject })
        }).then(token => {
          originalRequest.headers.Authorization = `Bearer ${token}`
          return api(originalRequest)
        })
      }
 
      originalRequest._retry = true
      isRefreshing = true
 
      try {
        const { data } = await axios.post('/api/auth/refresh', {}, { withCredentials: true })
        localStorage.setItem('accessToken', data.accessToken)
        
        failedQueue.forEach(p => p.resolve(data.accessToken))
        failedQueue = []
        
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`
        return api(originalRequest)
      } catch (refreshError) {
        failedQueue.forEach(p => p.reject(refreshError))
        failedQueue = []
        localStorage.removeItem('accessToken')
        window.location.href = '/login'
        return Promise.reject(refreshError)
      } finally {
        isRefreshing = false
      }
    }
 
    return Promise.reject(error)
  }
)
 
export default api

Where to Store Tokens

StorageXSS RiskCSRF RiskNotes
localStorageHIGHNoneAccessible to any JS on the page
sessionStorageHIGHNoneSame as localStorage but clears on tab close
HTTP-only CookieNoneModerateCan't be read by JS; use SameSite=strict to mitigate CSRF
Memory onlyNoneNoneLost on page refresh — use for access tokens

Best practice: Store access tokens in memory (React state), refresh tokens in HTTP-only cookies. If you must persist the access token, use a short expiry (15 min) and accept the tradeoff.

Common Security Mistakes

1. Long-lived access tokens. If a token is stolen, the attacker has access until it expires. Keep access tokens short (15 min max).

2. Not storing refresh tokens in the database. Without DB storage, you can't revoke a compromised refresh token.

3. Using the same secret for access and refresh tokens. A leaked access token secret shouldn't also compromise refresh tokens.

4. Putting sensitive data in the payload. JWTs are base64-encoded, not encrypted. Anyone who intercepts the token can read it.

5. Not verifying the alg header. Some old libraries had a vulnerability where setting alg: none bypassed signature verification. Use a maintained library.

Key Takeaways

  • JWTs are signed, not encrypted — never put sensitive data in the payload
  • Use short-lived access tokens (15 min) with long-lived refresh tokens (7 days)
  • Store refresh tokens in HTTP-only cookies to prevent JavaScript access
  • Keep refresh tokens in the database so you can revoke them when needed
  • Use separate secrets for access and refresh tokens
  • Implement automatic token refresh on the client to handle expiry transparently
Popular Blogs
Claude AI vs ChatGPT: An Honest Comparison for Developers
  • April 28, 2026
AI Tools Every Developer Should Be Using in 2026
  • April 20, 2026
Using the Claude API in Real Projects: A Practical Developer Guide
  • April 15, 2026
Prompt Engineering for Developers: Write Prompts That Actually Work
  • April 10, 2026
Categories
AIDevOpsNext.jsMobile DevelopmentWeb Development

Related Posts

Web Development
Building REST APIs with Node.js and Express: The Complete Guide
Sabir Khaloufi·Dec 25, 2025
Web Development
React Performance Optimization Techniques That Actually Work
Sabir Khaloufi·Dec 20, 2025
Web Development
TypeScript Best Practices for React Developers
Sabir Khaloufi·Dec 15, 2025