JWT Explained: JSON Web Tokens Tutorial with Code Examples

14 min read

JSON Web Tokens (JWTs) are everywhere in modern web development. They power authentication systems, API authorization, single sign-on, and more. At their core, JWTs are compact, URL-safe tokens built entirely on JSON. This guide breaks down how JWTs work, how to create and verify them, and the security practices you need to follow in production.

What is a JWT?

A JSON Web Token (JWT, pronounced "jot") is a compact, self-contained token format defined in RFC 7519. It allows two parties to exchange claims — statements about an entity (typically a user) — in a way that can be verified and trusted.

A JWT consists of three parts separated by dots (.):

header.payload.signature

Each part is a Base64URL-encoded string. The header and payload are JSON objects, and the signature is a cryptographic hash that ensures the token has not been tampered with. Because the header and payload are just JSON, you can use any JSON validator to check their structure once decoded.

JWT Structure Decoded

Here is a real JWT (line breaks added for readability):

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwi
aWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Let's decode each part.

1. Header

The header is a JSON object that describes the token type and the signing algorithm. Base64URL-decoding the first part gives:

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg — the signing algorithm (HS256 = HMAC-SHA256)
  • typ — the token type (always "JWT")

2. Payload

The payload contains the claims — the actual data the token carries. Decoding the second part gives:

{
  "sub": "1234567890",
  "name": "John Doe",
  "iat": 1516239022
}

This is just a plain JSON object. You can paste it into our JSON Formatter to pretty-print it, or into the JSON Validator to verify its structure.

3. Signature

The signature is created by taking the encoded header, the encoded payload, a secret key, and the algorithm specified in the header:

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

The signature ensures two things: the token was issued by a trusted party (who knows the secret), and the contents have not been modified since signing. Anyone who changes a single character in the header or payload invalidates the signature.

Important: Base64URL encoding is not encryption. Anyone can decode a JWT payload without the secret key. Never put sensitive information (passwords, credit card numbers, personal data) in a JWT payload.

Standard JWT Claims

The JWT specification defines a set of registered claims — standardized field names that have specific meanings. All are optional, but using them consistently improves interoperability:

{
  "iss": "https://auth.example.com",   // Issuer: who created the token
  "sub": "user-42",                     // Subject: who the token is about
  "aud": "https://api.example.com",     // Audience: who the token is for
  "exp": 1714300800,                    // Expiration: when the token expires (Unix timestamp)
  "nbf": 1714214400,                    // Not Before: token is not valid before this time
  "iat": 1714214400,                    // Issued At: when the token was created
  "jti": "a1b2c3d4-e5f6-7890-abcd"     // JWT ID: unique identifier for this token
}
  • iss (Issuer) — identifies the authorization server or service that issued the token. Verifiers should check this to ensure the token came from a trusted source.
  • sub (Subject) — identifies the principal (usually a user ID). This is the primary identifier the token represents.
  • aud (Audience) — specifies the intended recipient. An API server should reject tokens not intended for it.
  • exp (Expiration Time) — a Unix timestamp after which the token must be rejected. This is critical for limiting the window of a compromised token.
  • nbf (Not Before) — a Unix timestamp before which the token must not be accepted. Useful for tokens issued in advance.
  • iat (Issued At) — when the token was created. Useful for determining token age and for revocation policies.
  • jti (JWT ID) — a unique identifier for the token. Used to prevent replay attacks by tracking which tokens have already been used.

Beyond these registered claims, you can include any custom claims your application needs — roles, permissions, tenant IDs, etc.

Creating JWTs

You should never construct JWTs by hand in production — use a well-tested library. Here are examples in the two most popular languages.

Node.js with jsonwebtoken

// npm install jsonwebtoken

const jwt = require("jsonwebtoken");

const SECRET = process.env.JWT_SECRET; // Use an environment variable

// Create a token
const payload = {
  sub: "user-42",
  name: "Alice",
  role: "admin",
};

const token = jwt.sign(payload, SECRET, {
  expiresIn: "1h",       // Shorthand for exp claim
  issuer: "https://auth.example.com",
  audience: "https://api.example.com",
});

console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyLTQy...

// Create a token with RS256 (asymmetric)
const fs = require("fs");
const privateKey = fs.readFileSync("private.pem");

const rsaToken = jwt.sign(payload, privateKey, {
  algorithm: "RS256",
  expiresIn: "1h",
  issuer: "https://auth.example.com",
});

Python with PyJWT

# pip install PyJWT

import jwt
import datetime
import os

SECRET = os.environ["JWT_SECRET"]

# Create a token
payload = {
    "sub": "user-42",
    "name": "Alice",
    "role": "admin",
    "iss": "https://auth.example.com",
    "aud": "https://api.example.com",
    "exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(hours=1),
    "iat": datetime.datetime.now(datetime.UTC),
}

token = jwt.encode(payload, SECRET, algorithm="HS256")
print(token)

# Create with RS256 (asymmetric)
with open("private.pem", "r") as f:
    private_key = f.read()

rsa_token = jwt.encode(payload, private_key, algorithm="RS256")

Verifying JWTs

Verification is the most critical step. A server must verify the signature, check expiration, and validate the audience before trusting any claims in the token.

Node.js verification

const jwt = require("jsonwebtoken");

function verifyToken(token) {
  try {
    const decoded = jwt.verify(token, SECRET, {
      issuer: "https://auth.example.com",
      audience: "https://api.example.com",
      algorithms: ["HS256"],  // Explicitly whitelist algorithms
    });
    return { valid: true, payload: decoded };
  } catch (err) {
    return { valid: false, error: err.message };
  }
}

// Express middleware example
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: result.error });
  }

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

Python verification

import jwt

def verify_token(token: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            SECRET,
            algorithms=["HS256"],  # Always specify allowed algorithms
            issuer="https://auth.example.com",
            audience="https://api.example.com",
        )
        return {"valid": True, "payload": payload}
    except jwt.ExpiredSignatureError:
        return {"valid": False, "error": "Token has expired"}
    except jwt.InvalidAudienceError:
        return {"valid": False, "error": "Invalid audience"}
    except jwt.InvalidIssuerError:
        return {"valid": False, "error": "Invalid issuer"}
    except jwt.InvalidTokenError as e:
        return {"valid": False, "error": str(e)}

# Flask middleware example
from functools import wraps
from flask import request, jsonify, g

def require_auth(f):
    @wraps(f)
    def decorated(*args, **kwargs):
        auth_header = request.headers.get("Authorization", "")
        if not auth_header.startswith("Bearer "):
            return jsonify({"error": "Missing token"}), 401

        token = auth_header.split(" ")[1]
        result = verify_token(token)

        if not result["valid"]:
            return jsonify({"error": result["error"]}), 401

        g.user = result["payload"]
        return f(*args, **kwargs)
    return decorated

Debugging a JWT? Decode the payload (it is just Base64URL), then paste it into the JSON Validator to check for structural issues, or the Tree Viewer to explore nested claims visually.

JWTs in Authentication

The most common use of JWTs is in token-based authentication. Here is the typical flow:

  1. Login: The user sends credentials (username/password) to the auth server.
  2. Token issuance: The server validates the credentials, creates a JWT with user claims, and returns it to the client.
  3. Authenticated requests: The client includes the JWT in the Authorization: Bearer <token> header on every API request.
  4. Verification: The API server verifies the JWT signature and claims before processing the request.
  5. Token refresh: Before the access token expires, the client uses a refresh token to get a new access token.

Access tokens vs. refresh tokens

In production, you typically issue two tokens:

// Access token: short-lived (15 min), used for API calls
const accessToken = jwt.sign(
  { sub: user.id, role: user.role },
  ACCESS_SECRET,
  { expiresIn: "15m" }
);

// Refresh token: long-lived (7 days), used only to get new access tokens
const refreshToken = jwt.sign(
  { sub: user.id, tokenVersion: user.tokenVersion },
  REFRESH_SECRET,
  { expiresIn: "7d" }
);

// Return both to the client
res.json({
  accessToken,
  refreshToken,
});

The access token is short-lived because it is included with every request and is more exposed. The refresh token is long-lived but is only sent to a specific token refresh endpoint. If an access token is compromised, the damage window is limited to its short lifespan.

Security Best Practices

JWTs are secure when used correctly, but dangerous when misused. Follow these practices:

1. Always use HTTPS

JWTs are bearer tokens — anyone who has the token can use it. Without HTTPS, tokens can be intercepted in transit via man-in-the-middle attacks. Never transmit JWTs over plain HTTP.

2. Do not store sensitive data in the payload

The JWT payload is Base64URL-encoded, not encrypted. Anyone can decode it without the secret key. Never include passwords, API keys, credit card numbers, or other secrets in JWT claims. If you need to transmit sensitive data, use JWE (JSON Web Encryption) instead of JWS (JSON Web Signature).

3. Set short expiration times

Access tokens should expire quickly — 15 minutes is a common choice. Shorter expiration reduces the damage if a token is compromised. Use refresh tokens to issue new access tokens without forcing users to re-authenticate.

4. Use RS256 over HS256 for distributed systems

HS256 (HMAC-SHA256) uses a shared secret — every service that needs to verify tokens must have the same secret. This is fine for a single server, but risky in microservices architectures where the secret could leak. RS256 (RSA-SHA256) uses a public/private key pair: the auth server signs with the private key, and any service can verify with the public key. The public key cannot be used to forge tokens.

// HS256: shared secret (simple, single-server)
jwt.sign(payload, "shared-secret", { algorithm: "HS256" });

// RS256: key pair (secure, distributed systems)
jwt.sign(payload, privateKey, { algorithm: "RS256" });
// Verification only needs the public key:
jwt.verify(token, publicKey, { algorithms: ["RS256"] });

5. Implement token revocation strategies

JWTs are stateless — there is no built-in way to revoke them before expiration. Common revocation strategies include:

  • Short expiration + refresh tokens: Revoke the refresh token to prevent new access tokens from being issued.
  • Token version: Store a tokenVersion in the database. Increment it to invalidate all existing tokens for a user.
  • Blocklist: Maintain a list of revoked JTIs (JWT IDs). Check each incoming token against the blocklist. Use Redis with TTL matching token expiration so entries auto-expire.

6. Always specify allowed algorithms

When verifying, explicitly whitelist the algorithms you expect. This prevents algorithm confusion attacks:

// GOOD: explicitly allow only HS256
jwt.verify(token, secret, { algorithms: ["HS256"] });

// BAD: no algorithm restriction — vulnerable to "none" attack
jwt.verify(token, secret);

Common JWT Mistakes

These mistakes are responsible for the majority of JWT-related security vulnerabilities:

1. Storing JWTs in localStorage (XSS risk)

localStorage is accessible to any JavaScript running on the page. If your site has a cross-site scripting (XSS) vulnerability, an attacker can steal the token:

// RISKY: any XSS can steal this
localStorage.setItem("token", jwt);

// BETTER: use an HttpOnly cookie
// Set-Cookie: token=<jwt>; HttpOnly; Secure; SameSite=Strict; Path=/
// HttpOnly cookies cannot be accessed by JavaScript

The trade-off is that cookies are sent automatically with every request (requiring CSRF protection), while localStorage tokens are sent manually. For most web apps, HttpOnly cookies with CSRF tokens are the safer choice.

2. Not validating expiration

Always check the exp claim. Most libraries do this by default, but make sure you have not disabled it:

// Python: PyJWT validates exp by default
jwt.decode(token, SECRET, algorithms=["HS256"])

# To explicitly require exp validation:
jwt.decode(token, SECRET, algorithms=["HS256"],
           options={"require": ["exp"]})

3. Using the "none" algorithm

The JWT spec allows an "alg": "none" header, which means no signature at all. Some libraries accept this by default. An attacker can forge a token by setting the algorithm to "none" and removing the signature:

// An attacker crafts this:
// Header: {"alg": "none", "typ": "JWT"}
// Payload: {"sub": "admin", "role": "superuser"}
// Signature: (empty)

// If your server accepts "none", this token passes verification!
// ALWAYS whitelist algorithms to prevent this:
jwt.verify(token, secret, { algorithms: ["HS256"] }); // "none" rejected

4. Using a weak secret

For HS256, the secret must be long and random. A weak secret can be brute-forced:

// BAD secrets:
"secret"
"password123"
"my-jwt-secret"

// GOOD: generate a strong random secret
// Node.js:
require("crypto").randomBytes(64).toString("hex")
// Python:
import secrets; secrets.token_hex(64)

JWTs vs. Sessions: When to Use Each

JWTs and server-side sessions both solve authentication, but they have different trade-offs:

FactorJWTsServer Sessions
StateStateless (self-contained)Stateful (stored on server)
ScalabilityEasy to scale horizontallyRequires shared session store (Redis)
RevocationHard (requires blocklist)Easy (delete session from store)
SizeLarger (payload in every request)Small (just a session ID)
Cross-domainWorks naturally across domainsTricky with cookies across domains
Mobile/APIExcellent fitCan work, but less natural

Use JWTs when: You have a distributed system or microservices, you need cross-domain authentication, your clients are mobile apps or SPAs calling APIs, or you need to pass claims between services without database lookups.

Use sessions when: You have a traditional server-rendered app, you need instant revocation (logout, account lockout), your tokens would be too large due to many claims, or you want simplicity and are running a single server.

Many production systems use both: JWTs for service-to-service communication and API access, and sessions for the user-facing web application.

Decoding JWTs Without Verification

Sometimes you need to inspect a JWT without verifying the signature — for debugging, logging, or reading non-sensitive claims client-side:

// Node.js: decode without verification
const decoded = jwt.decode(token, { complete: true });
console.log(decoded.header);  // { alg: 'HS256', typ: 'JWT' }
console.log(decoded.payload); // { sub: 'user-42', name: 'Alice', ... }

// Browser: manual Base64URL decode
function decodeJwt(token) {
  const [headerB64, payloadB64] = token.split(".");

  const decode = (str) =>
    JSON.parse(atob(str.replace(/-/g, "+").replace(/_/g, "/")));

  return {
    header: decode(headerB64),
    payload: decode(payloadB64),
  };
}

// Python: decode without verification
payload = jwt.decode(token, options={"verify_signature": False})

Warning: Never make authorization decisions based on an unverified JWT. Always verify the signature server-side before trusting any claims. Decoding without verification is only safe for non-security-critical purposes like displaying the user's name in a UI.

Quick Reference: JWT Checklist

  • Use a well-tested library (jsonwebtoken for Node.js, PyJWT for Python) — never hand-roll JWT logic
  • Always verify the signature before trusting claims
  • Explicitly whitelist allowed algorithms (never accept "none")
  • Set exp on every token — 15 minutes for access tokens, 7 days for refresh tokens
  • Validate iss and aud claims to prevent token misuse across services
  • Use RS256 for distributed systems, HS256 for single-server setups
  • Store tokens in HttpOnly cookies, not localStorage
  • Use HTTPS exclusively — never transmit tokens over plain HTTP
  • Keep payloads small — store only essential claims
  • Implement a revocation strategy (token versioning or JTI blocklist)
  • Rotate secrets and keys on a regular schedule

Work with JWT JSON payloads

Decode your JWT payload and use our tools to validate, format, and explore the JSON structure inside.

We use cookies to enhance your experience. By continuing to visit this site you agree to our use of cookies. Learn more