
You're staring at a 401 Unauthorized. Your API is rejecting the token. You paste the JWT somewhere and get back a wall of Base64 that means nothing to you.
I've wasted entire afternoons on this. Turns out, reading a JWT takes about 10 seconds once you understand the structure. Let me save you the afternoon.
Canonical source: This article was originally published at WebToolsHub — How to Decode a JWT Token Online. That version includes a live free tool, full code examples, and is kept up to date.
What You'll Learn
- The exact 3-part JWT structure and what each part contains
- How to decode any JWT in your browser — no install, no account
- The 5 JWT errors that waste the most developer time (and how to fix each one)
- The localStorage mistake that's killed more auth systems than anything else
- HS256 vs RS256 vs ES256 — which one you should actually be using in 2026
Every JWT Is Three Base64 Strings Separated by Two Dots
That's it. The structure is always:
HEADER.PAYLOAD.SIGNATURE
Here's a real one:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsIm5hbWUiOiJBd2FpcyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxNjgwMDAwMCwiZXhwIjoxNzE2ODAzNjAwfQ.mK7tqV9ZjX2pL0nW8sRdUeGfHzCvBwQoTlMxYkIjA1c
Paste this into the free JWT Decoder at WebToolsHub and you'll see all three sections decoded instantly. The tool runs 100% in your browser — nothing is sent to any server.
Part 1: The Header
Decode the first segment and you get:
{
"alg": "HS256",
"typ": "JWT"
}
alg is the most important field for debugging. It tells you exactly what's needed to verify this token:
| Algorithm | Type | Used For |
|---|---|---|
HS256 |
Symmetric (shared secret) | Monoliths, simple APIs |
RS256 |
Asymmetric (public/private key) | Microservices, OAuth |
ES256 |
Asymmetric (elliptic curve) | High-performance APIs |
none |
No signature | Never accept in production |
If you're getting an invalid signature error, this field is where to start. Mismatched algorithm between your issuer and verifier is one of the top causes.
Part 2: The Payload (Claims)
Decode the second segment:
{
"sub": "user_123",
"name": "Awais",
"role": "admin",
"iat": 1716800000,
"exp": 1716803600
}
The registered claims you'll see most often:
-
sub— Subject. Usually the user ID. -
iss— Issuer. Usually your API's URL. -
aud— Audience. Must match your server's expected value exactly. -
exp— Expiration timestamp (Unix). Your server must validate this. -
iat— Issued At timestamp. -
nbf— Not Before. Token invalid until this time. -
jti— JWT ID. Useful for revocation lists.
Critical rule: The payload is Base64URL-encoded, NOT encrypted. Anyone with the token string can decode and read every claim. Never put passwords, credit card numbers, SSNs, or API keys in a JWT payload.
Those exp and iat timestamps are Unix epoch values. When you see "exp": 1716803600 you have no idea if that's past or future. The Unix Timestamp Converter at WebToolsHub converts these instantly. Or in JavaScript:
// Check if token is expired
const isExpired = Date.now() >= payload.exp * 1000;
// Convert to readable date
const expDate = new Date(payload.exp * 1000).toLocaleString();
Multiply by 1000 — JavaScript uses milliseconds, JWT uses seconds. Classic gotcha.
Part 3: The Signature
The signature is created from:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
If even one character in the header or payload changes, the signature breaks. That's the tamper-proof guarantee. Decoding the signature section tells you nothing useful — but verifying it tells you whether to trust the token.
Decoding ≠ Verifying. Anyone can decode a JWT without a key. Only the party with the correct secret or public key can verify the signature. Always verify on the server before trusting any claims.
The 5 JWT Errors That Waste the Most Time
Error 1: jwt expired
Your exp timestamp is in the past. Easy to diagnose — paste the token in the decoder and check the expiry date.
If valid tokens appear expired immediately after creation, you have clock skew. Your issuer and verifier have different system times.
// Add tolerance for clock skew
jwt.verify(token, secret, {
algorithms: ['HS256'],
clockTolerance: 60 // 60 seconds leeway
});
Run date -u on both servers. Even 30 seconds difference breaks short-lived tokens.
Error 2: invalid signature
Most common causes (ranked by how often I've seen them in the wild):
-
Different secret between environments — your
.env.localsecret doesn't match production - Algorithm mismatch — token is HS256, server expects RS256
- Base64 encoding issue — secret stored as raw string in one place, Base64-encoded in another
-
Trailing whitespace/newline in env var — often from copy-pasting into
.envfiles
Fix: Always whitelist algorithms explicitly:
// ✅ Always specify allowed algorithms
jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256']
});
// ❌ Never do this — vulnerable to algorithm confusion attacks
jwt.verify(token, process.env.JWT_SECRET);
Error 3: jwt malformed
The token doesn't have exactly 3 dot-separated segments. Common causes:
- Forgot to strip the
Bearerprefix before passing to verify - Token got URL-encoded twice (dots become
%2E) - Token was truncated in transit or logging
// ✅ Strip Bearer prefix correctly
const token = req.headers.authorization?.startsWith('Bearer ')
? req.headers.authorization.slice(7)
: null;
Error 4: Claims mismatch (iss/aud invalid)
Token is valid and not expired — but still rejected. Check the iss and aud claims in the decoder and compare them character-by-character against your server config.
Watch for:
- Trailing slash differences (
https://api.app.comvshttps://api.app.com/) - HTTP vs HTTPS mismatch
- Port numbers in one config but not the other
Error 5: alg:none attack
Not an error you'll encounter — it's an attack you need to block. Some older JWT libraries accept tokens where "alg": "none" is set in the header. An attacker strips the signature, sets the algorithm to none, and crafts any payload they want.
The fix is the same as Error 2: always whitelist algorithms. Never trust the algorithm value from the token header itself. Modern libraries like jose (used in Next.js Edge Runtime) block this by default.
For Next.js specifically, the article on JWT Authentication with JOSE in the Edge Runtime covers the full setup.
The JWT Security Rules That Actually Matter
Never Store JWTs in localStorage
I know every tutorial from 2020 did this. It's still wrong in 2026.
localStorage is readable by any JavaScript on the page. One XSS vulnerability, one compromised npm package — and every active user's token is gone. I've seen this happen in production.
The correct pattern:
Access token → Memory only (React state / context). TTL: 5–15 minutes.
Refresh token → HttpOnly, Secure, SameSite=Lax cookie. Not readable by JS.
HttpOnly cookies literally cannot be accessed by JavaScript. That's the point.
For a full breakdown of storage options, the localStorage vs sessionStorage vs Cookies guide covers every tradeoff in detail.
Validate Every Claim on the Server
Verifying the signature is not enough:
function verifyToken(token: string) {
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'],
issuer: 'https://api.myapp.com',
audience: 'myapp-frontend',
}) as JWTPayload;
// Explicit expiry check — some libraries skip this by default
if (payload.exp < Math.floor(Date.now() / 1000)) {
throw new Error('Token expired');
}
return payload;
}
Never silently swallow verification failures. Log the exact error message — it tells you precisely what went wrong.
Keep Access Token TTL Short
15 minutes. A stolen token that expires in 15 minutes is a minor incident. A stolen token that lasts 7 days is a disaster.
HS256 vs RS256 vs ES256 — Quick Decision Guide
| Scenario | Use |
|---|---|
| Single service (issues and verifies tokens itself) | HS256 |
| Multiple services / microservices | RS256 |
| High-throughput API, short key preference | ES256 |
| Starting a new project right now | RS256 — you'll thank yourself later |
I've migrated projects from HS256 to RS256 mid-flight. It's painful. If you're starting fresh in 2026, go RS256 from day one.
Verify a JWT Locally with Node.js
When you need this in your terminal during debugging:
import jwt from 'jsonwebtoken';
function verifyToken(token: string) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET!, {
algorithms: ['HS256'],
});
console.log('✅ Valid:', JSON.stringify(decoded, null, 2));
return decoded;
} catch (err: any) {
console.error('❌ Failed:', err.message);
// "jwt expired" | "invalid signature" | "jwt malformed" | "jwt audience invalid"
throw err;
}
}
The error message from jwt.verify() is specific. Use it.
Need a fresh secret? The JWT Secret Key Generator creates cryptographically random secrets — minimum 256 bits for HS256.
Quick Note: JWT vs bcrypt
I see this confusion constantly. They solve different problems:
- bcrypt — hashes passwords at registration. You store the hash, never the plain password. At login, compare the submitted password against the stored hash.
- JWT — issued after bcrypt verifies the password. Represents the authenticated session.
One comes before the other. The Bcrypt Hash Generator & Verifier lets you test hashing behavior if you're building the full auth flow.
TL;DR
- JWT = Header (algorithm) + Payload (claims) + Signature (tamper-proof)
- Payload is Base64 encoded, not encrypted — anyone can read it
- Always whitelist algorithms explicitly — never trust the
algfrom the token - Store access tokens in memory, refresh tokens in HttpOnly cookies
- Use RS256 for anything beyond a single-service monolith
- The 5 errors (expired, invalid signature, malformed, claims mismatch, alg:none) cover 95% of JWT debugging
Free tool: WebToolsHub JWT Decoder & Verifier — paste any JWT, get full decoded output in under 5 seconds. Runs in your browser, nothing sent to any server.
Originally published at webtoolshub.online. Free developer tools including JWT decoder, regex tester, JSON-to-TypeScript converter, and more — no sign-up required.
Top comments (0)