Anatomy of a JWT
Now that we know what a token is conceptually, it is time to actually look inside one. JWTs look mysterious from the outside: a long, ugly string of characters separated by dots. In reality, they are almost laughably straightforward once you see the pieces. This lesson we are going to dissect a real JWT and see exactly what is in it. Along the way, we will clear up the single biggest misconception about JWTs, the one that causes people to accidentally leak sensitive data into tokens thinking it is safe.
What JWT stands for
JWT stands for JSON Web Token. It is pronounced “jot” (yes, really, like the verb “to jot down a note”). It is an open standard, RFC 7519, for creating tokens that carry a JSON payload and are signed to prevent tampering.
A JWT looks like this:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOiI0MiIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJyb2xlIjoidXNlciJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c Terrifying, right? Actually, no, not really. Notice the two dots. They split the string into three parts. Each part is Base64URL-encoded, which means we can decode them and read them. Let’s do that now.
The three parts
1. Header
eyJhbGciOiJIUzI1NiJ9 Decoded:
{ "alg": "HS256" } The header tells you which algorithm was used to sign the token. HS256 means HMAC with SHA-256, which is a symmetric signing algorithm (meaning the same secret key is used to both sign and verify).
There are other algorithms you might see out there:
HS256: symmetric, HMAC-SHA-256. Same secret signs and verifies. Simplest option.RS256: asymmetric, RSA. A private key signs, a public key verifies. Useful when many services need to verify tokens but only one issues them.ES256: asymmetric, elliptic curve. Same idea as RS256 but smaller and faster keys.
We will use HS256 in this course because it is the simplest. For our case (one backend that both issues and verifies tokens), symmetric signing is perfect.
2. Payload
eyJ1c2VySWQiOiI0MiIsImVtYWlsIjoiYWxpY2VAZXhhbXBsZS5jb20iLCJyb2xlIjoidXNlciJ9 Decoded:
{
"userId": "42",
"email": "[email protected]",
"role": "user"
} This is where the fun happens. The payload is the actual data you put in the token. In JWT terminology, these fields are called claims. You can put any JSON data here: user ID, email, role, permissions, whatever you need for your app.
There are also some standard claims defined in the JWT spec. You can include any of these alongside your custom ones:
iss(issuer): who created the token, like"my-app.com"sub(subject): who the token is about, usually the user IDexp(expiration): when the token expires, as a Unix timestampiat(issued at): when the token was created, as a Unix timestampnbf(not before): earliest time the token should be considered valid
Of these, exp is by far the most important. A token with no expiration is valid forever. If it ever leaks, that is your problem forever. Always set an expiration.
3. Signature
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c This is the signature. It is created by taking the header and payload, combining them, and signing with a secret key:
HMAC-SHA256(
base64url(header) + "." + base64url(payload),
secret
) The signature proves that the token was created by someone who knows the secret. If anyone modifies the header or the payload (changes "role": "user" to "role": "admin", say), the signature will no longer match when you recompute it, and the server will reject the token.
This is the crucial property. The payload is public, but the signature lets you detect tampering.
The biggest misconception about JWTs
OK, here is the part nobody tells beginners clearly. Look back at what we did a moment ago. We took that terrifying Base64 string and decoded it into plain-readable JSON. That is right: the payload is Base64URL-encoded, not encrypted.
Base64 is a reversible encoding scheme. Anyone on earth can decode it. There is no secret key. There is no decryption step. It is literally just a way to represent bytes as ASCII characters.
Base64URL is a variant of Base64 specifically designed to be safe in URLs and HTTP headers. It swaps + for -, swaps / for _, and drops the trailing = padding. If you ever want to decode it manually, you convert back to standard Base64 first:
function decodeBase64Url(str: string): string {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
return atob(base64);
}
const payload = "eyJ1c2VySWQiOiI0MiJ9";
console.log(decodeBase64Url(payload)); // {"userId":"42"} You will rarely do this manually in practice (the jose library handles everything for us), but the point is real and important: anyone who has the token can read its contents. The signature does not hide the data. It only proves the data has not been tampered with.
[!WARNING] Never put sensitive data in a JWT payload. No passwords. No credit card numbers. No API keys. No personal secrets of any kind. The payload is readable by anyone who gets their hands on the token. Use JWTs for identity claims (user ID, role, email) that you would be comfortable writing on a whiteboard.
If you need actual confidentiality, that is a different standard called JWE (JSON Web Encryption). We are not using it in this course, but be aware it exists if you ever need it.
How verification works
When the server receives a JWT, it does three things:
- Decode the header and payload (just Base64URL decoding, no secret needed).
- Recompute the signature using the header, payload, and the server’s secret.
- Compare the recomputed signature to the signature that came in the token.
If the signatures match, the token is genuine and has not been tampered with. The server can trust the payload data and use it to figure out who the request is from.
If they do not match, someone fiddled with the token (or the token was created by someone using a different secret). The server rejects the request.
Token arrives: header.payload.signature
Server:
1. recomputed = HMAC-SHA256(header + "." + payload, secret)
2. recomputed === signature? → trust the payload
3. recomputed !== signature? → reject No database lookup. No session store query. The server just needs the secret key and the token itself.
Why the secret matters so much
The signing secret is the only thing preventing an attacker from creating their own tokens. If they ever get the secret, game over. They can sign a token with any payload they want, including "userId": "1", "role": "admin". The server verifies the signature, sees it is valid, trusts the payload, and hands them the keys to the kingdom.
The secret must be:
- Long enough to resist brute-force attacks (at least 256 bits, which is 32 bytes).
- Random, not a dictionary word, not “password”, not a common phrase.
- Stored securely, in an environment variable or secrets manager, never hardcoded in source code.
- Never exposed to clients, ever. Front-end code, public repos, logs, nowhere.
In the next lesson, we generate a proper secret and use it to actually sign JWTs on login.
Can you read the contents of a JWT without knowing the secret key?
What happens if an attacker changes the payload of a JWT (for example, changing the role from 'user' to 'admin')?