How to Decode a JWT Token: Structure, Claims, and Verification
You receive a JWT from an authentication server and need to know what's inside. Or a login fails and you want to check whether the token has expired. Or you're building an API and need to extract the user ID from the sub claim. This guide shows you exactly how JWT tokens are structured, how to decode them, and how to verify that they haven't been tampered with.
What Is a JWT Token?
A JSON Web Token (JWT) is a compact, self-contained string that represents a set of claims — statements about a user or system — in JSON format. JWTs are used to transmit authentication information between parties (typically a client and a server) in a way that can be cryptographically verified.
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cThree Base64url-encoded segments separated by dots. Each segment has a distinct role.
JWT Structure: Three Parts
Every JWT consists of exactly three parts:
[HEADER].[PAYLOAD].[SIGNATURE]| Part | Contains | Encoded As |
|---|---|---|
| Header | Algorithm and token type | Base64url(JSON) |
| Payload | Claims (user data, expiry, etc.) | Base64url(JSON) |
| Signature | Cryptographic proof of integrity | Base64url(bytes) |
Part 1: Header
The header identifies which algorithm was used to sign the token.
{
"alg": "HS256",
"typ": "JWT"
}Common alg values:
| Algorithm | Type | Notes |
|---|---|---|
HS256 | HMAC-SHA256 (symmetric) | Same secret for signing and verifying |
HS512 | HMAC-SHA512 (symmetric) | Larger hash, same shared-secret model |
RS256 | RSA-SHA256 (asymmetric) | Private key signs, public key verifies |
ES256 | ECDSA-SHA256 (asymmetric) | Smaller keys than RSA, same trust model |
none | No signature | Dangerous — never accept in production |
Part 2: Payload (Claims)
The payload is a JSON object containing claims — key-value pairs that carry the actual information. There are three categories of claims:
Registered Claims (standard, short names)
| Claim | Full Name | Description |
|---|---|---|
sub | Subject | Identifies the principal (e.g., user ID) |
iss | Issuer | Who created the token (e.g., "auth.myapp.com") |
aud | Audience | Intended recipient(s) |
exp | Expiration Time | Unix timestamp after which token is invalid |
nbf | Not Before | Unix timestamp before which token is not valid |
iat | Issued At | Unix timestamp when token was created |
jti | JWT ID | Unique identifier for the token |
Example Payload
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}Part 3: Signature
The signature is computed over the encoded header and payload using the algorithm specified in the header. For HS256:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret
)The signature ensures the token hasn't been modified. If anyone changes even one character in the header or payload, the signature will no longer match and the token must be rejected.
How to Decode a JWT Token
Decoding a JWT means extracting the header and payload JSON from the Base64url encoding. Decoding is not the same as verification — anyone can decode a JWT without a secret key.
Method 1: DevToys JWT Decoder (fastest)
- Open JWT Decoder
- Paste your JWT token
- See the decoded header and payload instantly, with expiry status and claim descriptions
The token is decoded entirely in your browser — it is never sent to any server.
Method 2: JavaScript (Browser)
function decodeJwt(token) {
const [headerB64, payloadB64] = token.split('.');
// Base64url → Base64 → JSON
function decodeSegment(b64url) {
// Replace URL-safe chars and add padding
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/')
+ '='.repeat((4 - (b64url.length % 4)) % 4);
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
return JSON.parse(new TextDecoder().decode(bytes));
}
return {
header: decodeSegment(headerB64),
payload: decodeSegment(payloadB64),
};
}
// Usage
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
const { header, payload } = decodeJwt(token);
console.log(header); // { alg: "HS256", typ: "JWT" }
console.log(payload); // { sub: "1234567890", name: "John Doe", iat: 1516239022 }Method 3: Node.js
function decodeJwt(token) {
const [headerB64, payloadB64] = token.split('.');
function decodeSegment(b64url) {
const b64 = b64url.replace(/-/g, '+').replace(/_/g, '/');
return JSON.parse(Buffer.from(b64, 'base64').toString('utf-8'));
}
return {
header: decodeSegment(headerB64),
payload: decodeSegment(payloadB64),
};
}
const { header, payload } = decodeJwt(process.env.ACCESS_TOKEN);
console.log('User ID:', payload.sub);
console.log('Expires:', new Date(payload.exp * 1000).toISOString());Method 4: Command Line
TOKEN="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
# Decode header
echo $TOKEN | cut -d'.' -f1 | base64 -d 2>/dev/null | python3 -m json.tool
# Decode payload
echo $TOKEN | cut -d'.' -f2 | base64 -d 2>/dev/null | python3 -m json.toolChecking Token Expiry
The exp claim is a Unix timestamp (seconds since epoch). To check if a token has expired:
function isTokenExpired(payload) {
if (!payload.exp) return false; // No expiry = never expires
const nowSeconds = Math.floor(Date.now() / 1000);
return nowSeconds > payload.exp;
}
function getTimeUntilExpiry(payload) {
if (!payload.exp) return null;
const nowSeconds = Math.floor(Date.now() / 1000);
const secondsRemaining = payload.exp - nowSeconds;
if (secondsRemaining <= 0) return "Expired";
const minutes = Math.floor(secondsRemaining / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) return `Expires in ${hours}h ${minutes % 60}m`;
return `Expires in ${minutes}m ${secondsRemaining % 60}s`;
}
const { payload } = decodeJwt(token);
console.log('Expired?', isTokenExpired(payload));
console.log(getTimeUntilExpiry(payload)); // "Expires in 2h 14m"JWT Decoding vs. JWT Verification
This is the most important distinction to understand:
| Operation | What it does | Requires secret/key? | Safe for authorization? |
|---|---|---|---|
| Decode | Reads the header and payload JSON | No | No — payload can be forged |
| Verify | Checks that the signature matches | Yes | Yes — confirms token hasn't been tampered with |
Never use a decoded-but-unverified JWT to grant access. Anyone can craft a JWT with any payload and sign it with their own secret. Verification proves the token was signed by the expected issuer.
How to Verify a JWT Signature
Use a trusted library — don't implement signature verification yourself.
Node.js (jsonwebtoken)
const jwt = require('jsonwebtoken');
// HS256: symmetric — same secret for sign and verify
const secret = process.env.JWT_SECRET;
try {
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
console.log('Valid! User:', payload.sub);
} catch (err) {
if (err.name === 'TokenExpiredError') {
console.error('Token expired at', err.expiredAt);
} else if (err.name === 'JsonWebTokenError') {
console.error('Invalid token:', err.message);
}
}
// RS256: asymmetric — verify with public key
const publicKey = fs.readFileSync('public.pem');
const payload = jwt.verify(token, publicKey, { algorithms: ['RS256'] });Browser (Web Crypto API)
async function verifyHs256Jwt(token, secret) {
const encoder = new TextEncoder();
const [headerB64, payloadB64, signatureB64] = token.split('.');
// Import the secret key
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
// Decode signature from Base64url
const signatureBytes = Uint8Array.from(
atob(signatureB64.replace(/-/g, '+').replace(/_/g, '/')),
c => c.charCodeAt(0)
);
// Verify: the signed data is "header.payload"
const data = encoder.encode(`${headerB64}.${payloadB64}`);
return crypto.subtle.verify('HMAC', key, signatureBytes, data);
}Security Pitfalls to Avoid
1. Accepting the "none" algorithm
Some early JWT libraries accepted tokens with "alg": "none", which have no signature at all. An attacker could forge any payload. Always allowlist the algorithms you accept:
// Good: explicitly require HS256
jwt.verify(token, secret, { algorithms: ['HS256'] });
// Bad: accepting any algorithm (including 'none')
jwt.verify(token, secret); // some libraries default to allowing 'none'2. Using decode instead of verify for authorization
// WRONG: trusts the payload without verifying the signature
const { payload } = decodeJwt(token); // no signature check
if (payload.role === 'admin') grantAccess(); // attacker can forge this
// CORRECT: verifies signature first
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] });
if (payload.role === 'admin') grantAccess();3. Not checking exp
// Good: jsonwebtoken checks exp automatically
const payload = jwt.verify(token, secret); // throws if expired
// If decoding manually, always check exp yourself
if (isTokenExpired(payload)) throw new Error('Token expired');4. Storing JWTs in localStorage
localStorage is accessible to any JavaScript on the page, making JWTs stored there vulnerable to XSS attacks. Prefer HttpOnly cookies for sensitive tokens, or keep short-lived tokens in memory.
Common JWT Debugging Scenarios
Scenario 1: "Token expired" errors in production
- Decode the token and check
exp— convert from Unix timestamp:new Date(exp * 1000).toISOString() - Check if clocks are synchronized between your auth server and API server (NTP drift)
- Add a small clock skew tolerance:
clockTolerance: 30(seconds) in jsonwebtoken
Scenario 2: Token looks valid but authorization fails
- Decode the payload and check
aud— the audience must match your API's expected value - Check
iss— the issuer should be the expected identity provider - Verify your server is using the correct public key / JWKS endpoint
Scenario 3: Decoding a token from an OAuth provider
OAuth access tokens from providers like Auth0 or Google may be opaque tokens (not JWTs) or JWTs signed with RS256. For RS256, verify using the provider's public JWKS endpoint instead of a shared secret:
// Example: verify Auth0 token using their JWKS
const jwksClient = require('jwks-rsa');
const jwt = require('jsonwebtoken');
const client = jwksClient({ jwksUri: 'https://YOUR_DOMAIN/.well-known/jwks.json' });
function getKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
callback(err, key?.getPublicKey());
});
}
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, payload) => {
if (err) console.error('Invalid:', err.message);
else console.log('Valid! Sub:', payload.sub);
});Quick Reference: JWT Claim Cheat Sheet
| Claim | Type | What to check |
|---|---|---|
exp | Unix timestamp (number) | Must be in the future |
nbf | Unix timestamp (number) | Must be in the past (or absent) |
iat | Unix timestamp (number) | Should be recent; flag tokens issued far in the past |
iss | String or URI | Must match your expected issuer |
aud | String or array | Must include your service's identifier |
sub | String | The user/entity identifier — use this, not a custom "userId" |
jti | String | Unique ID — useful for token revocation (store revoked JTIs) |
alg | String (in header) | Allowlist only the algorithms your system supports |
Summary
A JWT is three Base64url-encoded segments: a header declaring the algorithm, a payload carrying claims, and a signature proving integrity. Decoding reads the header and payload — anyone can do it. Verification checks the signature and requires the correct key.
Key rules:
- Always verify the signature server-side using a trusted library before trusting any claims
- Always check
exp,iss, andaud - Never accept
"alg": "none" - Use decode-only for debugging and inspection, never for authorization decisions
Need to inspect a JWT quickly? JWT Decoder decodes any token in your browser instantly — header, payload, claims, and expiry status — without sending your token anywhere.