JWT Tokens Explained: Structure, Security, and Best Practices

· 12 min read

JSON Web Tokens (JWTs) have become the de facto standard for stateless authentication in modern web applications. Whether you're building a REST API, a microservices architecture, or a single-page application, understanding how JWTs work is essential for implementing secure, scalable authentication.

This comprehensive guide breaks down everything you need to know about JWTsβ€”from their internal structure to production-ready security practices. By the end, you'll understand not just how to use JWTs, but when to use them and how to avoid common pitfalls that lead to security vulnerabilities.

Table of Contents

What is a JWT?

A JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties. The claims in a JWT are encoded as a JSON object that is digitally signed using JSON Web Signature (JWS).

Think of a JWT as a tamper-proof container for user information. When a user logs in, your server creates a JWT containing their user ID, roles, and other relevant data. This token is then sent to the client, which includes it with every subsequent request. The server can verify the token's authenticity without querying a database, making JWTs ideal for stateless, scalable architectures.

JWTs are defined by RFC 7519 and are widely supported across programming languages and frameworks. They're particularly popular in:

Quick tip: Use our JWT Decoder to inspect and debug any JWT token instantly. It's completely client-side, so your tokens never leave your browser.

JWT Structure: Header, Payload, and Signature

A JWT consists of three Base64URL-encoded parts separated by dots (.). Here's what a real JWT looks like:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Breaking this down:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9           ← Header
.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ  ← Payload
.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c    ← Signature
Part Contains Example (decoded)
Header Algorithm + token type {"alg": "HS256", "typ": "JWT"}
Payload Claims (user data) {"sub": "1234567890", "name": "John Doe", "iat": 1516239022}
Signature Verification hash HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)

The Header

The header typically consists of two parts: the token type (typ) and the signing algorithm (alg). Common algorithms include:

The Payload

The payload contains the claimsβ€”statements about an entity (typically the user) and additional metadata. Claims are not encrypted, only encoded, so never put sensitive information like passwords in a JWT payload.

The Signature

The signature ensures the token hasn't been tampered with. It's created by taking the encoded header, encoded payload, a secret key, and applying the algorithm specified in the header. If anyone modifies the header or payload, the signature verification will fail.

Pro tip: Generate test JWTs with custom claims using our JWT Generator tool. Perfect for development and testing scenarios.

Standard Claims (RFC 7519)

RFC 7519 defines several standard claims that provide interoperability between different JWT implementations. While these claims are optional, using them correctly improves security and compatibility.

Claim Name Purpose Example
iss Issuer Identifies who created the token "https://auth.example.com"
sub Subject Identifies who the token is about (usually user ID) "user_12345"
aud Audience Identifies who the token is intended for "https://api.example.com"
exp Expiration When the token expires (Unix timestamp) 1735689600
nbf Not Before Token is not valid before this time 1735603200
iat Issued At When the token was created 1735603200
jti JWT ID Unique identifier for the token (useful for revocation) "a1b2c3d4-e5f6"

Custom Claims

Beyond standard claims, you can add custom claims specific to your application. Common examples include:

Keep your payload smallβ€”every byte adds to network overhead. Include only the claims you need for authorization decisions. If you need additional user data, fetch it from your database using the sub claim as the lookup key.

How JWT Authentication Works

Understanding the complete authentication flow helps you implement JWTs correctly and securely. Here's the typical sequence:

  1. User Login: User sends credentials (username + password) to your authentication endpoint
  2. Credential Verification: Server validates credentials against your user database
  3. Token Generation: Server creates a JWT containing user claims and signs it with a secret key
  4. Token Delivery: Server returns the JWT to the client (typically in the response body or as an httpOnly cookie)
  5. Token Storage: Client stores the JWT securely (more on this in the Storage Options section)
  6. Authenticated Requests: Client includes the JWT in the Authorization header for subsequent requests
  7. Token Verification: Server verifies the signature and checks expirationβ€”no database lookup needed
  8. Access Granted: If valid, server processes the request using claims from the token

Here's a visual representation:

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”                                  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Client β”‚                                  β”‚ Server β”‚
β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜                                  β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
    β”‚                                           β”‚
    β”‚  POST /login {username, password}         β”‚
    │──────────────────────────────────────────>β”‚
    β”‚                                           β”‚
    β”‚                                    [Verify credentials]
    β”‚                                    [Generate JWT]
    β”‚                                           β”‚
    β”‚  200 OK {token: "eyJhbG..."}              β”‚
    β”‚<──────────────────────────────────────────│
    β”‚                                           β”‚
[Store token]                                   β”‚
    β”‚                                           β”‚
    β”‚  GET /api/profile                         β”‚
    β”‚  Authorization: Bearer eyJhbG...          β”‚
    │──────────────────────────────────────────>β”‚
    β”‚                                           β”‚
    β”‚                                    [Verify signature]
    β”‚                                    [Check expiration]
    β”‚                                    [Extract claims]
    β”‚                                           β”‚
    β”‚  200 OK {user data}                       β”‚
    β”‚<──────────────────────────────────────────│
    β”‚                                           β”‚

Pro tip: The beauty of JWTs is that step 7 (token verification) doesn't require a database query. The server can verify authenticity and extract user information directly from the token, making your API highly scalable.

Implementation Examples

Let's look at practical implementations across popular programming languages and frameworks.

Node.js with jsonwebtoken

const jwt = require('jsonwebtoken');
const bcrypt = require('bcrypt');

// Login endpoint
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  
  // Find user in database
  const user = await User.findOne({ username });
  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' });
  }
  
  // Create JWT
  const token = jwt.sign(
    { 
      sub: user.id,
      email: user.email,
      role: user.role 
    },
    process.env.JWT_SECRET,
    { 
      expiresIn: '15m',
      issuer: 'https://api.example.com',
      audience: 'https://example.com'
    }
  );
  
  res.json({ token });
});

// Protected route middleware
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: 'No token provided' });
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(403).json({ error: 'Invalid or expired token' });
  }
};

// Use the middleware
app.get('/api/profile', authenticateToken, (req, res) => {
  res.json({ userId: req.user.sub, email: req.user.email });
});

Python with PyJWT

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

app = Flask(__name__)
SECRET_KEY = 'your-secret-key'

@app.route('/login', methods=['POST'])
def login():
    data = request.get_json()
    username = data.get('username')
    password = data.get('password')
    
    # Verify credentials (simplified)
    user = verify_credentials(username, password)
    if not user:
        return jsonify({'error': 'Invalid credentials'}), 401
    
    # Create JWT
    payload = {
        'sub': user['id'],
        'email': user['email'],
        'role': user['role'],
        'exp': datetime.datetime.utcnow() + datetime.timedelta(minutes=15),
        'iat': datetime.datetime.utcnow()
    }
    
    token = jwt.encode(payload, SECRET_KEY, algorithm='HS256')
    return jsonify({'token': 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:
            # Remove 'Bearer ' prefix
            token = token.split(' ')[1]
            decoded = jwt.decode(token, SECRET_KEY, algorithms=['HS256'])
            request.user = decoded
        except jwt.ExpiredSignatureError:
            return jsonify({'error': 'Token expired'}), 403
        except jwt.InvalidTokenError:
            return jsonify({'error': 'Invalid token'}), 403
        
        return f(*args, **kwargs)
    
    return decorator

@app.route('/api/profile')
@token_required
def profile():
    return jsonify({'userId': request.user['sub']})

Go with golang-jwt

package main

import (
    "time"
    "github.com/golang-jwt/jwt/v5"
)

type Claims struct {
    UserID string `json:"sub"`
    Email  string `json:"email"`
    Role   string `json:"role"`
    jwt.RegisteredClaims
}

func generateToken(userID, email, role string) (string, error) {
    claims := Claims{
        UserID: userID,
        Email:  email,
        Role:   role,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            Issuer:    "https://api.example.com",
        },
    }
    
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
    return token.SignedString([]byte("your-secret-key"))
}

func verifyToken(tokenString string) (*Claims, error) {
    token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
        return []byte("your-secret-key"), nil
    })
    
    if err != nil {
        return nil, err
    }
    
    if claims, ok := token.Claims.(*Claims); ok && token.Valid {
        return claims, nil
    }
    
    return nil, jwt.ErrSignatureInvalid
}

Security Best Practices

JWTs are only as secure as your implementation. Follow these practices to avoid common vulnerabilities:

Token Expiration

Always set short expiration times for access tokens. A good rule of thumb:

Short-lived tokens limit the damage if a token is compromised. Use refresh tokens (covered below) to issue new access tokens without requiring the user to log in again.

Algorithm Selection

Never use the none algorithm in production. Always explicitly specify and validate the algorithm:

// Bad - accepts any algorithm
jwt.verify(token, secret);

// Good - explicitly requires HS256
jwt.verify(token, secret, { algorithms: ['HS256'] });

For microservices or when you need to distribute public keys, prefer RS256 over HS256. With RS256, services only need the public key to verify tokens, while only the authentication service holds the private key for signing.

Secret Key Management

Your JWT secret is the foundation of security. Treat it like a password:

Validate All Claims

Don't just verify the signatureβ€”validate the claims too:

const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256'],
  issuer: 'https://api.example.com',
  audience: 'https://example.com',
  clockTolerance: 30 // Allow 30 seconds clock skew
});

// Additional validation
if (decoded.exp < Date.now() / 1000) {
  throw new Error('Token expired');
}

Security Checklist

Do Don't
Use short expiration times (15 min) Set tokens to never expire
Store in httpOnly cookies for web apps Store in localStorage (XSS vulnerable)
Use HTTPS in production Send tokens over unencrypted HTTP
Validate issuer, audience, and expiration Only check the signature
Use strong, random secrets (256+ bits) Use weak or predictable secrets
Implement refresh token rotation Reuse the same refresh token indefinitely
Log token verification failures Ignore suspicious activity
Use RS256 for distributed systems Share HS256 secrets across services

Pro tip: Enable rate limiting on your authentication endpoints to prevent brute force attacks. Even with JWTs, your login endpoint is still vulnerable to credential stuffing.

Refresh Tokens and Token Rotation

Access tokens should be short-lived, but forcing users to log in every 15 minutes creates a terrible user experience. The solution? Refresh tokens.

How Refresh Tokens Work

When a user logs in, issue two tokens:

  1. Access token: Short-lived (15 min), used for API requests
  2. Refresh token: Long-lived (7-30 days), used only to obtain new access tokens

When the access token expires, the client sends the refresh token to a dedicated endpoint to get a new access token. This happens transparently without user interaction.

Implementation Example

// Login endpoint returns both tokens
app.post('/login', async (req, res) => {
  // ... verify credentials ...
  
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  const refreshToken = jwt.sign(
    { sub: user.id, type: 'refresh' },
    process.env.REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  
  // Store refresh token in database for revocation capability
  await RefreshToken.create({
    token: refreshToken,
    userId: user.id,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  });
  
  res.json({ accessToken, refreshToken });
});

// Refresh endpoint
app.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }
  
  try {
    // Verify refresh token
    const decoded = jwt.verify(refreshToken, process.env.REFRESH_SECRET);
    
    // Check if token exists in database (not revoked)
    const storedToken = await RefreshToken.findOne({
      token: refreshToken,
      userId: decoded.sub
    });
    
    if (!storedToken) {
      return res.status(403).json({ error: 'Invalid refresh token' });
    }
    
    // Issue new access token
    const newAccessToken = jwt.sign(
      { sub: decoded.sub, role: decoded.role },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    res.json({ accessToken: newAccessToken });
  } catch (err) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }
});

Refresh Token Rotation

For maximum security, implement refresh token rotation. Each time a refresh token is used, issue a new refresh token and invalidate the old one:

// Enhanced refresh endpoint with rotation
app.post('/refresh', async (req, res) => {
  const { refreshToken } = req.body;
  
  // ... verify token ...
  
  // Issue new access token AND new refresh token
  const newAccessToken = jwt.sign(
    { sub: decoded.sub, role: decoded.role },
    process.env.JWT_SECRET,
    { expiresIn: '15m' }
  );
  
  const newRefreshToken = jwt.sign(
    { sub: decoded.sub, type: 'refresh' },
    process.env.REFRESH_SECRET,
    { expiresIn: '7d' }
  );
  
  // Delete old refresh token
  await RefreshToken.deleteOne({ token: refreshToken });
  
  // Store new refresh token
  await RefreshToken.create({
    token: newRefreshToken,
    userId: decoded.sub,
    expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
  });
  
  res.json({ 
    accessToken: newAccessToken,
    refreshToken: newRefreshToken 
  });
});

This approach detects token theft. If an attacker uses a stolen refresh token, the legitimate user's next refresh attempt will fail, alerting you to the compromise.

JWT vs Session-Based Authentication

Should you use JWTs or traditional server-side sessions? The answer depends on your architecture and requirements.

When to Use JWTs

When to Use Sessions

Comparison Table

Feature JWT Sessions
Storage Client-side (token) Server-side (session store)
Scalability Excellent (stateless) Requires shared session store or sticky sessions
Revocation Difficult (requires blacklist) Immediate (delete session)
Size Larger (sent with every request) Smaller (just session ID)
Security Vulnerable to XSS if stored in localStorage Vulnerable to CSRF if not protected
Cross-domain Easy (include in Authorization header) Difficult (cookie domain restrictions)
Mobile apps Ideal Awkward (no cookie support)

Quick tip: You can combine both approaches. Use sessions for your main web application and JWTs for your mobile API. They're not mutually exclusive.

Common Mistakes to Avoid

Even experienced developers make these JWT mistakes. Learn from others' errors:

1. Storing Sensitive Data in the Payload

JWTs are encoded, not encrypted. Anyone can decode a JWT and read its contents. Never include:

If you need to include sensitive data, use JWE (JSON Web Encryption) instead of JWS (JSON Web Signature).

2. Not Validating the Algorithm

The infamous "none" algorithm vulnerability