JWT Tokens Explained: Structure, Security & Common Mistakes

JSON Web Tokens (JWTs) are everywhere in modern web development — powering authentication, authorization, and API security. But misunderstanding how they work leads to security vulnerabilities that get applications breached. This guide covers JWT structure, signing algorithms, security best practices, and the mistakes you need to avoid.

🔑 Try the Tool

Decode any JWT instantly — view header, payload, expiration status, and claim explanations.

Open JWT Decoder →

What Is a JWT?

A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe token format defined by RFC 7519. It provides a standardized way to transmit claims — assertions about an entity (typically a user) — between two parties.

JWTs solve a fundamental problem in stateless architectures: how does a server know who you are without checking a database on every request? After authentication, the server issues a JWT containing the user's identity and permissions. The client sends this token with each subsequent request, and the server can verify it using cryptographic signatures — no database lookup needed.

JWTs are used for:

JWT Structure: Three Parts, Three Purposes

Every JWT consists of three Base64URL-encoded parts separated by dots:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkphbmUgRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.POstGetfAytaZS82wHcjoTyoqhMyxXiWdR7Nn7A29DNSl0EiXLdwJ6xC6AfgZWF1bOsS_TuYI3OG85AmiExREkrS6tDfTQ2B3WXlrr-wp5AokiRbz3_oB4OxG-W9KcEEbDRcZc0nH3L7LzYptiy1PtAylQGxHTWZXtGz4ht0bAecBgmpdgXMguEIcoqPJ1n3pIWk_dUZegpqx0Lka21H6XxUTxiy8OcaarA8zdnPUnV6AmNP3ecFawIFYdvJB_cm-GvpCSbr8G8y_Mllj8f4x9nBH8pQux89_6gUY618iYv7tuPWBFfEbLxtF2pZS6YC1aSfLQxaOoaBSTPoqkm

Breaking this down:

1. Header

The header is a JSON object that identifies the token type and the signing algorithm:

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

Common header fields:

2. Payload

The payload contains claims — key-value pairs carrying information. RFC 7519 defines seven registered claims:

ClaimNameDescription
issIssuerWho created the token (e.g., your auth server URL)
subSubjectWho the token is about (usually a user ID)
audAudienceWho the token is intended for
expExpirationUnix timestamp when the token expires
nbfNot BeforeUnix timestamp before which the token is not valid
iatIssued AtUnix timestamp when the token was created
jtiJWT IDUnique identifier for the token (prevents replay)

You can also add any custom claims you need — roles, permissions, email, name, etc. But remember: the payload is readable by anyone. It's Base64URL-encoded, not encrypted.

3. Signature

The signature ensures the token hasn't been tampered with. It's created by signing the encoded header and payload with a secret key:

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

The signature is the only part that provides security. Without it (or with "alg": "none"), anyone can modify the payload and the server won't know.

Base64URL: Not Base64

JWTs use Base64URL encoding, not standard Base64. The difference matters:

This makes JWTs safe to use in URLs, HTTP headers, and cookies without percent-encoding. If you try to decode a JWT with standard atob(), you'll need to convert it first:

// Decode JWT payload in JavaScript
function decodeJwtPayload(token) {
    const payload = token.split('.')[1];
    // Convert Base64URL to standard Base64
    const base64 = payload
        .replace(/-/g, '+')
        .replace(/_/g, '/');
    // Add padding
    const padded = base64 + '=='.slice(0, (4 - base64.length % 4) % 4);
    return JSON.parse(atob(padded));
}

// Python
import base64, json
payload = token.split('.')[1]
# Add padding and decode
decoded = base64.urlsafe_b64decode(payload + '=='*(-len(payload) % 4))
claims = json.loads(decoded)

Signing Algorithms: HS256 vs RS256

The two most common JWT signing algorithms have fundamentally different security models:

PropertyHS256 (Symmetric)RS256 (Asymmetric)
Key typeShared secretPrivate key + public key pair
Who signs?Anyone with the secretOnly the private key holder
Who verifies?Anyone with the secretAnyone with the public key
Best forSingle server, internal APIsMicroservices, third-party consumers
Key distributionSecret must be shared carefullyPublic key can be shared openly
PerformanceFaster (no asymmetric crypto)Slower but more secure for distributed systems
💡 Rule of Thumb

If the same service issues and verifies tokens, HS256 is fine. If different services need to verify tokens (microservices, mobile apps, third-party integrations), use RS256 so you never share the signing key.

JWT Security Best Practices

1. Always Validate the Signature

Never trust a JWT without verifying its signature. Decoding is not verification. Libraries like jsonwebtoken (Node.js) handle this automatically — don't roll your own.

2. Set Short Expiration Times

Access tokens should expire quickly — 5 to 15 minutes for sensitive applications. Use refresh tokens (stored server-side or in HTTP-only cookies) to issue new access tokens without re-authentication.

3. Validate All Claims

Don't just check the signature. Also validate:

4. Don't Put Secrets in the Payload

The payload is not encrypted — anyone can decode it with a single line of code. Never include passwords, API keys, credit card numbers, social security numbers, or any sensitive data in a JWT payload.

5. Use HTTPS Everywhere

JWTs sent over HTTP can be intercepted. Always transmit tokens over HTTPS. Store them in HTTP-only, Secure, SameSite cookies when possible (not localStorage, which is vulnerable to XSS).

6. Handle Key Rotation

Use the kid (Key ID) header claim and maintain a JWKS (JSON Web Key Set) endpoint. This lets you rotate signing keys without invalidating all existing tokens.

Common JWT Mistakes

⚠ Mistake #1: Using "alg": "none"

The none algorithm means the token has no signature. If your server accepts alg: none tokens, an attacker can forge any token. Always reject unsigned tokens in production. Most JWT libraries default to rejecting none, but verify your configuration.

Mistake #2: Confusing encoding with encryption. JWT payloads are Base64URL-encoded, not encrypted. Encoding is a format, not a security mechanism. If you need the payload to be unreadable, use JWE (JSON Web Encryption) or keep sensitive data out of the token entirely.

Mistake #3: Storing JWTs in localStorage. localStorage is accessible to any JavaScript running on your page. If you have an XSS vulnerability, an attacker can steal the token. HTTP-only cookies are safer because JavaScript can't access them.

Mistake #4: Not checking expiration. If your server doesn't check the exp claim, stolen tokens work forever. Always validate expiration on every request.

Mistake #5: Bloating the payload. Every JWT is sent with every request. A token with 50 custom claims wastes bandwidth and hits header size limits. Keep payloads minimal — store detailed user data server-side and reference it by ID.

Mistake #6: No token revocation strategy. JWTs are stateless — there's no "logout" mechanism built in. If a token is compromised, it's valid until it expires. Solutions: short expiration times, token blacklists (backed by Redis or a database), or refresh token rotation.

JWT vs Sessions: When to Use What

JWTs and server-side sessions solve the same problem differently:

FactorJWTServer-side Session
StateStateless (token contains all data)Stateful (server stores session data)
ScalabilityNo shared state — scales horizontallyNeeds shared session store (Redis, DB)
RevocationHard — token valid until expiryEasy — delete the session record
SizeCan be large (all claims in token)Small (just a session ID cookie)
Best forAPIs, microservices, SPAsTraditional web apps, when revocation matters

Neither is universally better. Use JWTs when you need stateless authentication across multiple services. Use sessions when you need instant revocation and don't mind the shared state.

Decoding JWTs in Code

JavaScript (Browser)

// Quick decode without a library
const [header, payload] = token.split('.').slice(0, 2).map(part => {
    const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
    return JSON.parse(atob(base64));
});

console.log('Algorithm:', header.alg);
console.log('Expires:', new Date(payload.exp * 1000));
console.log('User:', payload.sub);

Node.js (with verification)

const jwt = require('jsonwebtoken');

// Verify and decode (ALWAYS verify in production)
try {
    const decoded = jwt.verify(token, publicKey, {
        algorithms: ['RS256'],
        issuer: 'https://auth.example.com',
        audience: 'https://api.example.com'
    });
    console.log('Valid token for user:', decoded.sub);
} catch (err) {
    console.error('Token rejected:', err.message);
}

Python

import jwt  # pip install PyJWT

# Verify and decode
try:
    decoded = jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],
        audience="https://api.example.com",
        issuer="https://auth.example.com"
    )
    print(f"Valid token for user: {decoded['sub']}")
except jwt.ExpiredSignatureError:
    print("Token has expired")
except jwt.InvalidTokenError as e:
    print(f"Token rejected: {e}")

Key Takeaways

🔑 Decode JWTs Instantly

Paste a JWT to view header, payload, signature, expiration status, and claim explanations — all client-side.

Open JWT Decoder →