CodeWithSabir
HomeAIDevOpsNext.jsMobile DevelopmentWeb Development
CodeWithSabir
  • Home
  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development
  • About
  • Contact
CodeWithSabir

In-depth articles, tutorials, and guides on web development, React, Next.js, AI, and modern programming practices.

Topics

  • AI
  • DevOps
  • Next.js
  • Mobile Development
  • Web Development

Company

  • About
  • Contact
  • Privacy Policy
  • Terms

© 2026 CodeWithSabir. All rights reserved.

Built with SabirSoft.com

Home/Web Development/Understanding JWT Authentication in Modern Web Apps
Web Development

Understanding JWT Authentication in Modern Web Apps

A clear guide to JWT authentication — how tokens work, where to store them, refresh token patterns, security pitfalls, and practical implementation in Node.js and React.

Sabir KhaloufiSabir KhaloufiDecember 10, 20254 min read

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:

code
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:

json
{
  "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

typescript
// 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.
typescript
// 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 } })
}
typescript
// 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

typescript
// 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

typescript
// 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
#jwt#authentication#security#node.js#web development
Share:
Sabir Khaloufi — author photo

Written by

Sabir Khaloufi

Full-stack developer and tech blogger sharing in-depth tutorials on React, Next.js, AI, and modern web development.

On this page

  • What Is a JWT?
  • Generating and Verifying Tokens
  • The Access + Refresh Token Pattern
  • Auth Middleware
  • Client-Side: Automatic Token Refresh
  • Where to Store Tokens
  • Common Security Mistakes
  • Key Takeaways

Related Articles

Web Development

Building REST APIs with Node.js and Express: The Complete Guide

Learn to build production-ready REST APIs with Node.js and Express. Covers routing, middleware, validation, authentication, error handling, and testing best practices.

Sabir KhaloufiDecember 25, 20254 min read
Web Development

React Performance Optimization Techniques That Actually Work

Practical React performance techniques that make a real difference — memo, useMemo, useCallback, code splitting, virtualization, and when each one actually helps.

Sabir KhaloufiDecember 20, 20254 min read
Web Development

TypeScript Best Practices for React Developers

Practical TypeScript patterns for React — covering component typing, generic components, custom hook types, discriminated unions, and the mistakes that cause runtime errors despite type checking.

Sabir KhaloufiDecember 15, 20254 min read