How to Decode a JWT: A Comprehensive Step-by-Step Tutorial
Learn what a JWT is, how to decode one, what each part means, and real-world debugging tips from production experience.
What is a JWT, and why should you care?
JWT (pronounced "jot") stands for JSON Web Token. It's a compact, URL-safe way to represent claims between two parties. JWTs are everywhere in modern web development — you'll find them in authentication, API authorization, OAuth2, OpenID Connect, and more.
I remember when JWTs first became popular. We were using session cookies for everything, and JWTs felt like a breath of fresh air for APIs and SPAs. But with great power comes great responsibility, and I've seen my share of JWT-related bugs over the years.
A JWT has three parts separated by dots: Header.Payload.Signature. Let's look at a real example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c Each part is Base64URL encoded (not regular Base64 — there's a difference we'll cover later). The header and payload are JSON objects, and the signature is used to verify that the token hasn't been tampered with.
What's clever about JWTs is that they're stateless. The server doesn't need to store anything — all the information is in the token itself. This makes them great for scaling, but it also means you have to be careful about what you put in them and how you verify them.
The JWT Header Explained (part 1 of 3)
The header is the first part of the JWT, before the first dot. It typically contains two fields, but can have more:
- `alg`: The signing algorithm (e.g., HS256, RS256, ES256, none — but never use `none` in production!)
- `typ`: The token type (usually "JWT")
- `kid`: Key ID (optional, used when you have multiple signing keys)
- `cty`: Content type (rare, used for nested JWTs)
When we decode the header from our example, we get this:
{
"alg": "HS256",
"typ": "JWT"
} This tells us that the token uses HMAC-SHA256 (a symmetric algorithm) for signing, and that it's indeed a JWT.
The `alg` field is important. HS256 means the same secret key is used to sign and verify the token. RS256 means a private key signs it and a public key verifies it — this is common in OAuth2 and OpenID Connect where you don't control the identity provider.
One critical security note: never trust the `alg` header without validating it. An attacker could set `alg` to `none` and bypass signature verification if your library accepts it. Always configure your JWT library to expect a specific algorithm.
The JWT Payload Explained (part 2 of 3)
The payload is the second part, between the two dots. This is where the actual claims live. Claims are statements about the user and additional metadata.
There are three types of claims:
1. **Registered claims** — predefined claims that are recommended but not mandatory
2. **Public claims** — defined in the IANA JSON Web Token Registry or defined by you
3. **Private claims** — custom claims you create for your application
Here are the most common registered claims you'll see:
- `iss` (Issuer): Who created the token (e.g., `"https://auth.your-app.com"`)
- `sub` (Subject): Who the token is about — usually a user ID (e.g., `"user_12345"`)
- `aud` (Audience): Who the token is intended for — your app's identifier
- `exp` (Expiration time): When the token expires (Unix timestamp, seconds since epoch)
- `nbf` (Not before time): When the token becomes valid (can't use before this time)
- `iat` (Issued at time): When the token was created
- `jti` (JWT ID): Unique identifier for the token — good for preventing replay attacks
When we decode our example payload, we get this:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
} You can also include custom claims like `role`, `email`, `permissions`, or anything else your app needs. But a word of caution: don't put sensitive data in the payload unless it's encrypted. Remember: the header and payload are just Base64URL encoded — anyone can decode them.
The JWT Signature Explained (part 3 of 3)
The signature is the third part, after the second dot. This is what makes JWTs secure. The signature is created by taking the encoded header, the encoded payload, a secret or private key, and the algorithm specified in the header, then signing that combination.
For HS256 (HMAC-SHA256), the signature is computed like this:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
) The signature is then Base64URL encoded and appended as the third part.
The signature serves two crucial purposes:
1. It verifies that the sender of the JWT is who they say they are
2. It verifies that the message wasn't changed along the way
If anyone modifies the header or payload, the signature will no longer match, and verification will fail.
For RS256 (RSA-SHA256), the signature is created with a private key and verified with the corresponding public key. This is great for scenarios where the signer and verifier are different entities (like OAuth2 with Google or GitHub).
How to decode a JWT (step-by-step with examples)
Decoding a JWT is straightforward — you don't need any secret keys because you're just reading the data, not verifying it. Let's walk through it:
**Step 1: Get the JWT string** — Copy the full JWT. It should have three parts separated by dots. If it doesn't have three parts, it's not a valid JWT.
**Step 2: Split the string on dots** — You can do this in any language with a string split function. For example, in JavaScript:
const jwt = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const [header, payload, signature] = jwt.split('.'); **Step 3: Base64URL decode the header** — Remember, it's Base64URL, not regular Base64. The difference is that Base64URL uses - instead of + and _ instead of /, and omits padding. Here's how to decode it in JavaScript:
function base64UrlDecode(str) {
// Add padding if needed
str = str.padEnd(str.length + (4 - str.length % 4) % 4, '=');
// Replace URL-safe characters
str = str.replace(/-/g, '+').replace(/_/g, '/');
// Decode
return JSON.parse(atob(str));
}
const decodedHeader = base64UrlDecode(header);
console.log(decodedHeader); **Step 4: Base64URL decode the payload** — Same process as the header.
**Step 5: The signature part** — You can ignore the signature if you just want to read the claims. To actually verify the signature, you need the secret or public key.
And that's it! Decoding is simple. But remember: decoding is not verification. Just because you can read the claims doesn't mean you should trust them.
Critical: Decoding is NOT verification (this is important)
This is the single most important thing to understand about JWTs: decoding a JWT just lets you read the data. It does NOT tell you whether the token is valid or trustworthy.
Anyone can create a JWT with any claims. I could create a JWT right now that says `{"sub": "admin", "role": "superuser", "exp": 9999999999}` and sign it with my own secret. Decoding it would show those claims, but that doesn't make the token valid for your application.
To trust a JWT, you must verify the signature using the appropriate secret or public key. Verification also includes checking:
- The signature is valid (it matches the header and payload)
- The `exp` claim is in the future (token hasn't expired)
- The `nbf` claim is in the past (if present)
- The `iss` claim matches what you expect
- The `aud` claim matches what you expect
Never trust decoded claims without verifying the signature in production code. I've seen security vulnerabilities where an application used decoded claims without verification — don't make that mistake.
Also: Never log or share production JWTs. They can contain sensitive information and might be valid for a long time. If you need to debug, use a test token or redact the signature.
Common JWT decoding issues and how to fix them
After debugging countless JWT issues, these are the most common decoding problems I see:
**Problem 1: Trying to use regular Base64 instead of Base64URL** — JWT uses Base64URL, which is slightly different. Regular Base64 will often fail with invalid character errors. Make sure to replace - with + and _ with /, and handle padding correctly.
**Problem 2: The JWT has extra whitespace or characters** — Sometimes when copying JWTs from logs or chat, you get extra spaces or newlines. Make sure you're copying exactly the three parts with dots.
**Problem 3: The JWT is truncated** — JWTs can be long, especially with many claims. Make sure you have the complete token.
**Problem 4: Timestamps don't look right** — Remember that JWT timestamps are in seconds since epoch, not milliseconds. If you multiply by 1000 in JavaScript, you'll get a Date object that makes sense.
const exp = 1516239022;
const expirationDate = new Date(exp * 1000);
console.log(expirationDate); **Problem 5: The token has 5 parts instead of 3** — Wait, that's not a JWT! That's probably a JWE (JSON Web Encryption), which is an encrypted JWT. Or it might be a SAML token, which is XML-based and looks similar but is different.
If decoding fails, first check that you have a complete, valid JWT with three dot-separated parts. Then make sure you're using Base64URL decoding, not regular Base64.
Best practices for working with JWTs in production
Here are the best practices I've learned from years of working with JWTs in production:
**1. Always verify tokens**: Never skip verification in production. Use a reputable library — don't implement JWT verification yourself. Libraries like jsonwebtoken (Node.js), jose (JavaScript), or auth0/java-jwt (Java) are well-audited and reliable.
**2. Use short expiration times**: JWTs are stateless, so you can't easily revoke them. Use short expiration times (15-60 minutes) and use refresh tokens to get new JWTs.
**3. Don't put sensitive data in the payload**: The header and payload are only Base64URL encoded — anyone can read them. Don't put passwords, credit card numbers, or other sensitive information in the payload.
**4. Use HTTPS**: Always send JWTs over HTTPS. Never send them over unencrypted HTTP.
**5. Consider where to store them**: In browsers, prefer HTTP-only, secure cookies over localStorage for storing JWTs. localStorage is vulnerable to XSS attacks.
**6. Validate all claims**: Don't just check the signature — check `exp`, `iss`, `aud`, `nbf`, and any other claims that matter for your application.
**7. Use asymmetric keys when possible**: If you have multiple services or third-party clients, RS256 is better than HS256 because you don't have to share the private key.
**8. Have a key rotation strategy**: Signing keys should be rotated periodically. Use the `kid` header to support multiple keys.
**9. Don't use the `none` algorithm**: Ever. Even if you think it's only for testing. Just don't.
**10. Log carefully**: Don't log full JWTs in production. If you need to log something, log the `jti` or a hash of the token, not the token itself.
Try These Tools
Open the tools that go with this tutorial and practice what you learned.
More Tutorials in This Category
Keep Learning
JSON Best Practices for Developers
Battle-tested tips and best practices for working with JSON in APIs, config files, and data exchange from years of production experience.
JWT Debugging Guide: Common Issues and Fixes from Production
Troubleshoot JWT problems: expired tokens, invalid signatures, missing claims, and more with battle-tested debugging strategies.