JWT Authentication: How JSON Web Tokens Work

· 12 min read

Table of Contents

What is a JSON Web Token?

A JSON Web Token (JWT) is a compact, URL-safe way to represent claims between two parties. JWTs have become the de facto standard for authentication and authorization in modern web applications, APIs, and microservices architectures.

When a user logs in, the server generates a JWT containing user information and sends it to the client. The client then includes this token in subsequent requests to prove their identity. Think of it like a digital passport that contains your credentials and can be verified without calling back to the issuing authority every time.

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 token is contained within the token itself. This architectural decision brings several advantages:

You can inspect and decode any JWT using our JWT Decoder tool to see its contents without needing to write any code. This is particularly useful when debugging authentication issues or understanding what data your tokens contain.

Pro tip: JWTs are signed, not encrypted by default. Anyone can decode and read the contents of a JWT. Never store sensitive information like passwords, credit card numbers, or social security numbers in JWT payloads.

JWT Structure Explained

A JWT consists of three distinct parts separated by dots (.): the Header, the Payload, and the Signature. Here's what a real JWT looks like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiZW1haWwiOiJqb2huQGV4YW1wbGUuY29tIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNTE2MjM5MDIyLCJleHAiOjE1MTYyNDI2MjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Let's break down each component to understand how they work together to create a secure, verifiable token.

1. Header

The header typically contains two critical fields that define how the token should be processed:

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

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

2. Payload

The payload contains claims — statements about the user and additional metadata. This is where you store the actual data you want to transmit:

{
  "sub": "1234567890",
  "name": "John Doe",
  "email": "[email protected]",
  "role": "admin",
  "iat": 1516239022,
  "exp": 1516242622
}

Claims are divided into three categories:

Registered claims (standardized and recommended):

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 department.

3. Signature

The signature is what makes JWTs secure and tamper-proof. 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 serves two critical purposes:

  1. Integrity verification: Ensures the token hasn't been modified since it was issued
  2. Authentication: Proves the token was created by someone with access to the secret key

If anyone tries to modify the header or payload, the signature verification will fail, and the token will be rejected.

How JWT Authentication Works

Understanding the complete authentication flow is essential for implementing JWTs correctly. Here's the step-by-step process:

Step 1: User Login

The user submits their credentials (username and password) to the authentication endpoint. The server validates these credentials against the database.

Step 2: Token Generation

If credentials are valid, the server generates a JWT containing user information and claims. The server signs the token using a secret key (for symmetric algorithms) or a private key (for asymmetric algorithms).

Step 3: Token Delivery

The server sends the JWT back to the client, typically in the response body. The client stores this token for future requests.

Step 4: Authenticated Requests

For subsequent requests, the client includes the JWT in the Authorization header using the Bearer schema:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Step 5: Token Verification

The server receives the request, extracts the JWT from the header, and verifies the signature using the secret or public key. If valid, the server extracts user information from the payload and processes the request.

Step 6: Token Expiration

When the token expires, the client must obtain a new token, either by re-authenticating or using a refresh token (covered in the next section).

Quick tip: Always validate the exp claim on the server side, even if you check expiration on the client. Client-side checks can be bypassed, but server-side validation is authoritative.

Access Tokens vs Refresh Tokens

A robust JWT authentication system typically uses two types of tokens working together: access tokens and refresh tokens. Understanding the distinction is crucial for building secure applications.

Access Tokens

Access tokens are short-lived JWTs (typically 15 minutes to 1 hour) that grant access to protected resources. They contain user identity and permissions needed to authorize requests.

Characteristics:

Refresh Tokens

Refresh tokens are long-lived tokens (days to months) used exclusively to obtain new access tokens. They're stored securely and only sent to the token refresh endpoint.

Characteristics:

Why Use Both?

This dual-token approach balances security and user experience:

Aspect Access Token Only Access + Refresh Tokens
Security Lower (long-lived tokens at risk) Higher (short-lived access tokens)
User Experience Better (no re-authentication) Better (seamless token refresh)
Revocation Difficult (stateless) Possible (refresh tokens in DB)
Network Overhead Lower Slightly higher (refresh requests)
Implementation Complexity Simple Moderate

Token Refresh Flow

  1. Client detects access token is expired or about to expire
  2. Client sends refresh token to /auth/refresh endpoint
  3. Server validates refresh token against database
  4. Server generates new access token (and optionally new refresh token)
  5. Client receives and stores new tokens
  6. Client retries original request with new access token

Pro tip: Implement refresh token rotation — issue a new refresh token each time one is used and invalidate the old one. This limits the damage if a refresh token is compromised.

Node.js Implementation Guide

Let's build a complete JWT authentication system using Node.js, Express, and the jsonwebtoken library. This implementation includes both access and refresh tokens.

Installation

npm install express jsonwebtoken bcrypt dotenv

Environment Configuration

Create a .env file with your secret keys:

ACCESS_TOKEN_SECRET=your-super-secret-access-key-change-this
REFRESH_TOKEN_SECRET=your-super-secret-refresh-key-change-this
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d

Token Generation

const jwt = require('jsonwebtoken');

function generateAccessToken(user) {
  return jwt.sign(
    { 
      userId: user.id,
      email: user.email,
      role: user.role 
    },
    process.env.ACCESS_TOKEN_SECRET,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
  );
}

function generateRefreshToken(user) {
  return jwt.sign(
    { userId: user.id },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
  );
}

Login Endpoint

const bcrypt = require('bcrypt');

app.post('/auth/login', async (req, res) => {
  try {
    const { email, password } = req.body;
    
    // Find user in database
    const user = await User.findOne({ email });
    if (!user) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    
    // Verify password
    const validPassword = await bcrypt.compare(password, user.password);
    if (!validPassword) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }
    
    // Generate tokens
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);
    
    // Store refresh token in database
    await RefreshToken.create({
      token: refreshToken,
      userId: user.id,
      expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
    });
    
    res.json({
      accessToken,
      refreshToken,
      user: {
        id: user.id,
        email: user.email,
        name: user.name
      }
    });
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});

Authentication Middleware

function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }
  
  jwt.verify(token, process.env.ACCESS_TOKEN_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid or expired token' });
    }
    
    req.user = user;
    next();
  });
}

// Usage
app.get('/api/protected', authenticateToken, (req, res) => {
  res.json({ 
    message: 'Protected data',
    user: req.user 
  });
});

Token Refresh Endpoint

app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  if (!refreshToken) {
    return res.status(401).json({ error: 'Refresh token required' });
  }
  
  try {
    // Verify refresh token exists in database
    const storedToken = await RefreshToken.findOne({ token: refreshToken });
    if (!storedToken) {
      return res.status(403).json({ error: 'Invalid refresh token' });
    }
    
    // Verify token signature
    jwt.verify(refreshToken, process.env.REFRESH_TOKEN_SECRET, async (err, user) => {
      if (err) {
        return res.status(403).json({ error: 'Invalid refresh token' });
      }
      
      // Get user data
      const userData = await User.findById(user.userId);
      
      // Generate new access token
      const accessToken = generateAccessToken(userData);
      
      res.json({ accessToken });
    });
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});

Logout Endpoint

app.post('/auth/logout', async (req, res) => {
  const { refreshToken } = req.body;
  
  try {
    // Remove refresh token from database
    await RefreshToken.deleteOne({ token: refreshToken });
    
    res.json({ message: 'Logged out successfully' });
  } catch (error) {
    res.status(500).json({ error: 'Server error' });
  }
});

You can test your JWT implementation using our JWT Decoder to verify the token structure and our API Tester to test your authentication endpoints.

Where to Store JWTs Securely

Token storage is one of the most debated aspects of JWT implementation. The wrong choice can expose your application to serious security vulnerabilities.

Storage Options Compared

Storage Method XSS Vulnerable CSRF Vulnerable Accessible to JS Best For
localStorage Yes No Yes Low-risk applications
sessionStorage Yes No Yes Single-tab applications
Memory (variable) Yes No Yes High-security SPAs
HttpOnly Cookie No Yes No Traditional web apps
Secure Cookie No Yes (mitigated) No Production applications

Recommended Approach

For most applications, use this hybrid approach:

This approach protects against both XSS (access tokens not in localStorage) and CSRF (SameSite cookies) attacks.

Cookie Configuration

res.cookie('refreshToken', refreshToken, {
  httpOnly: true,  // Not accessible via JavaScript
  secure: true,    // Only sent over HTTPS
  sameSite: 'strict', // CSRF protection
  maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
});

Memory Storage Pattern

When storing access tokens in memory, implement automatic refresh before expiration:

let accessToken = null;

async function refreshAccessToken() {
  const response = await fetch('/auth/refresh', {
    method: 'POST',
    credentials: 'include' // Send cookies
  });
  
  const data = await response.json();
  accessToken = data.accessToken;
  
  // Schedule next refresh before expiration
  setTimeout(refreshAccessToken, 14 * 60 * 1000); // 14 minutes
}

// Initial token fetch after login
await refreshAccessToken();

Pro tip: Never store tokens in localStorage if your application handles sensitive data. While convenient, localStorage is vulnerable to XSS attacks that can steal tokens and impersonate users.

Security Best Practices

Implementing JWTs correctly requires attention to security at every level. Here are the essential practices you must follow:

1. Use Strong Secret Keys

Your secret keys should be cryptographically random and at least 256 bits (32 bytes) long:

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

Never hardcode secrets in your source code. Use environment variables and keep them out of version control.

2. Set Appropriate Expiration Times

3. Validate All Claims

Always verify these claims on the server:

jwt.verify(token, secret, {
  algorithms: ['HS256'], // Whitelist allowed algorithms
  issuer: 'your-app-name',
  audience: 'your-api',
  clockTolerance: 30 // Allow 30 seconds clock skew
});

4. Implement Token Revocation

While JWTs are stateless, you need a revocation mechanism for critical scenarios:

Maintain a blacklist of revoked tokens or use token versioning:

// Add token version to payload
{
  "userId": "123",
  "tokenVersion": 5
}

// Increment version on password change
user.tokenVersion += 1;
await user.save();

// Verify token version matches
if (payload.tokenVersion !== user.tokenVersion) {
  throw new Error('Token revoked');
}

5. Use HTTPS Everywhere

JWTs must only be transmitted over HTTPS. Without encryption, tokens can be intercepted and used by attackers.

6. Implement Rate Limiting

Protect your authentication endpoints from 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('/auth/login', loginLimiter, loginHandler);

7. Sanitize Token Payloads

Never include sensitive information in JWT payloads:

8. Monitor and Log Authentication Events

Track authentication activities for security monitoring:

Common JWT Vulnerabilities

Understanding common JWT vulnerabilities helps you avoid them in your implementation. Here are the most critical security issues:

1. Algorithm Confusion Attack

Attackers change the algorithm from RS256 (asymmetric) to HS256 (symmetric) and sign the token with the public key.

Prevention:

// Always specify allowed algorithms
jwt.verify(token, secret, { algorithms: ['RS256'] });

2. None Algorithm Attack

Setting the algorithm to "none" bypasses signature verification entirely.

Prevention:

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

3. Weak Secret Keys

Using weak or default secrets makes tokens vulnerable to brute force attacks.

Prevention: Use cryptographically random secrets of at least 256 bits.

4. Missing Expiration Validation

Failing to check the exp claim allows expired tokens to remain valid indefinitely.

Prevention:

// jsonwebtoken library checks exp by default
// But always verify it's present
if (!payload.exp) {
  throw new Error('Token missing expiration');
}

5. Token Sidejacking

Attackers steal tokens through XSS, man-in-the-middle attacks, or insecure storage.

Prevention:

6. Cross-Site Request Forgery (CSRF)

When storing JWTs in cookies, CSRF attacks can trick users into making unwanted requests.

Prevention:

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

// Or implement CSRF tokens for state-changing operations

7. JWT Injection

Attackers inject malicious data into JWT claims to exploit application logic.

Prevention:

Quick tip: Use our Security Headers Checker to verify your application has proper security headers configured to protect against common attacks.

Choosing the Right Algorithm

JWT supports multiple signing algorithms, each with different security properties and use cases. Choosing the right one is crucial for your application's security.

Symmetric Algorithms (HMAC)

HS256 (HMAC-SHA256), HS384, HS512

These algorithms use the same secret key for both signing and verification. They're fast and simple but require sharing the secret between all parties.

Best for:

Pros:

Cons:

Asymmetric Algorithms (RSA/ECDSA)

RS256 (RSA-SHA256), RS384, RS512

RSA algorithms use a private key for signing and a public key for verification. The public key can be freely distributed.

Best for:

ES256 (ECDSA-SHA256), ES384, ES512

ECDSA provides similar security to RSA with smaller key sizes and faster operations.

Best for: