If you have worked with any modern web application or REST API, you have probably encountered JSON Web Tokens. JWTs are the most common way to handle authentication in single-page apps, mobile apps, and microservices. They are compact, stateless, and widely supported.
But they are also widely misunderstood. Developers use JWTs without understanding what is inside them, how to validate them properly, or what the security implications are. This guide explains everything you need to know.
What is a JWT?
A JSON Web Token is a compact, URL-safe string that represents a set of claims. It is used to securely transmit information between two parties — typically a client (browser or app) and a server.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlNhbSIsImlhdCI6MTcxMTAwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
It is three Base64-encoded JSON strings separated by dots:
HEADER.PAYLOAD.SIGNATURE
That is it. No encryption, no binary format — just three JSON objects encoded in Base64.
The Three Parts
Header
The header specifies the token type and the signing algorithm:
{
"alg": "HS256",
"typ": "JWT"
}
Common algorithms:
- HS256: HMAC with SHA-256 (symmetric — same secret key for signing and verifying)
- RS256: RSA with SHA-256 (asymmetric — private key signs, public key verifies)
- ES256: ECDSA with SHA-256 (asymmetric, smaller keys than RSA)
Payload
The payload contains the claims — the actual data you want to transmit:
{
"sub": "1234567890",
"name": "Sam",
"email": "[email protected]",
"role": "admin",
"iat": 1711000000,
"exp": 1711086400
}
Important: The payload is Base64-encoded, NOT encrypted. Anyone can decode it and read the contents. Never put secrets, passwords, or sensitive data in a JWT payload.
Standard Claims
| Claim | Full Name | Purpose |
|---|---|---|
iss |
Issuer | Who issued the token |
sub |
Subject | Who the token is about (usually user ID) |
aud |
Audience | Who the token is intended for |
exp |
Expiration | When the token expires (Unix timestamp) |
nbf |
Not Before | Token is not valid before this time |
iat |
Issued At | When the token was created |
jti |
JWT ID | Unique identifier for the token |
You can add any custom claims you want (like role, email, permissions).
Signature
The signature verifies that the token has not been tampered with:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The server creates the signature using a secret key (or private key for RS256). When the token comes back, the server recalculates the signature and compares. If someone modified the payload, the signatures will not match, and the token is rejected.
The Authentication Flow
Here is how JWT authentication typically works:
Step 1: Login The user sends their credentials (username/password) to the server.
POST /api/login
{ "email": "[email protected]", "password": "..." }
Step 2: Server Creates Token The server verifies the credentials, creates a JWT with the user's info, signs it, and sends it back.
{
"access_token": "eyJhbGciOi...",
"token_type": "Bearer",
"expires_in": 3600
}
Step 3: Client Stores Token The client stores the token (typically in memory or localStorage) and includes it in every subsequent request.
Step 4: Authenticated Requests Every API request includes the token in the Authorization header:
GET /api/profile
Authorization: Bearer eyJhbGciOi...
Step 5: Server Validates The server checks the signature, expiration, and claims. If valid, it processes the request. No database lookup needed — the token itself contains all the information.
Access Tokens vs Refresh Tokens
Short-lived access tokens are more secure (if stolen, they expire quickly), but users do not want to log in every 15 minutes. The solution: refresh tokens.
| Token | Lifetime | Purpose | Storage |
|---|---|---|---|
| Access Token | 15 min–1 hour | Authenticate API requests | Memory |
| Refresh Token | 7–30 days | Get new access tokens | HttpOnly cookie |
The flow:
- User logs in → gets both tokens
- Access token is used for API calls
- When access token expires, the client sends the refresh token to get a new access token
- If the refresh token expires, the user must log in again
JWT vs Sessions
| JWT | Sessions | |
|---|---|---|
| State | Stateless (token contains everything) | Stateful (server stores session data) |
| Storage | Client-side | Server-side (memory, Redis, database) |
| Scalability | Excellent (any server can validate) | Requires shared session store |
| Revocation | Hard (cannot invalidate a token easily) | Easy (delete the session) |
| Size | Larger (contains claims data) | Small (just a session ID) |
| Best for | APIs, microservices, mobile apps | Traditional server-rendered apps |
Security Best Practices
Do
- Set short expiration times (15 minutes for access tokens)
- Use HTTPS only — tokens are not encrypted, just signed
- Validate everything: signature, expiration, issuer, audience
- Use RS256 for public-facing APIs (asymmetric — you can share the public key for verification without exposing the signing key)
- Store access tokens in memory, not localStorage (XSS can steal localStorage)
- Store refresh tokens in HttpOnly cookies (inaccessible to JavaScript)
Do Not
- Never put secrets in the payload — it is Base64, not encrypted
- Never skip signature verification — this is how token forgery happens
- Never use
alg: none— some libraries accept unsigned tokens if you do not explicitly reject them - Never use JWTs as session storage — if you need to store lots of data, use sessions
- Never trust the client — always validate on the server
The alg: none Attack
A classic JWT vulnerability: the attacker changes the header to "alg": "none" and removes the signature. If the server library does not explicitly require a specific algorithm, it might accept the unsigned token as valid. Always specify the expected algorithm when verifying:
# Python (PyJWT)
jwt.decode(token, secret, algorithms=["HS256"]) # Good — explicit
jwt.decode(token, secret, algorithms=["none", "HS256"]) # BAD
Decode a JWT
Use our free JWT Decoder to paste any JWT and instantly see the decoded header, payload with human-readable claim labels, expiration status, and signature. All decoding happens in your browser — your token is never sent to any server.