BytePane

JWT Tokens Explained: Structure, Security, and Best Practices

Security15 min read

What Is a JWT?

A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe token format used to securely transmit claims between two parties. JWTs are the backbone of modern authentication: when you log into an application and subsequent requests are authenticated without hitting the database, a JWT is almost certainly involved.

Unlike session-based authentication where the server stores session data, JWTs are stateless. All the information needed to verify the user is contained within the token itself. The server signs the token with a secret key, and any service that knows the key (or has the public key) can verify its authenticity without network calls.

// A JWT looks like this (three Base64-encoded parts separated by dots):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

// Three parts:
// 1. Header    → algorithm and token type
// 2. Payload   → claims (user data, expiration, etc.)
// 3. Signature → cryptographic proof of authenticity

To decode and inspect any JWT instantly, paste it into our JWT Decoder. It splits the token into its three parts, decodes the header and payload, and shows the signature -- all in your browser without sending the token to any server.

The Three Parts of a JWT

1. Header

The header is a JSON object that specifies the signing algorithm and the token type. It is Base64Url-encoded to form the first part of the JWT.

{
  "alg": "HS256",    // signing algorithm
  "typ": "JWT"       // token type
}

// Base64Url-encoded: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9

// Common algorithms:
// HS256 — HMAC with SHA-256 (symmetric, shared secret)
// RS256 — RSA with SHA-256 (asymmetric, public/private key)
// ES256 — ECDSA with P-256 and SHA-256 (asymmetric, smaller keys)
// EdDSA — Edwards-curve DSA (asymmetric, modern, fast)

2. Payload (Claims)

The payload contains claims -- statements about the user and metadata. Claims are categorized into three types: registered (standard), public (community-defined), and private (application-specific).

{
  // Registered claims (standardized by RFC 7519)
  "iss": "https://auth.example.com",    // issuer
  "sub": "user-12345",                   // subject (user ID)
  "aud": "https://api.example.com",     // audience
  "exp": 1709856000,                     // expiration time (Unix timestamp)
  "nbf": 1709852400,                     // not before (token not valid before this)
  "iat": 1709852400,                     // issued at
  "jti": "a1b2c3d4-e5f6-7890",          // JWT ID (unique identifier)

  // Private claims (application-specific)
  "role": "admin",
  "permissions": ["read", "write", "delete"],
  "email": "[email protected]"
}
ClaimFull NamePurpose
issIssuerWho created and signed the token
subSubjectWho the token is about (usually user ID)
audAudienceWho the token is intended for
expExpirationWhen the token expires (Unix timestamp)
nbfNot BeforeToken is invalid before this time
iatIssued AtWhen the token was created
jtiJWT IDUnique identifier (prevents replay attacks)

3. Signature

The signature ensures the token has not been tampered with. It is created by encoding the header and payload, then signing them with the algorithm specified in the header.

// HS256 signature formula:
HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret
)

// RS256 signature formula:
RSASHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  privateKey
)

// The signature is what makes JWTs tamper-proof:
// If anyone changes the header or payload, the signature won't match
// If anyone tries to re-sign, they don't have the secret/private key

The header and payload are Base64Url-encoded, not encrypted. Anyone can decode them -- the signature only proves authenticity, not confidentiality. To understand Base64 encoding in depth, read our Base64 Encoding Explained guide, or use our Base64 Encoder/Decoder tool.

Signing Algorithms: HS256 vs RS256 vs ES256

AlgorithmTypeKeyBest For
HS256SymmetricShared secret (256 bits)Monoliths, single-server apps
RS256AsymmetricRSA key pair (2048+ bits)Microservices, third-party verification
ES256AsymmetricECDSA P-256 key pairMobile apps, performance-sensitive
EdDSAAsymmetricEd25519 key pairModern systems, fastest asymmetric
// HS256: Same secret for signing AND verification
//   ✓ Simple setup
//   ✓ Fast
//   ✗ Secret must be shared with every verifier
//   ✗ If one service is compromised, all are

// RS256: Private key signs, public key verifies
//   ✓ Only auth server has private key
//   ✓ Any service can verify with public key
//   ✓ Public key can be freely distributed (JWKS endpoint)
//   ✗ Slower than HS256
//   ✗ Larger key size

// ES256: Like RS256 but with smaller keys and faster signing
//   ✓ 256-bit key ≈ 3072-bit RSA security
//   ✓ Smaller tokens
//   ✓ Faster than RS256
//   ✗ Slightly less library support than RS256

HS256 uses HMAC-SHA256 under the hood. To understand how HMAC works, see the HMAC section in our Hash Functions Explained guide. For generating secure secrets, use our Password Generator with maximum length and all character types enabled.

Creating and Verifying JWTs

Node.js with jsonwebtoken

// npm install jsonwebtoken
const jwt = require('jsonwebtoken');

const SECRET = process.env.JWT_SECRET;  // at least 256 bits (32+ chars)

// Create a token
function createToken(user) {
  return jwt.sign(
    {
      sub: user.id,
      email: user.email,
      role: user.role,
    },
    SECRET,
    {
      expiresIn: '15m',        // 15 minutes
      issuer: 'https://api.example.com',
      audience: 'https://app.example.com',
    }
  );
}

// Verify a token
function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, SECRET, {
      issuer: 'https://api.example.com',
      audience: 'https://app.example.com',
      algorithms: ['HS256'],    // ALWAYS specify allowed algorithms
    });
    return { valid: true, payload: decoded };
  } catch (error) {
    return { valid: false, error: error.message };
  }
}

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

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

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

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

Python with PyJWT

# pip install pyjwt
import jwt
from datetime import datetime, timedelta, timezone

SECRET = "your-256-bit-secret"

# Create a token
def create_token(user_id: str, role: str) -> str:
    payload = {
        "sub": user_id,
        "role": role,
        "iat": datetime.now(timezone.utc),
        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
        "iss": "https://api.example.com",
    }
    return jwt.encode(payload, SECRET, algorithm="HS256")

# Verify a token
def verify_token(token: str) -> dict:
    try:
        return jwt.decode(
            token,
            SECRET,
            algorithms=["HS256"],       # ALWAYS specify algorithms
            issuer="https://api.example.com",
        )
    except jwt.ExpiredSignatureError:
        raise ValueError("Token has expired")
    except jwt.InvalidTokenError as e:
        raise ValueError(f"Invalid token: {e}")

# FastAPI dependency
from fastapi import Depends, HTTPException, Header

async def get_current_user(authorization: str = Header(...)):
    if not authorization.startswith("Bearer "):
        raise HTTPException(status_code=401, detail="Invalid auth header")
    token = authorization.split(" ")[1]
    try:
        return verify_token(token)
    except ValueError as e:
        raise HTTPException(status_code=401, detail=str(e))

RS256 with Public/Private Keys

// Generate RSA key pair:
// openssl genrsa -out private.pem 2048
// openssl rsa -in private.pem -pubout -out public.pem

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

const PRIVATE_KEY = fs.readFileSync('private.pem');
const PUBLIC_KEY = fs.readFileSync('public.pem');

// Auth server: sign with PRIVATE key
const token = jwt.sign({ sub: 'user-123' }, PRIVATE_KEY, {
  algorithm: 'RS256',
  expiresIn: '15m',
});

// Any service: verify with PUBLIC key (no secret needed)
const decoded = jwt.verify(token, PUBLIC_KEY, {
  algorithms: ['RS256'],
});

// JWKS endpoint (JSON Web Key Set)
// Most auth providers expose their public keys at:
// https://auth.example.com/.well-known/jwks.json
// Services fetch the public key from this endpoint to verify tokens

The JWT Authentication Flow

// 1. User logs in
POST /auth/login
Body: { "email": "[email protected]", "password": "..." }

// 2. Server validates credentials, creates tokens
Response: {
  "accessToken": "eyJhbGci...",     // short-lived (15 min)
  "refreshToken": "eyJhbGci...",    // long-lived (7-30 days)
  "expiresIn": 900                   // seconds until access token expires
}
// refreshToken is also set as HttpOnly cookie

// 3. Client stores access token in memory (NOT localStorage)
let accessToken = response.accessToken;

// 4. Client sends access token with every API request
GET /api/users
Authorization: Bearer eyJhbGci...

// 5. When access token expires (401 response):
POST /auth/refresh
Cookie: refreshToken=eyJhbGci...    // sent automatically

// 6. Server validates refresh token, issues new access token
Response: {
  "accessToken": "eyJhbGci...",     // new token
  "expiresIn": 900
}

// 7. If refresh token is expired or invalid: redirect to login

Security Best Practices

  1. Always specify allowed algorithms -- The "none" algorithm attack exploits libraries that accept the algorithm from the token header. Always pass algorithms: ['HS256'] (or your chosen algorithm) when verifying.
  2. Use short expiration for access tokens -- Access tokens should expire in 5-15 minutes. If a token is stolen, the window of vulnerability is limited.
  3. Store tokens securely -- Access tokens in memory (JavaScript variable). Refresh tokens in HttpOnly, Secure, SameSite=Strict cookies. Never in localStorage or sessionStorage.
  4. Validate all registered claims -- Check exp (expiration), iss (issuer), and aud (audience) on every verification. Do not trust a token just because the signature is valid.
  5. Implement token rotation for refresh tokens -- Issue a new refresh token every time one is used. If an old refresh token is replayed, invalidate all tokens for that user (indicating theft).
  6. Never put sensitive data in the payload -- JWTs are encoded, not encrypted. Anyone can decode the payload. Do not include passwords, SSNs, or API keys.
  7. Use strong secrets -- For HS256, use at least 256 bits (32 characters) of cryptographically random data. A weak secret can be brute-forced.
  8. Consider token size -- Every claim adds to the token size, and tokens are sent with every request. Keep payloads minimal. If you need extensive permissions data, fetch it from the server after verifying the token.

Common JWT Attacks and Defenses

AttackHow It WorksDefense
Algorithm: noneAttacker sets alg to "none", removes signatureAlways specify allowed algorithms in verify()
Algorithm confusionSwitches RS256 to HS256, uses public key as HMAC secretSpecify algorithms; use separate verification paths for symmetric/asymmetric
Token theft (XSS)JavaScript reads token from localStorageStore tokens in memory or HttpOnly cookies, implement CSP headers
Brute-force secretWeak HMAC secret is guessableUse 256+ bit cryptographically random secrets
Replay attackStolen token is reused before expirationShort expiration (5-15 min), jti claim for critical operations
Refresh token theftLong-lived refresh token is stolenToken rotation, reuse detection, HttpOnly cookies

The "alg: none" Attack in Detail

// VULNERABLE code — DO NOT do this:
const decoded = jwt.verify(token, secret);
// This reads the algorithm from the token header!
// If the attacker sets "alg": "none", no verification happens

// SAFE code — always specify algorithms:
const decoded = jwt.verify(token, secret, {
  algorithms: ['HS256']   // only accept HS256
});

// If the token has "alg": "none" or "alg": "RS256",
// verification will fail immediately

Token Revocation Strategies

One of the biggest limitations of JWTs is that they cannot be revoked once issued (they are stateless). If a user logs out or their account is compromised, the access token remains valid until it expires. Here are strategies to address this.

StrategyHow It WorksTrade-off
Short expirationAccess tokens expire in 5-15 minutesRequires refresh flow; small revocation delay
Token blocklistStore revoked token JTIs in RedisAdds a database check (partially defeats stateless benefit)
Token versionStore version in user record; token has version claimRequires DB lookup; changing version invalidates all tokens
Refresh token rotationNew refresh token per use; old one invalidatedReuse detection catches theft; slightly more complex
// Token blocklist with Redis (Node.js)
const Redis = require('ioredis');
const redis = new Redis();

// On logout: add token to blocklist
async function revokeToken(token) {
  const decoded = jwt.decode(token);
  const ttl = decoded.exp - Math.floor(Date.now() / 1000);
  if (ttl > 0) {
    await redis.setex(`blocklist:${decoded.jti}`, ttl, '1');
  }
}

// On every request: check blocklist
async function isTokenRevoked(token) {
  const decoded = jwt.decode(token);
  const revoked = await redis.get(`blocklist:${decoded.jti}`);
  return !!revoked;
}

// Middleware
async function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Missing token' });

  if (await isTokenRevoked(token)) {
    return res.status(401).json({ error: 'Token has been revoked' });
  }

  try {
    req.user = jwt.verify(token, SECRET, { algorithms: ['HS256'] });
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid token' });
  }
}

JWT vs Session Cookies: When to Use Each

FactorJWTSession Cookie
StateStateless (data in token)Stateful (data on server)
ScalabilityNo shared state neededRequires shared session store
RevocationDifficult (needs blocklist)Easy (delete from store)
Cross-domainWorks via Authorization headerLimited by cookie domain rules
Mobile appsNative supportRequires cookie handling
Best forAPIs, microservices, mobileTraditional web apps, SSR

For a deeper comparison with code examples and architectural guidance, read our dedicated JWT vs Session Cookies article. For understanding the HTTP layer that carries these tokens, see our HTTP Status Codes Guide.

When NOT to Use JWTs

JWTs are not always the right choice. Here are situations where session-based authentication or other approaches work better.

  • When immediate revocation is critical -- Banking, healthcare, and other high-security applications need the ability to instantly invalidate sessions. Session cookies do this naturally; JWTs require a blocklist.
  • When you only have one server -- The stateless benefit of JWTs does not matter if you have a single server. Session cookies are simpler to implement and more secure by default.
  • When token size matters -- JWTs can be 500+ bytes. If they are sent in cookies, you may hit the 4KB cookie size limit. Session IDs are typically 32-64 bytes.
  • When you need to store lots of user data -- Putting extensive user data in the JWT payload makes it large. Store the data server-side and use the token only for identity.

Frequently Asked Questions

Where should I store JWT tokens on the client?

Store access tokens in memory (a JavaScript variable) and refresh tokens in HttpOnly, Secure, SameSite cookies. Never store tokens in localStorage or sessionStorage -- they are accessible to any JavaScript on the page, making them vulnerable to XSS attacks. HttpOnly cookies cannot be read by JavaScript, protecting against token theft. If you must use localStorage (for example, in mobile webviews), keep access tokens short-lived (5-15 minutes) and implement refresh token rotation.

What is the difference between HS256 and RS256?

HS256 (HMAC-SHA256) uses a single shared secret for both signing and verification. It is simpler and faster, but every service that verifies tokens needs the same secret. RS256 (RSA-SHA256) uses a key pair: a private key for signing and a public key for verification. Only the auth server needs the private key; any service can verify tokens using the public key. Use HS256 for monolithic applications. Use RS256 for microservices where multiple services verify tokens independently.

How do I handle JWT token expiration and refresh?

Use a two-token strategy: a short-lived access token (5-15 minutes) and a long-lived refresh token (7-30 days). When the access token expires, send the refresh token to a dedicated endpoint to get a new access token without requiring re-login. Implement token rotation -- issue a new refresh token each time one is used, and invalidate the old one. If a refresh token is used twice (indicating theft), invalidate all tokens for that user.

Decode and Inspect JWT Tokens

Paste any JWT into our free decoder to see the header, payload, and signature. Verify the structure, check claims, and debug authentication issues -- all in your browser.

Open JWT Decoder

Related Articles