JWT Authentication: Token-Based Auth Guide

JSON Web Tokens (JWT) have become the de facto standard for stateless authentication in modern web applications. Unlike traditional session-based authentication where the server maintains session...

Key Insights

  • JWTs enable stateless authentication by encoding user data in cryptographically signed tokens, eliminating the need for server-side session storage and making horizontal scaling trivial.
  • Implement a dual-token strategy with short-lived access tokens (15 minutes) and long-lived refresh tokens (7 days) to balance security and user experience.
  • Store refresh tokens in httpOnly cookies and access tokens in memory to prevent XSS attacks while maintaining a smooth authentication flow.

Introduction to JWT Authentication

JSON Web Tokens (JWT) have become the de facto standard for stateless authentication in modern web applications. Unlike traditional session-based authentication where the server maintains session state in memory or a database, JWTs are self-contained tokens that carry all necessary user information within themselves.

A JWT consists of three parts separated by dots: header.payload.signature. The header specifies the token type and hashing algorithm, the payload contains claims (user data), and the signature ensures the token hasn’t been tampered with.

// Decode a JWT to see its structure
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiIxMjM0NSIsImVtYWlsIjoidXNlckBleGFtcGxlLmNvbSIsImlhdCI6MTYzMjE1MDQwMCwiZXhwIjoxNjMyMTU0MDAwfQ.abc123signature";

// Split and decode each part
const [header, payload, signature] = jwt.split('.');

const decodedHeader = JSON.parse(Buffer.from(header, 'base64').toString());
const decodedPayload = JSON.parse(Buffer.from(payload, 'base64').toString());

console.log('Header:', decodedHeader);
// { alg: "HS256", typ: "JWT" }

console.log('Payload:', decodedPayload);
// { userId: "12345", email: "user@example.com", iat: 1632150400, exp: 1632154000 }

Use JWT authentication when you need stateless sessions, are building microservices, or require authentication across multiple domains. Stick with session-based auth for monolithic applications where you need server-side session revocation capabilities.

Implementing JWT Authentication in Node.js/Express

Let’s build a complete JWT authentication system. First, install the required dependencies:

npm install express jsonwebtoken bcrypt dotenv

Here’s a login endpoint that validates credentials and issues a JWT:

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');
require('dotenv').config();

const app = express();
app.use(express.json());

// Mock user database
const users = [
  {
    id: '1',
    email: 'user@example.com',
    // Password: "password123" hashed
    passwordHash: '$2b$10$rXQ3Y8Z9vVvKxVqYqXqXqe7Z8Z9vVvKxVqYqXqXqe7Z8Z9vVvKxVq'
  }
];

app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;

  // Find user
  const user = users.find(u => u.email === email);
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Verify password
  const validPassword = await bcrypt.compare(password, user.passwordHash);
  if (!validPassword) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  // Generate access token (15 minutes)
  const accessToken = jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );

  // Generate refresh token (7 days)
  const refreshToken = jwt.sign(
    { userId: user.id },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  // Set refresh token as httpOnly cookie
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
  });

  res.json({ accessToken });
});

app.listen(3000, () => console.log('Server running on port 3000'));

Protecting Routes with JWT Middleware

Create middleware to verify JWTs and protect your API endpoints:

const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  jwt.verify(token, process.env.JWT_SECRET, (err, decoded) => {
    if (err) {
      if (err.name === 'TokenExpiredError') {
        return res.status(401).json({ error: 'Token expired' });
      }
      return res.status(403).json({ error: 'Invalid token' });
    }

    // Attach user info to request
    req.user = decoded;
    next();
  });
};

// Protected route example
app.get('/api/profile', authenticateToken, (req, res) => {
  res.json({
    userId: req.user.userId,
    email: req.user.email
  });
});

// Admin-only route
app.get('/api/admin', authenticateToken, (req, res) => {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ error: 'Admin access required' });
  }
  res.json({ message: 'Admin data' });
});

Client-Side JWT Storage and Usage

On the client side, store access tokens in memory (JavaScript variables) and use Axios interceptors to attach them to requests:

// auth.js - Client-side authentication module
class AuthService {
  constructor() {
    this.accessToken = null;
  }

  async login(email, password) {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include', // Include cookies
      body: JSON.stringify({ email, password })
    });

    if (!response.ok) throw new Error('Login failed');

    const { accessToken } = await response.json();
    this.accessToken = accessToken;
    return accessToken;
  }

  getAccessToken() {
    return this.accessToken;
  }

  logout() {
    this.accessToken = null;
    // Call logout endpoint to clear refresh token cookie
    fetch('/api/auth/logout', {
      method: 'POST',
      credentials: 'include'
    });
  }
}

const authService = new AuthService();

// Axios interceptor to add JWT to requests
import axios from 'axios';

axios.interceptors.request.use(
  (config) => {
    const token = authService.getAccessToken();
    if (token) {
      config.headers.Authorization = `Bearer ${token}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

Token Refresh Strategy

Implement automatic token refresh to maintain user sessions without requiring re-login:

// Server-side refresh endpoint
app.post('/api/auth/refresh', (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }

  jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET, (err, decoded) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid refresh token' });
    }

    // Generate new access token
    const accessToken = jwt.sign(
      { userId: decoded.userId },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );

    res.json({ accessToken });
  });
});

// Client-side auto-refresh logic
axios.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;

    // If access token expired, try to refresh
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const { data } = await axios.post('/api/auth/refresh', {}, {
          withCredentials: true
        });
        
        authService.accessToken = data.accessToken;
        originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
        
        return axios(originalRequest);
      } catch (refreshError) {
        // Refresh failed, redirect to login
        authService.logout();
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

Security Best Practices

Configure your JWT implementation with security in mind:

// .env configuration
require('dotenv').config();

const config = {
  jwt: {
    secret: process.env.JWT_SECRET, // Use strong random string (32+ chars)
    refreshSecret: process.env.JWT_REFRESH_SECRET,
    accessTokenExpiry: '15m', // Short-lived
    refreshTokenExpiry: '7d'
  },
  cookie: {
    httpOnly: true, // Prevents JavaScript access
    secure: process.env.NODE_ENV === 'production', // HTTPS only in production
    sameSite: 'strict' // CSRF protection
  }
};

// Enhanced token validation
const validateToken = (token, secret) => {
  try {
    const decoded = jwt.verify(token, secret);
    
    // Additional validation checks
    if (!decoded.userId) {
      throw new Error('Invalid token structure');
    }
    
    // Check if token was issued in the future (clock skew attack)
    if (decoded.iat > Math.floor(Date.now() / 1000)) {
      throw new Error('Token issued in future');
    }
    
    return decoded;
  } catch (error) {
    throw new Error(`Token validation failed: ${error.message}`);
  }
};

Never store sensitive data in JWT payloads—they’re base64 encoded, not encrypted. Always use HTTPS in production to prevent token interception. Rotate your JWT secrets periodically and implement token blacklisting for critical applications.

Common Pitfalls and Debugging

Handle common JWT issues with proper error handling and debugging utilities:

// Comprehensive error handling
app.use((err, req, res, next) => {
  console.error('Error:', err);

  if (err.name === 'JsonWebTokenError') {
    return res.status(403).json({ error: 'Invalid token format' });
  }

  if (err.name === 'TokenExpiredError') {
    return res.status(401).json({ 
      error: 'Token expired',
      expiredAt: err.expiredAt 
    });
  }

  res.status(500).json({ error: 'Internal server error' });
});

// Debugging utility
const debugToken = (token) => {
  try {
    const decoded = jwt.decode(token, { complete: true });
    console.log('Token Debug Info:', {
      header: decoded.header,
      payload: decoded.payload,
      isExpired: decoded.payload.exp < Date.now() / 1000,
      expiresAt: new Date(decoded.payload.exp * 1000),
      issuedAt: new Date(decoded.payload.iat * 1000)
    });
  } catch (error) {
    console.error('Token decode error:', error.message);
  }
};

// CORS configuration for Authorization headers
const cors = require('cors');
app.use(cors({
  origin: process.env.CLIENT_URL,
  credentials: true,
  exposedHeaders: ['Authorization']
}));

JWT authentication provides a scalable, stateless solution for modern applications. Implement the dual-token strategy, store tokens securely, and always validate tokens server-side. With these practices, you’ll build a robust authentication system that scales effortlessly.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.