JWT Tokens Explained: Authentication for Modern Web Apps

· 12 min read

Table of Contents

JSON Web Tokens (JWT) have become the de facto standard for authentication in modern web applications. They provide a compact, URL-safe way to represent claims between two parties, enabling stateless authentication that scales horizontally without shared session storage.

Whether you're building a REST API, a microservices architecture, or a single-page application, understanding JWTs is essential for implementing secure, scalable authentication. This comprehensive guide explains how JWTs work, their structure, security considerations, and production-ready best practices.

What Is JWT?

JWT (pronounced "jot") is an open standard (RFC 7519) that defines a compact, self-contained format for securely transmitting information between parties as a JSON object. The information is digitally signed using either a secret key (HMAC) or a public/private key pair (RSA or ECDSA), ensuring the token's integrity and authenticity.

Unlike traditional session-based authentication where the server maintains session state in memory or a database, JWTs are stateless. All the information needed to verify the user's identity is contained within the token itself.

Primary Use Cases for JWT

Pro tip: Use our JWT Decoder to inspect and validate tokens during development. It helps you understand the structure and catch common issues before they reach production.

JWT Structure Breakdown

A JWT consists of three distinct parts separated by dots (.): header.payload.signature

Here's what a complete JWT looks like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRGV2ZWxvcGVyIiwiZW1haWwiOiJqYW5lQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzEwNTkwNDAwLCJleHAiOjE3MTA2NzY4MDB9.4Adcj0mYZ8s5vxjKvV8pF7jKX9s8vZ5xJ3kL9mN2pQ4

Part 1: Header

The header typically contains two fields that describe the token's cryptographic operations:

{
  "alg": "HS256",
  "typ": "JWT"
}

This JSON object is Base64Url-encoded to form the first part of the JWT. The encoding makes it URL-safe and compact for transmission in HTTP headers.

Part 2: Payload

The payload contains claims—statements about the user and additional metadata. Claims are categorized into three types:

{
  "sub": "1234567890",
  "name": "Jane Developer",
  "email": "[email protected]",
  "role": "admin",
  "iat": 1710590400,
  "exp": 1710676800
}

Registered claims are predefined by the JWT specification and provide standard information:

Public claims are custom claims that should be defined in the IANA JSON Web Token Registry or use collision-resistant names (like URLs).

Private claims are custom claims agreed upon between parties, like role, permissions, or organizationId.

Important: The payload is only Base64Url-encoded, not encrypted. Never store sensitive information like passwords, credit card numbers, or social security numbers in a JWT payload.

Part 3: Signature

The signature ensures the token hasn't been altered. It's created by taking the encoded header, encoded payload, a secret key, and applying the algorithm specified in the header:

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

The signature allows the recipient to verify that the sender is who they claim to be and that the message wasn't changed along the way.

Signing Algorithms Comparison

Algorithm Type Security Level Use Case Key Management
HS256 Symmetric (HMAC) Good Single service, internal APIs Shared secret
RS256 Asymmetric (RSA) Very Good Multiple services, public APIs Public/private key pair
ES256 Asymmetric (ECDSA) Excellent High security requirements Public/private key pair
PS256 Asymmetric (RSA-PSS) Excellent Modern applications Public/private key pair

How JWT Authentication Works

Understanding the complete authentication flow helps you implement JWTs correctly and troubleshoot issues effectively.

The Complete Authentication Flow

  1. User Login: The client sends credentials (username/password) to the authentication endpoint
  2. Credential Verification: The server validates credentials against the database
  3. Token Generation: Upon successful authentication, the server creates a JWT containing user information and claims
  4. Token Delivery: The server sends the JWT back to the client (typically in the response body)
  5. Token Storage: The client stores the JWT (in memory, localStorage, or httpOnly cookies)
  6. Authenticated Requests: For subsequent requests, the client includes the JWT in the Authorization header
  7. Token Verification: The server validates the JWT signature and checks expiration
  8. Access Granted: If valid, the server processes the request using the claims in the token

Example Authentication Request

POST /api/auth/login HTTP/1.1
Host: api.example.com
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "securePassword123"
}

Example Authentication Response

HTTP/1.1 200 OK
Content-Type: application/json

{
  "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "expiresIn": 3600,
  "tokenType": "Bearer"
}

Example Authenticated API Request

GET /api/users/profile HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

JWT vs Session-Based Authentication

Choosing between JWT and session-based authentication depends on your application's architecture, scale, and requirements.

Aspect JWT Session-Based
State Stateless (server doesn't store tokens) Stateful (server stores session data)
Scalability Excellent (no shared storage needed) Requires session store (Redis, database)
Revocation Difficult (requires blacklist or short expiry) Easy (delete session from store)
Size Larger (sent with every request) Smaller (just session ID)
Cross-domain Easy (CORS-friendly) Complex (cookie domain restrictions)
Mobile apps Ideal (no cookie support needed) Challenging (cookie handling)
Security Requires careful implementation Well-established patterns

When to Use JWT

When to Use Sessions

Implementing JWT in Your Application

Let's walk through practical implementation examples in popular programming languages and frameworks.

Node.js with Express

const jwt = require('jsonwebtoken');
const express = require('express');
const app = express();

const SECRET_KEY = process.env.JWT_SECRET;

// Login endpoint
app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;
  
  // Verify credentials (pseudo-code)
  const user = await verifyCredentials(email, password);
  
  if (!user) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  // Generate JWT
  const token = jwt.sign(
    {
      sub: user.id,
      email: user.email,
      role: user.role
    },
    SECRET_KEY,
    { expiresIn: '1h' }
  );
  
  res.json({ accessToken: token });
});

// Authentication middleware
const authenticateToken = (req, res, next) => {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  jwt.verify(token, SECRET_KEY, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid token' });
    }
    req.user = user;
    next();
  });
};

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

Python with Flask

from flask import Flask, request, jsonify
import jwt
import datetime
import os

app = Flask(__name__)
SECRET_KEY = os.getenv('JWT_SECRET')

@app.route('/api/auth/login', methods=['POST'])
def login():
    data = request.get_json()
    email = data.get('email')
    password = data.get('password')
    
    # Verify credentials
    user = verify_credentials(email, password)
    
    if not user:
        return jsonify({'error': 'Invalid credentials'}), 401
    
    # Generate JWT
    token = jwt.encode({
        'sub': user['id'],
        'email': user['email'],
        'role': user['role'],
        'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=1)
    }, SECRET_KEY, algorithm='HS256')
    
    return jsonify({'accessToken': token})

def token_required(f):
    def decorator(*args, **kwargs):
        token = request.headers.get('Authorization')
        
        if not token:
            return jsonify({'error': 'No token provided'}), 401
        
        try:
            token = token.split(' ')[1]
            data = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
            request.user = data
        except jwt.ExpiredSignatureError:
            return jsonify({'error': 'Token expired'}), 403
        except jwt.InvalidTokenError:
            return jsonify({'error': 'Invalid token'}), 403
        
        return f(*args, **kwargs)
    
    decorator.__name__ = f.__name__
    return decorator

@app.route('/api/users/profile')
@token_required
def profile():
    return jsonify({'user': request.user})

Quick tip: Always use environment variables for secret keys. Never hardcode them in your source code or commit them to version control. Use tools like Password Generator to create strong secrets.

Security Best Practices

Implementing JWT securely requires attention to multiple security considerations. Following these best practices helps protect your application from common attacks.

1. Use Strong Signing Algorithms

Always use secure algorithms like RS256 or ES256 for production applications. Avoid the none algorithm entirely—it's a security vulnerability waiting to happen.

Never allow clients to specify the algorithm. Some libraries accept the algorithm from the token header, which attackers can exploit by changing it to none or switching from RS256 to HS256.

2. Keep Tokens Short-Lived

Access tokens should expire quickly (15-30 minutes) to limit the damage if compromised. Use refresh tokens for longer sessions.

const accessToken = jwt.sign(payload, SECRET, { expiresIn: '15m' });
const refreshToken = jwt.sign(payload, REFRESH_SECRET, { expiresIn: '7d' });

3. Validate All Claims

Don't just verify the signature—validate all critical claims:

4. Use HTTPS Everywhere

Always transmit JWTs over HTTPS. Without encryption, tokens can be intercepted through man-in-the-middle attacks.

5. Implement Token Rotation

Rotate refresh tokens after each use to detect token theft. If a refresh token is used twice, it indicates potential compromise.

6. Store Secrets Securely

Use environment variables, secret management services (AWS Secrets Manager, HashiCorp Vault), or key management systems. Never commit secrets to version control.

7. Implement Rate Limiting

Protect authentication endpoints with rate limiting to prevent brute force attacks:

const rateLimit = require('express-rate-limit');

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 5, // 5 requests per window
  message: 'Too many login attempts, please try again later'
});

app.post('/api/auth/login', loginLimiter, loginHandler);

8. Add Security Headers

Use security headers to protect against common attacks:

app.use((req, res, next) => {
  res.setHeader('X-Content-Type-Options', 'nosniff');
  res.setHeader('X-Frame-Options', 'DENY');
  res.setHeader('X-XSS-Protection', '1; mode=block');
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  next();
});

Common Vulnerabilities and How to Prevent Them

Understanding common JWT vulnerabilities helps you build more secure applications. Here are the most critical issues and their solutions.

1. Algorithm Confusion Attack

The vulnerability: Attackers change the algorithm from RS256 (asymmetric) to HS256 (symmetric) and sign the token with the public key, which the server then uses as the HMAC secret.

Prevention:

// Explicitly specify the expected algorithm
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

2. None Algorithm Attack

The vulnerability: Attackers set the algorithm to "none" and remove the signature, bypassing verification entirely.

Prevention:

// Never allow 'none' algorithm
jwt.verify(token, secret, { 
  algorithms: ['HS256', 'RS256'],
  // 'none' is automatically rejected
});

3. Weak Secret Keys

The vulnerability: Using short or predictable secrets allows attackers to brute force the signing key.

Prevention: Use cryptographically strong secrets (at least 256 bits for HS256):

// Generate a strong secret
const crypto = require('crypto');
const secret = crypto.randomBytes(32).toString('hex');

4. Token Leakage

The vulnerability: Tokens exposed in URLs, logs, or browser history can be stolen.

Prevention:

5. Missing Expiration Validation

The vulnerability: Tokens without expiration or improper validation allow indefinite access.

Prevention:

// Always set expiration
const token = jwt.sign(payload, secret, { expiresIn: '15m' });

// Verify expiration is checked
jwt.verify(token, secret, { 
  clockTolerance: 0 // No tolerance for expired tokens
});

6. Cross-Site Scripting (XSS)

The vulnerability: If tokens are stored in localStorage, XSS attacks can steal them.

Prevention:

7. Cross-Site Request Forgery (CSRF)

The vulnerability: When using cookies, attackers can trigger authenticated requests from malicious sites.

Prevention:

res.cookie('token', jwt, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict'
});

Token Refresh Strategies

Implementing a robust token refresh mechanism balances security with user experience. Users shouldn't need to log in constantly, but tokens shouldn't live forever.

The Refresh Token Pattern

Use two types of tokens:

Implementation Example

// Generate both tokens on login
app.post('/api/auth/login', async (req, res) => {
  const user = await authenticateUser(req.body);
  
  const accessToken = jwt.sign(
    { sub: user.id, email: user.email },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  
  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  
  // Store refresh token in database
  await storeRefreshToken(user.id, refreshToken);
  
  res.json({ accessToken, refreshToken });
});

// Refresh endpoint
app.post('/api/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  try {
    const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
    
    // Verify token exists in database
    const isValid = await verifyRefreshToken(decoded.sub, refreshToken);
    if (!isValid) {
      return res.status(403).json({ error: 'Invalid refresh token' });
    }
    
    // Generate new access token
    const accessToken = jwt.sign(
      { sub: decoded.sub },
      ACCESS_SECRET,
      { expiresIn: '15m' }
    );
    
    // Optional: Rotate refresh token
    const newRefreshToken = jwt.sign(
      { sub: decoded.sub, type: 'refresh' },
      REFRESH_SECRET,
      { expiresIn: '7d' }
    );
    
    await rotateRefreshToken(decoded.sub, refreshToken, newRefreshToken);
    
    res.json({ accessToken, refreshToken: newRefreshToken });
  } catch (error) {
    res.status(403).json({ error: 'Invalid refresh token' });
  }
});

Automatic Token Refresh

Implement automatic refresh in your client application:

// JavaScript client example
let accessToken = localStorage.getItem('accessToken');
let refreshToken = localStorage.getItem('refreshToken');

async function refreshAccessToken() {
  const response = await fetch('/api/auth/refresh', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ refreshToken })
  });
  
  const data = await response.json();
  accessToken = data.accessToken;
  refreshToken = data.refreshToken;
  
  localStorage.setItem('accessToken', accessToken);
  localStorage.setItem('refreshToken', refreshToken);
}

// Intercept 401 responses and refresh
async function apiRequest(url, options = {}) {
  options.headers = {
    ...options.headers,
    'Authorization': `Bearer ${accessToken}`
  };
  
  let response = await fetch(url, options);
  
  if (response.status === 401) {
    await refreshAccessToken();
    options.headers['Authorization'] = `Bearer ${accessToken}`;
    response = await fetch(url, options);
  }
  
  return response;
}

Pro tip: Refresh tokens proactively before access tokens expire (e.g., when 80% of the lifetime has passed) to avoid interrupting user workflows.

Where to Store JWTs

Choosing where to store JWTs significantly impacts your application's security posture. Each option has trade-offs.

Storage Options Comparison

1. localStorage / sessionStorage

Pros:

Cons:

Best for: Low-security applications, development environments

2. httpOnly Cookies

Pros:

Cons:

Best for: Traditional web applications, high-security requirements

3. Memory (JavaScript variable)

Pros:

Cons:

We use cookies for analytics. By continuing, you agree to our Privacy Policy.