JSON Tools

JSON Web Tokens: Structure, Security and Usage

The Debuggers Engineering Team
12 min read

TL;DR

  • A JWT has three Base64URL-encoded parts: header (algorithm), payload (claims), and signature (verification)
  • Use RS256 (asymmetric) for public APIs and multi-service architectures. Use HS256 (symmetric) only for single-server setups with a strong secret
  • Access tokens should expire in 15-30 minutes. Refresh tokens should expire in 7-30 days and be stored in HTTP-only cookies
  • Never store JWTs in localStorage. It is vulnerable to XSS. Use HTTP-only, secure, SameSite cookies instead

Table of Contents

Authentication flow diagram showing JWT token exchange between client and server

What Is a JWT

A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe way to represent claims between two parties. It is used primarily for authentication: after a user logs in, the server creates a JWT containing the user's identity and permissions, signs it, and sends it to the client. The client includes this token in subsequent requests to prove its identity.

JWTs are self-contained: the token itself contains all the information needed to verify the user's identity. The server does not need to look up a session in a database. This makes JWTs ideal for stateless architectures and microservices where multiple servers need to verify the same token independently.

A typical JWT looks like this:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMTIzIiwicm9sZSI6ImFkbWluIiwiZXhwIjoxNzA5NjgzMjAwfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Three Base64URL-encoded strings separated by dots. Each dot separates a different part: header, payload, and signature.

The Three Parts

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

The header specifies the signing algorithm (alg) and the token type (typ). The algorithm determines how the signature is computed and verified.

Payload (Claims)

{
  "sub": "user_123",
  "name": "Alice Smith",
  "role": "admin",
  "iat": 1709596800,
  "exp": 1709683200,
  "iss": "https://auth.example.com",
  "aud": "https://api.example.com"
}

Claims are key-value pairs in the payload. Standard (registered) claims include:

ClaimFull NamePurpose
subSubjectWho the token represents (user ID)
issIssuerWho created the token (your auth server)
audAudienceWho the token is intended for (your API)
expExpirationWhen the token expires (Unix timestamp)
iatIssued AtWhen the token was created
nbfNot BeforeToken is not valid before this time
jtiJWT IDUnique identifier for the token (for revocation)

You can add custom claims (role, permissions, tenantId) but keep them minimal. The payload is Base64URL-encoded, not encrypted. Anyone with the token can read the payload.

Signature

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

The signature prevents tampering. If anyone modifies the header or payload, the signature verification fails and the token is rejected. The signature does not encrypt the data; it only verifies integrity.

Use our JWT Debugger to decode any token and inspect all three parts visually.

JWT token structure diagram with header payload and signature sections

Signing Algorithms

HS256 (HMAC with SHA-256)

Symmetric: uses the same secret key for signing and verification.

const jwt = require('jsonwebtoken');
const secret = 'your-256-bit-secret';

// Sign
const token = jwt.sign({ userId: '123' }, secret, { expiresIn: '1h' });

// Verify
const decoded = jwt.verify(token, secret);

Use when: Single server or all servers share the same secret. Simple setup, fast computation.

Risk: If the secret leaks, anyone can forge tokens. Rotate secrets regularly.

RS256 (RSA with SHA-256)

Asymmetric: uses a private key for signing and a public key for verification.

const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');

// Sign (only the auth server has the private key)
const token = jwt.sign({ userId: '123' }, privateKey, { 
  algorithm: 'RS256', 
  expiresIn: '1h' 
});

// Verify (any service can verify with the public key)
const decoded = jwt.verify(token, publicKey, { algorithms: ['RS256'] });

Use when: Multiple services need to verify tokens (microservices), public APIs, or when you cannot securely distribute a shared secret.

ES256 (ECDSA with SHA-256)

Asymmetric like RS256 but uses elliptic curve cryptography. Shorter keys, faster computation, same security level as RS256 with 2048-bit keys.

Recommendation for 2026: Use ES256 for new projects. Use RS256 if you need compatibility with older libraries. Avoid HS256 for multi-server architectures.

Access Tokens vs Refresh Tokens

Access token: Short-lived (15-30 minutes). Sent with every API request. Contains user identity and permissions. If stolen, the attacker has limited time to use it.

Refresh token: Long-lived (7-30 days). Used only to get new access tokens. Stored securely (HTTP-only cookie). Can be revoked server-side.

The flow:

  1. User logs in with credentials
  2. Server returns access token (15 min) + refresh token (30 days)
  3. Client sends access token with every API request
  4. When access token expires (401 response), client sends refresh token to get a new access token
  5. If refresh token is expired or revoked, user must log in again
// Refresh token endpoint
app.post('/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;
  
  // Verify the refresh token
  const decoded = jwt.verify(refreshToken, REFRESH_SECRET);
  
  // Check if the token has been revoked
  const isRevoked = await db.revokedTokens.exists(decoded.jti);
  if (isRevoked) return res.status(401).json({ error: 'Token revoked' });
  
  // Issue new access token
  const accessToken = jwt.sign(
    { userId: decoded.sub, role: decoded.role },
    ACCESS_SECRET,
    { expiresIn: '15m' }
  );
  
  res.json({ accessToken });
});

Token Storage

StorageXSS SafeCSRF SafeRecommendation
localStorageNoYesNever use for tokens
sessionStorageNoYesNever use for tokens
HTTP-only cookieYesNo (needs CSRF protection)Best option
Memory (variable)YesYesGood for SPAs

HTTP-only cookies are the safest storage because JavaScript cannot read them, making XSS attacks unable to steal the token. Add CSRF protection (SameSite=Strict or CSRF tokens) to prevent cross-site request forgery.

// Set refresh token as HTTP-only cookie
res.cookie('refreshToken', refreshToken, {
  httpOnly: true,    // Cannot be read by JavaScript
  secure: true,      // Only sent over HTTPS
  sameSite: 'strict', // Not sent with cross-site requests
  maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
  path: '/auth/refresh', // Only sent to this endpoint
});

For more on JWT storage security risks, see our JWT localStorage security guide.

Common JWT Vulnerabilities

Algorithm confusion (alg: "none")

Some JWT libraries accept "alg": "none", which means no signature verification. An attacker changes the header to {"alg": "none"}, removes the signature, and the server accepts the modified token.

Fix: Always specify allowed algorithms when verifying:

jwt.verify(token, secret, { algorithms: ['HS256'] }); // Only accept HS256

Secret brute force

If you use HS256 with a weak secret (e.g., "password123"), attackers can brute-force it offline. They have the token (header + payload) and the signature. They try millions of secrets per second until one produces a matching signature.

Fix: Use a minimum 256-bit random secret. Generate with:

openssl rand -hex 32

Token not validated on the server

Some developers decode the JWT on the client and trust the claims without server-side verification. An attacker modifies the payload (changes role from "user" to "admin"), re-encodes it, and the server never checks the signature.

Fix: Always verify the signature server-side on every request. Never trust decoded token claims without verification.

Tokens that never expire

Tokens without exp claims are valid forever. If stolen, the attacker has permanent access.

Fix: Always set exp. Access tokens: 15-30 minutes. Refresh tokens: 7-30 days maximum.

For a comprehensive list of JWT security mistakes, see our JWT security mistakes guide.

Building a JWT Auth Flow

Complete example with Express.js:

import jwt from 'jsonwebtoken';
import bcrypt from 'bcryptjs';

const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET!;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET!;

// Login
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await db.users.findByEmail(email);
  
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  
  const accessToken = jwt.sign(
    { sub: user.id, role: user.role },
    ACCESS_SECRET,
    { expiresIn: '15m', issuer: 'your-app' }
  );
  
  const refreshToken = jwt.sign(
    { sub: user.id, jti: crypto.randomUUID() },
    REFRESH_SECRET,
    { expiresIn: '30d', issuer: 'your-app' }
  );
  
  res.cookie('refreshToken', refreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000,
  });
  
  res.json({ accessToken });
});

// Protected route middleware
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'No token' });
  
  try {
    req.user = jwt.verify(token, ACCESS_SECRET, { 
      algorithms: ['HS256'],
      issuer: 'your-app',
    });
    next();
  } catch {
    res.status(401).json({ error: 'Invalid token' });
  }
}

JWT in Microservices

In a microservices architecture, the auth service signs tokens and all other services verify them:

  1. Auth service: Has the private key (RS256) or shared secret (HS256). Handles login, token creation, and refresh
  2. API services: Have the public key (RS256) or shared secret (HS256). Verify tokens on every request
  3. API Gateway: Can verify tokens centrally so individual services do not need to implement verification

Use RS256 in microservices so only the auth service needs the private key. Other services only need the public key, which cannot be used to forge tokens.

Microservices architecture showing token flow between services

Debugging JWTs

When authentication fails, debugging the token is the first step:

  1. Decode the token with our JWT Debugger. Check the header (correct algorithm?), payload (correct claims, not expired?), and whether the signature is valid
  2. Check the expiry - the exp claim is a Unix timestamp. Compare it to the current time
  3. Check the issuer - does iss match what your server expects?
  4. Check the audience - does aud match your API's expected audience?
  5. Verify the signature - paste the token and your secret/public key into the debugger

For validating the JSON structure of JWT payloads, use our JSON Formatter.

For the complete JSON developer reference, see our pillar guide: The Complete JSON Guide for Web Developers.

The Debuggers provides authentication architecture consulting and secure API development for web and mobile applications.

Frequently Asked Questions

Should I use JWTs or session cookies?

Use JWTs for stateless APIs, microservices, and mobile app backends where you need to verify identity without a database lookup. Use session cookies for traditional server-rendered web applications where you can manage sessions in a database. JWTs scale better across services but are harder to revoke. Sessions are easier to revoke but require shared session storage in multi-server setups.

Can JWTs be revoked?

Not directly, because JWTs are self-contained and verified without a server lookup. To revoke a JWT, you need a server-side blocklist of revoked token IDs (the jti claim). Check the blocklist on every request. This partially negates the stateless advantage of JWTs but is necessary for security (logout, password change, account compromise). An alternative is to keep access tokens very short-lived (5-15 minutes) so they expire quickly after revocation.

What should I put in a JWT payload?

Put the minimum information needed to identify and authorise the user: user ID (sub), role, and permissions. Do not put sensitive data (passwords, credit card numbers, personal health information) because the payload is only encoded, not encrypted. Anyone with the token can decode and read the payload. Keep the payload small: large tokens increase request size on every API call.

How do I handle token expiry in a single-page application?

Implement silent refresh. When any API call returns 401 (token expired), the SPA automatically sends the refresh token (from an HTTP-only cookie) to the refresh endpoint, gets a new access token, and retries the failed request. Use an HTTP interceptor (Axios interceptor, fetch wrapper) so this is transparent to the rest of the application. Store the access token in memory (a JavaScript variable), not in localStorage.


Debug your JWT tokens now

Use our free JWT Debugger to decode tokens, inspect claims, and verify signatures. Validate JSON payloads with our JSON Formatter.

Building authentication for your app? The Debuggers provides secure authentication architecture and implementation services.

Need Help Implementing This in a Real Project?

Our team supports end-to-end development for web and mobile software, from architecture to launch.

json web tokens explainedjwt structurejwt securityjwt refresh tokenjwt authentication guide

Found this helpful?

Join thousands of developers using our tools to write better code, faster.