JWT Tokens Explained: How They Work and How to Decode Them
This guide has a free tool → Open JWT Decoder
JWT Tokens Explained: How They Work and How to Decode Them
A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe token format used for securely transmitting information between parties. JWTs are the most widely used authentication mechanism in modern web applications and APIs. When you log into a website and remain authenticated across page refreshes, API calls, and multiple browser tabs, there is a good chance a JWT is involved.
Understanding how JWTs work is essential for any developer building or consuming APIs. It will also help you recognize security vulnerabilities and avoid the most common mistakes.
JWT Decoder
Free online JWT decoder - decode and inspect JSON Web Tokens without sending them to a server
Timestamp Converter
Free online timestamp converter - convert between Unix timestamps and human-readable dates instantly
The Problem JWTs Solve
To understand why JWTs exist, you need to understand the problem they solve.
HTTP is stateless. Every request is independent --- the server has no memory of previous requests. For a web application that needs to keep users logged in, this creates an immediate challenge: how does the server know who is making each request?
The traditional solution is server-side sessions. The user logs in, the server creates a session record and stores it in memory or a database, and gives the user a session ID cookie. On every subsequent request, the server looks up that session ID to identify the user.
This works fine for a single server. But modern applications often run across many servers, microservices, and third-party APIs. Sharing session state across all of them is complex and introduces infrastructure dependencies.
JWTs take a different approach: put all the necessary information in the token itself, and sign it so the server can verify the token without looking anything up. The token travels with the request, the server verifies the signature, and immediately knows who the user is and what they are allowed to do.
How JWTs Work: The Full Flow
Here is the complete authentication flow using JWTs:
- The user sends their username and password to the server's login endpoint
- The server verifies the credentials against the database
- The server creates a JWT containing the user's ID, roles, and an expiration time, then signs it with a secret key
- The server returns the JWT to the client (usually in the response body)
- The client stores the JWT (in memory, localStorage, or an httpOnly cookie)
- On every subsequent request, the client sends the JWT in the
Authorizationheader - The server receives the JWT, verifies the signature using the secret key, checks the expiration, and reads the user information directly from the token
- The server responds to the request based on who the user is
The key insight: the server does not query a database or session store on step 7. It simply verifies the cryptographic signature and reads the token data. This is what "stateless" authentication means --- the server holds no state between requests.
The Three Parts of a JWT
A JWT looks like a long string of seemingly random characters with two dots dividing it into three sections:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cThose three sections are:
[HEADER].[PAYLOAD].[SIGNATURE]Each section is Base64URL-encoded (a URL-safe variant of Base64 that replaces + with - and / with _).
1. Header
{
"alg": "HS256",
"typ": "JWT"
}The header specifies two things:
alg: the signing algorithm used (HS256, RS256, ES256, etc.)typ: always "JWT"
This is Base64URL-encoded to produce eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
2. Payload
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"roles": ["user", "admin"],
"iat": 1516239022,
"exp": 1516325422
}The payload contains claims --- statements about the user and metadata about the token. There are three types:
Registered claims (standardized, optional but recommended):
sub(subject): who the token is about, typically a user IDiss(issuer): who created and signed the token (e.g., "api.example.com")aud(audience): who the token is intended for (e.g., "app.example.com")exp(expiration time): Unix timestamp after which the token is invalidnbf(not before): Unix timestamp before which the token is invalidiat(issued at): Unix timestamp of when the token was createdjti(JWT ID): unique identifier for the token, useful for revocation
Public claims: custom claims registered in the IANA JWT Claims Registry, like email, name, preferred_username.
Private claims: custom claims specific to your application, like roles, permissions, tenantId.
3. Signature
The signature is produced by:
- Taking the encoded header:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 - Adding a dot
- Adding the encoded payload:
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ - Signing the combined string using the algorithm specified in the header and a secret key
For HS256 (HMAC-SHA256), this looks like:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)The result is Base64URL-encoded to produce the third segment.
The signature guarantees two things: the token was created by someone who holds the secret key, and the header and payload have not been modified since the token was created. If you change even a single character in the payload and try to send the token, the server will reject it because the signature will not match.
Critical Security Concept: JWTs Are NOT Encrypted
This is the most important thing to understand about JWTs: they are signed, not encrypted. The header and payload are Base64URL-encoded, which is encoding --- not encryption. Anyone can decode a JWT and read the payload without any key.
You can verify this yourself. Take any JWT and paste the payload segment into a Base64 decoder. You will immediately see the plain JSON inside.
What signing does: it proves the token has not been tampered with and was issued by a trusted party. The server can verify the token is authentic without looking anything up.
What signing does not do: it does not hide the data. The payload is public.
Practical rule: never put sensitive information in a JWT payload. This means:
- No passwords (obviously)
- No credit card numbers
- No social security numbers
- No API keys or secrets
- No private business logic that should not be visible to clients
Information like user ID, email address, roles, and permissions is generally fine to include --- it is the kind of information the client legitimately needs access to anyway.
If you need to transmit sensitive data that cannot be read by the client, use JWE (JSON Web Encryption), which is a separate standard that encrypts the payload.
Signing Algorithms
The signing algorithm used dramatically affects the security model of your JWT implementation.
Symmetric algorithms (HMAC)
| Algorithm | Full Name | Key Type |
|---|---|---|
| HS256 | HMAC with SHA-256 | Shared secret |
| HS384 | HMAC with SHA-384 | Shared secret |
| HS512 | HMAC with SHA-512 | Shared secret |
With symmetric algorithms, the same secret key is used to both sign and verify tokens. This is simple and fast, but it means any service that needs to verify tokens must have the secret key. If you share the secret key with a third party, they can also create tokens.
Asymmetric algorithms (RSA/ECDSA)
| Algorithm | Full Name | Key Type |
|---|---|---|
| RS256 | RSA with SHA-256 | Public/private key pair |
| RS384 | RSA with SHA-384 | Public/private key pair |
| RS512 | RSA with SHA-512 | Public/private key pair |
| ES256 | ECDSA with SHA-256 | Public/private key pair |
| ES384 | ECDSA with SHA-384 | Public/private key pair |
| ES512 | ECDSA with SHA-512 | Public/private key pair |
With asymmetric algorithms, a private key signs the token and a public key verifies it. The private key stays on the authentication server. Any other service can verify tokens using only the public key --- without being able to create tokens themselves.
When to use HS256: Simple applications with one backend service. Fast and easy to implement.
When to use RS256 or ES256: Microservices architectures where multiple services need to verify tokens but should not be able to create them. Also required when using external identity providers like Auth0, Okta, or Google.
ES256 produces shorter signatures than RS256 and is faster, making it the preferred choice for new implementations.
Implementing JWTs in Node.js
The jsonwebtoken library is the most widely used JWT library for Node.js.
const jwt = require("jsonwebtoken");
const SECRET_KEY = process.env.JWT_SECRET; // Must be long, random, kept secret
// Create a JWT (on login)
function createToken(userId, email, roles) {
const payload = {
sub: userId,
email: email,
roles: roles,
};
const options = {
expiresIn: "24h", // Expiration: 1 day
issuer: "api.example.com",
audience: "app.example.com",
};
return jwt.sign(payload, SECRET_KEY, options);
}
// Verify a JWT (on each request)
function verifyToken(token) {
try {
const decoded = jwt.verify(token, SECRET_KEY, {
algorithms: ["HS256"], // Always specify allowed algorithms
issuer: "api.example.com",
audience: "app.example.com",
});
return { valid: true, payload: decoded };
} catch (err) {
if (err.name === "TokenExpiredError") {
return { valid: false, reason: "expired" };
}
if (err.name === "JsonWebTokenError") {
return { valid: false, reason: "invalid" };
}
return { valid: false, reason: "unknown" };
}
}
// Express middleware example
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = authHeader.slice(7); // Remove "Bearer " prefix
const result = verifyToken(token);
if (!result.valid) {
return res.status(401).json({ error: `Token ${result.reason}` });
}
req.user = result.payload;
next();
}Implementing JWTs in Python
The PyJWT library provides JWT support for Python.
import jwt
import datetime
from datetime import timezone
SECRET_KEY = "your-secret-key-must-be-long-and-random"
# Create a JWT
def create_token(user_id: str, email: str, roles: list) -> str:
now = datetime.datetime.now(timezone.utc)
payload = {
"sub": user_id,
"email": email,
"roles": roles,
"iat": now,
"exp": now + datetime.timedelta(hours=24),
"iss": "api.example.com",
"aud": "app.example.com",
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
# Verify a JWT
def verify_token(token: str) -> dict | None:
try:
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
issuer="api.example.com",
audience="app.example.com",
)
return payload
except jwt.ExpiredSignatureError:
raise ValueError("Token has expired")
except jwt.InvalidTokenError as e:
raise ValueError(f"Invalid token: {e}")
# FastAPI dependency example
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
async def get_current_user(token: str = Depends(oauth2_scheme)):
try:
payload = verify_token(token)
return payload
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=str(e),
headers={"WWW-Authenticate": "Bearer"},
)Implementing JWTs in Go
package auth
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
)
var secretKey = []byte("your-secret-key-must-be-long-and-random")
type Claims struct {
UserID string `json:"sub"`
Email string `json:"email"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
// Create a JWT
func CreateToken(userID, email string, roles []string) (string, error) {
claims := Claims{
UserID: userID,
Email: email,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(time.Now()),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
Issuer: "api.example.com",
Audience: jwt.ClaimStrings{"app.example.com"},
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(secretKey)
}
// Verify a JWT
func VerifyToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return secretKey, nil
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token")
}
return claims, nil
}Token Storage: localStorage vs httpOnly Cookies
Where you store a JWT on the client has significant security implications.
| localStorage / sessionStorage | httpOnly Cookie | |
|---|---|---|
| XSS vulnerability | Yes --- JS can read it | No --- JS cannot access httpOnly cookies |
| CSRF vulnerability | No --- not sent automatically | Yes --- requires CSRF token |
| Accessible from JS | Yes | No |
| Works across tabs | Yes (localStorage) | Yes |
| Works cross-domain | Yes | Requires CORS + credentials |
| Best for | SPAs with controlled JS | Traditional web apps, high-security |
The XSS risk of localStorage: if an attacker can inject JavaScript into your page (XSS), they can run localStorage.getItem("jwt") and steal all tokens. This is a real and common attack.
The CSRF risk of cookies: if a JWT is in a cookie without CSRF protection, an attacker can trick a user's browser into making authenticated requests to your API from a malicious site.
Recommendation for most applications: store JWTs in httpOnly, SameSite=Strict cookies. This eliminates the XSS risk. Add CSRF protection (a double-submit cookie or CSRF token header) to address CSRF.
If you need to access the JWT from JavaScript (e.g., to read the user's name from the payload), use a separate non-httpOnly cookie or endpoint just for that data, and keep the actual JWT in an httpOnly cookie.
Token Expiration and Refresh Tokens
JWTs should have short expiration times. A 24-hour access token means that if the token is stolen, the attacker has 24 hours of access before the token expires. For high-security applications, 15 minutes is a more appropriate access token lifetime.
To handle short-lived access tokens without forcing users to log in constantly, use refresh tokens:
- On login, the server returns both an access token (short-lived: 15 minutes) and a refresh token (long-lived: 30 days)
- When the access token expires, the client sends the refresh token to a
/refreshendpoint - The server validates the refresh token (usually by looking it up in a database), and issues a new access token
- The refresh token can be rotated (new refresh token issued with each use) for additional security
// Refresh token flow
async function refreshAccessToken(refreshToken) {
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
});
if (!response.ok) {
// Refresh token expired or invalid --- force re-login
redirectToLogin();
return;
}
const { access_token, refresh_token } = await response.json();
// Store the new tokens
return access_token;
}
// Automatically retry failed requests with refreshed token
async function authenticatedFetch(url, options = {}) {
let token = getAccessToken();
let response = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}` },
});
if (response.status === 401) {
// Token might have expired --- try refreshing
token = await refreshAccessToken(getRefreshToken());
if (token) {
response = await fetch(url, {
...options,
headers: { ...options.headers, Authorization: `Bearer ${token}` },
});
}
}
return response;
}JWT Revocation: The Hardest Problem
JWTs have a well-known limitation: they cannot be revoked before their expiration. If a user logs out, changes their password, or has their account compromised, their existing JWT is still valid until it expires.
There are several approaches to handle this:
Short expiration times: If tokens expire in 15 minutes, the window for abuse after revocation is small.
Token blocklist: Maintain a database of revoked JTI (JWT ID) values. On every request, check if the token's jti appears in the blocklist. This reintroduces server-side state but allows immediate revocation.
// Token blocklist approach
const revokedTokens = new Set(); // In production, use Redis
function revokeToken(jti) {
revokedTokens.add(jti);
}
function isRevoked(jti) {
return revokedTokens.has(jti);
}
function verifyTokenWithRevocation(token) {
const decoded = jwt.verify(token, SECRET_KEY);
if (isRevoked(decoded.jti)) {
throw new Error("Token has been revoked");
}
return decoded;
}Version-based invalidation: Add a version field to the user record in the database. Include the version in the JWT. On verification, compare the token version against the current user version. When you want to invalidate all tokens, increment the user's version.
Common JWT Security Mistakes
1. Storing JWTs in localStorage. Vulnerable to XSS. Use httpOnly cookies when possible.
2. Accepting `alg: "none"`. Some early JWT libraries accepted a token with algorithm set to "none," meaning no signature verification was performed. Always explicitly specify allowed algorithms in your verification code.
3. Using weak or guessable secrets for HS256. If you use "secret" or "password" as your JWT secret, attackers can brute-force it. Use a cryptographically random string of at least 256 bits (32 bytes).
# Generate a strong secret
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# or
openssl rand -hex 324. Not validating expiration. The exp claim must be checked on every request. Some libraries do this automatically, but confirm your implementation does.
5. Trusting the `kid` header without validation. The kid (key ID) header specifies which key to use for verification. Some vulnerabilities allow attackers to inject a path or URL as the kid value to force the server to use a key they control. Always validate that kid matches a known, expected key identifier.
6. Putting too much data in the payload. Every request sends the JWT. A 10KB payload adds meaningful latency and bandwidth at scale. Keep payloads small: user ID, roles, and a few relevant claims. Fetch additional user data from the database or cache if needed.
7. Not checking the `iss` and `aud` claims. If you use multiple JWT issuers (e.g., your auth server and a third-party OAuth provider), always validate that the token was issued by the expected issuer for the expected audience.
JWT vs Session Tokens: Which Should You Use?
| JWT (Stateless) | Session Tokens (Stateful) | |
|---|---|---|
| Server storage required | No | Yes (database or cache) |
| Scalability | Excellent --- works across any server | Requires shared session store |
| Revocation | Difficult | Immediate |
| Token size | Larger (payload in token) | Small (just an ID) |
| Database lookup per request | No | Yes |
| Best for | APIs, microservices, mobile apps | Traditional web apps with single server |
For most new web applications, especially those with React/Vue/Angular frontends consuming REST or GraphQL APIs, JWTs are the right choice. For server-rendered applications that do not span multiple services, traditional sessions are simpler and offer better revocation control.
Decoding JWTs for Debugging
When debugging authentication issues, you often need to quickly inspect a JWT to see its claims, check the expiration, and verify the algorithm. This is a read-only operation that does not require the secret key --- anyone can decode the header and payload.
The JWT Decoder on ToolBox lets you:
- Paste any JWT and immediately see the decoded header and payload formatted as readable JSON
- See the expiration date as a human-readable timestamp --- not just a Unix number
- Check whether the token is currently expired based on the
expclaim - View all claims including custom application-specific ones
- Process everything entirely in your browser --- your token is never sent to any server
That last point is critical. Never paste production JWTs into online tools that send data to a remote server. A JWT contains enough information to impersonate a user, and you should treat it like a password. The ToolBox JWT Decoder runs entirely client-side using JavaScript --- the token never leaves your browser tab.
This also applies to the timestamp values in JWTs. Use the Timestamp Converter to translate the iat and exp Unix timestamps into readable dates when inspecting tokens manually.
Reading Claims from a JWT in the Browser
Sometimes you need to read JWT claims in client-side JavaScript, for example to display the user's name or check their roles without making an extra API call.
function decodeJWT(token) {
try {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
// Decode the payload (second part)
// Base64URL decoding: replace - with + and _ with /
const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
// Pad to make length a multiple of 4
const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, "=");
const decoded = JSON.parse(atob(padded));
return decoded;
} catch (err) {
console.error("Failed to decode JWT:", err);
return null;
}
}
// Usage
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJKYW5lIERvZSIsImVtYWlsIjoiamFuZUBleGFtcGxlLmNvbSIsInJvbGVzIjpbInVzZXIiXSwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwODY0MDB9.signature";
const claims = decodeJWT(token);
console.log(claims.name); // "Jane Doe"
console.log(claims.email); // "jane@example.com"
console.log(claims.roles); // ["user"]
// Check if expired
const isExpired = claims.exp < Math.floor(Date.now() / 1000);
console.log(isExpired ? "Token expired" : "Token valid");Remember: this decoding is read-only and requires no secret key. You are just Base64-decoding the payload. This is fine to do on the client for display purposes. Never trust the client-decoded claims for authorization decisions on the server --- always verify the signature server-side.
---
Inspect your next JWT with the ToolBox JWT Decoder. Paste a token and see the decoded header, payload, and expiration status instantly. No server, no signup, completely private.
Related Tools
Free, private, no signup required
Password Generator
Strong password generator online - generate secure random passwords that never leave your browser
Hash Generator
Free online hash generator - generate MD5, SHA-1, SHA-256 hashes from any input text
AES Encryption Tool
Free online AES encryption tool - encrypt and decrypt text using AES-256 encryption
JSON Formatter
JSON formatter and validator online - format, beautify, and validate JSON data instantly in your browser
You might also like
10 min read
Best Free JWT Decoders Compared - jwt.io Alternatives Worth Knowing
19 min read
Your Code Is Not Private: I Audited What CodePen, JSFiddle, CodeSandbox, and Replit Do With Your Code
12 min read
I Audited the Privacy of Popular Free Dev Tools - The Results Are Terrifying
Want higher limits, batch processing, and AI tools?