Skip to main content
🔐Security

JWT (JSON Web Tokens): Structure, Security, and Best Practices

JSON Web Tokens (JWTs) are the backbone of modern authentication and authorization in web applications and APIs. They provide a compact, self-contained way...

📖 8 min read

JWT (JSON Web Tokens): Structure, Security, and Best Practices

JSON Web Tokens (JWTs) are the backbone of modern authentication and authorization in web applications and APIs. They provide a compact, self-contained way to transmit claims between parties as a JSON object. Understanding JWT internals is critical for building secure systems and performing well in system design interviews.

JWT Structure

A JWT consists of three Base64URL-encoded parts separated by dots: header.payload.signature

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6IkphbmUgRG9lIiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNjk5OTk2NDAwLCJleHAiOjE3MDAwMDAwMDB9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

The header specifies the token type and signing algorithm:

{
  "alg": "RS256",    // Signing algorithm
  "typ": "JWT",      // Token type
  "kid": "key-2024"  // Key ID (for key rotation)
}

Payload (Claims)

The payload contains claims — statements about the user and metadata:

{
  "iss": "https://auth.myapp.com",  // Issuer
  "sub": "user123",                  // Subject (user ID)
  "aud": "https://api.myapp.com",    // Audience
  "exp": 1700000000,                 // Expiration (Unix timestamp)
  "iat": 1699996400,                 // Issued at
  "nbf": 1699996400,                 // Not before
  "jti": "unique-token-id-789",      // JWT ID (prevents replay)
  "name": "Jane Doe",                // Custom claim
  "role": "admin",                   // Custom claim
  "permissions": ["read", "write"]   // Custom claim
}

Registered Claims Reference

Claim Name Purpose Required?
iss Issuer Who created the token Recommended
sub Subject Who the token is about Recommended
aud Audience Intended recipient Recommended
exp Expiration When the token expires Required
iat Issued At When the token was created Recommended
jti JWT ID Unique ID to prevent replay Optional

Signature

The signature verifies the token was not tampered with. It is created by signing the encoded header and payload with a secret or private key:

// For HMAC (HS256):
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

// For RSA (RS256):
RSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  privateKey
)

Signing Algorithms

Algorithm Type Key Best For
HS256 Symmetric (HMAC) Shared secret Single-service apps
RS256 Asymmetric (RSA) Private/public key pair Microservices, distributed systems
ES256 Asymmetric (ECDSA) Private/public key pair Mobile apps, performance-critical
PS256 Asymmetric (RSA-PSS) Private/public key pair High-security requirements

For microservice architectures, prefer asymmetric algorithms (RS256 or ES256). The auth service signs tokens with a private key, and any service can verify with the public key — no shared secret distribution needed.

Creating and Verifying JWTs

Creating a JWT (Node.js)

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

// Using RS256 (asymmetric)
const privateKey = fs.readFileSync('./keys/private.pem');

function generateAccessToken(user) {
  return jwt.sign(
    {
      sub: user.id,
      name: user.name,
      role: user.role,
      permissions: user.permissions
    },
    privateKey,
    {
      algorithm: 'RS256',
      expiresIn: '15m',
      issuer: 'https://auth.myapp.com',
      audience: 'https://api.myapp.com',
      jwtid: crypto.randomUUID()
    }
  );
}

function generateRefreshToken(user) {
  return jwt.sign(
    { sub: user.id, type: 'refresh' },
    privateKey,
    {
      algorithm: 'RS256',
      expiresIn: '7d',
      issuer: 'https://auth.myapp.com',
      jwtid: crypto.randomUUID()
    }
  );
}

Verifying a JWT

const publicKey = fs.readFileSync('./keys/public.pem');

function verifyAccessToken(token) {
  try {
    const decoded = jwt.verify(token, publicKey, {
      algorithms: ['RS256'],     // CRITICAL: Whitelist algorithms
      issuer: 'https://auth.myapp.com',
      audience: 'https://api.myapp.com',
      clockTolerance: 30         // 30 seconds clock skew tolerance
    });
    return { valid: true, payload: decoded };
  } catch (error) {
    if (error.name === 'TokenExpiredError') {
      return { valid: false, error: 'Token expired' };
    }
    if (error.name === 'JsonWebTokenError') {
      return { valid: false, error: 'Invalid token' };
    }
    return { valid: false, error: 'Verification failed' };
  }
}

// Express middleware
function authenticateJWT(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing token' });
  }

  const token = authHeader.split(' ')[1];
  const result = verifyAccessToken(token);

  if (!result.valid) {
    return res.status(401).json({ error: result.error });
  }

  req.user = result.payload;
  next();
}

Access Tokens vs Refresh Tokens

Feature Access Token Refresh Token
Purpose Authorize API requests Get new access tokens
Lifetime 15 minutes (typical) 7-30 days
Contents User claims, roles, permissions Minimal (user ID, token type)
Storage (Browser) In-memory variable HttpOnly secure cookie
Revocation Difficult (short expiry mitigates) Server-side blocklist

Token Rotation Pattern

// Refresh endpoint with token rotation
app.post('/auth/refresh', async (req, res) => {
  const { refreshToken } = req.cookies;
  if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });

  // Check if token is in the blocklist (was already rotated)
  const isRevoked = await redis.get(`revoked:${refreshToken}`);
  if (isRevoked) {
    // Possible token theft — revoke entire family
    await revokeAllUserTokens(isRevoked);
    return res.status(401).json({ error: 'Token reuse detected' });
  }

  try {
    const decoded = jwt.verify(refreshToken, publicKey, {
      algorithms: ['RS256']
    });

    // Revoke the old refresh token
    await redis.set(`revoked:${refreshToken}`, decoded.sub, 'EX', 86400 * 30);

    // Issue new token pair
    const newAccessToken = generateAccessToken(decoded);
    const newRefreshToken = generateRefreshToken(decoded);

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true, secure: true, sameSite: 'strict', maxAge: 7 * 86400 * 1000
    });
    res.json({ accessToken: newAccessToken });
  } catch (err) {
    return res.status(401).json({ error: 'Invalid refresh token' });
  }
});

Security Pitfalls and Mitigations

1. The "none" Algorithm Attack

Attackers modify the header to use "alg": "none", bypassing signature verification entirely.

// VULNERABLE — accepts any algorithm
jwt.verify(token, secret); // DO NOT DO THIS

// SECURE — whitelist specific algorithms
jwt.verify(token, publicKey, { algorithms: ['RS256'] });

2. Algorithm Confusion Attack

An attacker changes RS256 to HS256 and signs with the public key (which is publicly available). If the server uses the same public key to verify HMAC, the forged token passes verification.

Mitigation: Always specify the expected algorithm explicitly. Never allow the token to dictate the verification algorithm.

3. Weak Signing Secrets

// WEAK — easily brute-forced
const secret = 'password123';

// STRONG — 256+ bit random secret
const secret = crypto.randomBytes(64).toString('hex');
// Or use asymmetric keys (RS256/ES256) instead

4. Storing Sensitive Data in Payload

JWT payloads are Base64URL-encoded, not encrypted. Anyone can decode and read them. Never include passwords, credit card numbers, or other sensitive data in JWT claims. Use our Security Crypto Tools to decode and inspect JWT tokens.

5. Token Size Issues

Every claim increases token size. JWTs are sent with every request in the Authorization header. Large tokens impact performance, especially on mobile networks. Keep tokens under 1KB by including only essential claims.

JWT vs Session-Based Authentication

Feature JWT (Stateless) Sessions (Stateful)
Server storage None (self-contained) Session store (Redis, DB)
Scalability Excellent (no shared state) Requires sticky sessions or shared store
Revocation Difficult (need blocklist) Easy (delete session)
Cross-domain Easy (Bearer header) Complex (cookie domain issues)
Best for APIs, microservices, SPAs Server-rendered apps, simple setups

In microservice architectures, JWTs are generally preferred because each service can independently verify tokens without calling a central session store. For OAuth 2.0 flows, JWTs are the standard format for access tokens and ID tokens.

Best Practices Summary

  • Always whitelist algorithms — never let the token header dictate verification
  • Use RS256 or ES256 for distributed systems; HS256 only for single-service apps
  • Set short expiration times (15 minutes for access tokens)
  • Implement refresh token rotation with reuse detection
  • Store access tokens in memory, refresh tokens in HttpOnly secure cookies
  • Validate all registered claims: iss, aud, exp, nbf
  • Keep token payloads minimal — avoid bloating with unnecessary claims
  • Implement rate limiting on token endpoints to prevent abuse
  • Use key rotation with kid (Key ID) header parameter
  • Never store secrets or sensitive data in JWT payloads

Explore more about securing your APIs with our API Security guide and try tools at swehelper.com/tools.

Frequently Asked Questions

Are JWTs encrypted?

Standard JWTs (JWS — JSON Web Signature) are signed but not encrypted. The payload is Base64URL-encoded, which is trivially decodable. If you need encrypted tokens, use JWE (JSON Web Encryption), but this is less common. The signature only ensures integrity and authenticity, not confidentiality. Never put sensitive data in a standard JWT. For encryption concepts, see our Encryption guide.

How do I revoke a JWT before it expires?

Since JWTs are stateless, you cannot truly revoke them without introducing server-side state. Common approaches: (1) Keep tokens short-lived (15 minutes) so revocation is less critical. (2) Maintain a server-side blocklist of revoked token JTIs in Redis with TTL matching token expiry. (3) Use refresh token rotation — when a refresh token is reused, revoke the entire token family.

Should I use JWTs for session management?

JWTs work well for API authentication in distributed systems. For traditional server-rendered web apps, server-side sessions with a session ID cookie are often simpler and more secure (easier revocation, smaller cookie size). Use JWTs when you need stateless verification across multiple services, cross-domain authentication, or when building APIs for mobile or SPA clients.

What is the maximum size for a JWT?

There is no protocol-defined limit, but practical limits matter. HTTP headers are typically limited to 8KB. Most web servers default to 4-8KB header limits. Aim to keep JWTs under 1KB for optimal performance, especially on mobile networks where every byte counts. If your token exceeds 2KB, reconsider what claims you are including.

Related Articles