A JWT is three base64 strings. Here's what's actually inside yours.
Decode a real JWT and understand what each of the three parts contains, why they're base64url encoded, and what you can and cannot trust.
A JSON Web Token looks like random noise — three chunks of characters separated by dots. But it’s completely readable. Every JWT you’ve ever issued can be decoded in seconds at jwt.io or with a one-liner in Node. Understanding what’s inside changes how you think about authentication.
The structure
A JWT has exactly three parts separated by .:
header.payload.signature
Each part is base64url encoded — that’s base64 with + replaced by -, / replaced by _, and padding stripped. It is not encryption. Anyone who has the token can decode the first two parts without any key.
const token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImlhdCI6MTcxOTg0MDAwMCwiZXhwIjoxNzE5ODQzNjAwfQ.abc123";
const [header, payload, sig] = token.split(".");
console.log(JSON.parse(Buffer.from(header, "base64url").toString()));
console.log(JSON.parse(Buffer.from(payload, "base64url").toString()));
Part 1: the header
The header declares the token type and the signing algorithm:
{
"alg": "HS256",
"typ": "JWT"
}
alg is the most important field here. Common values are HS256 (HMAC-SHA256, symmetric), RS256 (RSA, asymmetric), and ES256 (ECDSA, asymmetric). The algorithm determines what kind of key verifies the signature.
There was a famous vulnerability where servers accepted "alg": "none", meaning no signature required. Any compliant JWT library today rejects this by default, but it’s why you should never pass algorithms: [] to your verification function.
Part 2: the payload
This is where your actual data lives:
{
"sub": "user_123",
"role": "admin",
"iat": 1719840000,
"exp": 1719843600
}
The fields here are called claims. Some are registered (standardized):
sub— subject, typically the user IDiat— issued at, Unix timestampexp— expiration, Unix timestampiss— issuer, identifies who created the tokenaud— audience, identifies who should consume the tokenjti— JWT ID, a unique identifier for this specific token
Everything else is a custom claim. You can put anything here — user roles, tenant ID, feature flags — but keep the payload small because it travels in every request header.
Critical point: the payload is visible to anyone who holds the token. Do not put passwords, secrets, PII beyond what’s necessary, or anything you wouldn’t want a user to see. The only thing protecting the payload’s integrity is the signature.
Part 3: the signature
The signature is computed as:
HMACSHA256(
base64url(header) + "." + base64url(payload),
secret
)
For RS256 it’s a private-key signature instead of a shared secret, but the principle is the same: the signature proves that a specific party produced this exact header and payload.
When your server verifies a JWT, it:
- Splits the token into three parts
- Recomputes the signature over the header and payload
- Compares it to the provided signature
- Checks that
expis in the future
If any bit of the header or payload was modified, the signature comparison fails. This is why you can trust the claims — not because they’re secret, but because they can’t be tampered with without breaking the signature.
import jwt from "jsonwebtoken";
// Verify and decode in one call
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ["HS256"], // always specify this
});
console.log(decoded.sub); // "user_123"
What to actually check on verification
The library handles signature verification, but make sure you’re also enforcing:
exp— reject expired tokensiss— if you have multiple token issuers, check thisaud— if a token was issued for a different service, reject it
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ["HS256"],
issuer: "https://yourapp.com",
audience: "api",
});
The one thing most people miss
Because JWTs are stateless, you cannot invalidate a specific token before it expires. If a user logs out or you revoke access, the token is still valid until exp. This is why JWT expiry should be short (15 minutes to 1 hour) and paired with a refresh token mechanism. The token is a signed, readable, self-contained credential — treat it accordingly.