JWTs are everywhere. If you've built anything with authentication in the last decade, you've used them. But most developers treat them as opaque blobs -- copy the token, paste it into a header, and hope for the best.
Let's actually understand what's inside a JWT, how to decode one, and what security pitfalls to avoid.
What is a JWT?
A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe way to represent claims between two parties. In plain terms: it's a signed JSON object that proves something -- usually that a user is authenticated and has certain permissions.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Those three sections separated by dots are:
HEADER.PAYLOAD.SIGNATURE
Each section is Base64URL-encoded. Let's break them down.
The three parts
1. Header
The header specifies the token type and the signing algorithm.
{
"alg": "HS256",
"typ": "JWT"
}
Common algorithms:
- HS256 -- HMAC with SHA-256 (symmetric, same key signs and verifies)
- RS256 -- RSA with SHA-256 (asymmetric, private key signs, public key verifies)
- ES256 -- ECDSA with P-256 curve (asymmetric, smaller keys than RSA)
2. Payload
The payload contains the claims -- the actual data the token carries.
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
Standard claims (called "registered claims"):
| Claim | Full name | Meaning |
sub | Subject | Who the token is about (usually user ID) |
iss | Issuer | Who created the token |
aud | Audience | Who the token is intended for |
exp | Expiration | When the token expires (Unix timestamp) |
iat | Issued At | When the token was created |
nbf | Not Before | Token is invalid before this time |
You can add any custom claims you want (role, email, org_id, etc.).
3. Signature
The signature ensures the token hasn't been tampered with. For HS256:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
The server that created the token can verify the signature. If anyone modifies the header or payload, the signature won't match.
Decoding a JWT manually
Since the header and payload are just Base64URL-encoded, you can decode them without any special tools.
In JavaScript (browser or Node.js)
const token = 'eyJhbGciOiJIUzI1NiIs...'; // your JWT
// Split into parts
const [header, payload, signature] = token.split('.');
// Decode header
const decodedHeader = JSON.parse(atob(header));
console.log('Header:', decodedHeader);
// Decode payload
const decodedPayload = JSON.parse(atob(payload));
console.log('Payload:', decodedPayload);
// Check expiration
const exp = new Date(decodedPayload.exp * 1000);
console.log('Expires:', exp);
console.log('Expired?', Date.now() > decodedPayload.exp * 1000);
Note: atob() handles standard Base64, but JWT uses Base64URL encoding (replacing + with - and / with _). For most tokens this doesn't matter, but for a robust solution:
function base64UrlDecode(str) {
str = str.replace(/-/g, '+').replace(/_/g, '/');
const pad = str.length % 4;
if (pad) str += '='.repeat(4 - pad);
return atob(str);
}
In Python
import base64
import json
token = "eyJhbGciOiJIUzI1NiIs..."
header, payload, signature = token.split(".")
# Add padding and decode
def decode_part(part):
padding = 4 - len(part) % 4
part += "=" * padding
decoded = base64.urlsafe_b64decode(part)
return json.loads(decoded)
print("Header:", decode_part(header))
print("Payload:", decode_part(payload))
In the command line
echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | base64 -d
# {"alg":"HS256","typ":"JWT"}
Visual decoding
If you want a quick visual breakdown without writing code, tools like devdash.io/tools/jwt-decoder let you paste a token and see the header, payload, and expiration status color-coded. Useful when debugging auth issues and you just need to check what's inside a token fast.
Security considerations
1. JWTs are NOT encrypted
Decoding is not the same as decrypting. Anyone who has the token can read the payload. Never put sensitive data in a JWT -- no passwords, no SSNs, no credit card numbers.
If you need encrypted tokens, look into JWE (JSON Web Encryption), but for most use cases, just keep sensitive data out of the payload.
2. The "none" algorithm attack
Some JWT libraries accept "alg": "none", which means no signature verification. An attacker can modify the payload and set the algorithm to "none" to bypass verification.
Always validate the algorithm on the server side. Don't let the token tell your code which algorithm to use.
// BAD - accepts whatever algorithm the token specifies
jwt.verify(token, secret);
// GOOD - explicitly require the expected algorithm
jwt.verify(token, secret, { algorithms: ['HS256'] });
3. Algorithm confusion attacks
If your server is configured for RS256 (asymmetric) but an attacker sends a token with HS256 (symmetric), some libraries will use the public key as the HMAC secret. Since the public key is... public, the attacker can forge tokens.
Again: always specify the expected algorithm explicitly.
4. Token expiration
Always set exp claims. A JWT without an expiration is valid forever (or until you rotate your signing key). Short-lived tokens (15-60 minutes) combined with refresh tokens are standard practice.
5. Token storage
- Don't store JWTs in localStorage -- vulnerable to XSS attacks
- HttpOnly cookies are generally safer for web applications
- In-memory storage works for single-page apps but tokens are lost on refresh
When NOT to use JWTs
JWTs are a good fit for stateless authentication where the server doesn't need to look up session data. But they have drawbacks:
- You can't revoke them (without a blocklist, which defeats the stateless purpose)
- They can get large if you add many claims
- Clock skew between servers can cause
expvalidation issues
For simple web apps with a single server, traditional session cookies are often simpler and more secure.
Debugging checklist
When a JWT isn't working, check these in order:
- Is it expired? Decode and check the
expclaim - Is the audience correct? Check
audmatches your service - Is the signature valid? Use the correct secret/key
- Is the algorithm correct? Match what the server expects
- Is there clock skew? Check
iatandnbfagainst your server time - Is the token being sent correctly? Header should be
Authorization: Bearer
JWTs aren't complicated once you understand the three parts. Decode them, inspect them, and always validate them properly on the server side.