๐ JSON Web Tokens Explained
Once a user has logged in, every request they make afterwards must somehow prove "yes, this really is the same person." A JSON Web Token (JWT) is the tamper-evident wristband that carries that proof โ small enough to fit in an HTTP header, self-contained enough that your server can trust it without a database lookup, and signed so that nobody can forge or alter it.
Week 9 · Day 2 (Tuesday: JWT Authentication) · Lecture 1
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Explain what a JWT is and why stateless authentication scales better than server-side sessions
- Break a token into its three parts โ
header.payload.signatureโ and describe what each holds - Explain why the payload is encoded, not encrypted, and what must never go inside it
- Describe how a signature is produced and verified with HS256 versus RS256
- Identify the standard registered claims (
iss,sub,exp,iatโฆ) and whyexpmatters - Trace the request/response flow of a token from login to a protected route
Estimated Time: 55 minutes
Practice: Decode a real token by hand and predict the outcome of a set of verification scenarios.
In This Lesson
What Problem Does a JWT Solve?
HTTP is stateless โ the server forgets you the instant it finishes a response. So after you log in, how does the next request prove who you are? The classic answer was a session: the server stores a record ("session #a3fโฆ belongs to user 42") in memory or a database and hands the browser a cookie holding the session id. Every request the server looks that id up.
That works, but it puts state on the server. Scale to ten servers behind a load balancer and each one needs access to the same session store, or a user's requests must be pinned to one machine. JWTs flip the model: instead of storing the fact on the server, you hand the user a signed statement of fact and trust them to present it back. Because the statement is signed, the server can verify it hasn't been tampered with โ no lookup required.
๐ชช Real-world analogy: the tamper-evident wristband
Think of a festival wristband. At the gate, staff check your ticket once and clip on a wristband printed with a hard-to-forge pattern. Inside, any vendor can glance at the band and serve you โ they don't phone the box office. The band carries the proof. A JWT is that wristband: the server issues it once at login, and every later request wears it. Crucially, anyone can read what's printed on the band (it isn't a secret), but only the issuer can produce a valid one.
A JWT (pronounced "jot") is defined by RFC 7519. It is compact (fits in a header), self-contained (carries its own claims), and verifiable (digitally signed). That combination is why it powers API auth, single sign-on, and service-to-service trust across the modern web.
Anatomy of a Token
A JWT is a single string of three base64url-encoded parts joined by dots:
header.payload.signature
โ โ โ
โ โ โโ proves the first two parts weren't altered
โ โโ the claims: who the user is, when it expires
โโ metadata: which algorithm signed this token
A real (short) token looks like this โ notice the two dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBZGEiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
โ ๏ธ base64url is not encryption
Base64url is just a way to represent bytes as URL-safe text. Anyone who has the token can paste it into jwt.io and read the header and payload in plain JSON. The signature does not hide the data โ it only proves the data wasn't changed. Never put a password, a card number, or any secret in a JWT payload.
The Header
The header is a tiny JSON object describing how the token is signed. It has two standard fields: alg (the signing algorithm) and typ (the token type, always "JWT").
{
"alg": "HS256",
"typ": "JWT"
}
That JSON is base64url-encoded to become the first segment. The alg value tells the verifier which algorithm to expect โ and, importantly, the verifier should not blindly trust it (more on the "alg switching" attack in the Best Practices section).
๐ก HS256 vs. RS256 at a glance
HS256 (HMAC + SHA-256) uses one shared secret to both sign and verify โ simple, fast, perfect when the same server does both. RS256 (RSA + SHA-256) uses a private key to sign and a public key to verify โ ideal when many services need to verify tokens that only one auth server can issue.
The Payload & Claims
The payload is where the useful information lives. Each field is called a claim โ a statement about the user or the token itself.
{
"sub": "42",
"name": "Ada Lovelace",
"role": "admin",
"iat": 1516239022,
"exp": 1516242622
}
There are three flavours of claim. Registered claims are the standard, reserved names from RFC 7519 (below). Public claims are names you agree on with others (ideally namespaced to avoid collisions). Private claims are custom fields your own app understands, like role above.
| Claim | Name | What it means |
|---|---|---|
iss | Issuer | Who created and signed the token |
sub | Subject | Who the token is about โ usually the user id |
aud | Audience | Which service the token is intended for |
exp | Expiration | Unix time after which the token is invalid |
nbf | Not Before | Unix time before which the token isn't valid yet |
iat | Issued At | Unix time the token was created |
jti | JWT ID | Unique id โ handy for a revocation list |
โ ๏ธ Keep the payload lean โ and public
Two reasons. First, the token travels on every request, so a bloated payload wastes bandwidth. Second, and more important: the payload is readable by anyone holding the token. Put an id and a role, not an email address, phone number, or anything you would not print on a postcard. And always include exp โ a token with no expiry is a token that lives forever.
The Signature
The signature is what turns "some JSON a user handed me" into "a claim I can trust." The server takes the already-encoded header and payload, joins them with a dot, and runs them through the signing algorithm together with a secret that only the server knows:
// Conceptually, for HS256:
signature = HMACSHA256(
base64urlEncode(header) + "." + base64urlEncode(payload),
secret // the server's private secret โ NEVER shipped to the client
);
When a token comes back, the server recomputes that same HMAC over the received header and payload with its secret, and compares the result to the signature segment. If a single character of the payload was altered โ say an attacker changed "role": "user" to "role": "admin" โ the recomputed signature no longer matches, and verification fails. The attacker cannot fix the signature because they don't have the secret.
equals received one?"} G -->|Yes| H["Trust the claims"] G -->|No| I["Reject: tampered or forged"]
โ The core guarantee
A valid signature proves two things: the token was issued by someone who holds the secret (or private key), and it has not been modified since. It does not keep the contents secret โ that's confidentiality, a different property that JWTs alone do not provide.
The Authentication Flow
Here is the whole journey: the client logs in once, receives a token, and attaches it to every later request in the Authorization: Bearer header. The server verifies the signature and the expiry on each request.
The token rides in the HTTP Authorization header using the Bearer scheme โ "the bearer of this token is authorized":
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
The word "Bearer" matters: whoever holds the token can use it. That is exactly why the token must travel only over HTTPS and why access tokens are short-lived โ topics we build on in the next two lessons.
JWT vs. Server Sessions
Neither approach is universally "better" โ they trade different things. Sessions keep state on the server, which makes revocation trivial (delete the row) but scaling harder. JWTs push state to the client, which scales effortlessly but makes instant revocation genuinely difficult.
| Concern | JWT (stateless) | Server session |
|---|---|---|
| Where state lives | In the token, on the client | In a store, on the server |
| Scaling across servers | Trivial โ any server can verify | Needs a shared/sticky session store |
| Per-request database lookup | Not required | Required to load the session |
| Revoking access instantly | Hard โ needs a deny list or short expiry | Easy โ delete the session |
| Best fit | APIs, mobile clients, microservices, SSO | Traditional server-rendered web apps |
๐ก The revocation catch
Because a JWT is valid until it expires no matter what, you cannot simply "log someone out" server-side the way you delete a session. The standard mitigations โ short access-token lifetimes plus refresh tokens, and a jti deny list โ are exactly what the next two lessons cover.
Practice & Quiz
๐๏ธ Exercise 1: Decode a token by hand
Goal: Read the claims out of a token without any library, proving to yourself that the payload is public. In a Node REPL or the browser console, decode the middle segment.
const token =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" +
".eyJzdWIiOiI0MiIsIm5hbWUiOiJBZGEiLCJyb2xlIjoiYWRtaW4iLCJpYXQiOjE1MTYyMzkwMjJ9" +
".SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
// TODO: split on ".", take the payload segment,
// base64url-decode it, and JSON.parse it. What role is claimed?
๐ก Hint
Split the string on ".". The payload is index 1. In Node, Buffer.from(segment, "base64url") decodes it; in the browser use atob (after swapping the URL-safe characters). Then JSON.parse the resulting text.
โ Solution
const [ , payloadB64 ] = token.split(".");
// Node.js supports the "base64url" encoding directly:
const json = Buffer.from(payloadB64, "base64url").toString("utf8");
const claims = JSON.parse(json);
console.log(claims);
// { sub: '42', name: 'Ada', role: 'admin', iat: 1516239022 }
console.log(claims.role); // "admin"
// The lesson: you never provided a secret, yet you read every claim.
// Encoding is not encryption โ keep secrets OUT of the payload.
๐๏ธ Exercise 2: Predict the verification result
Goal: For each scenario, decide whether jwt.verify(token, secret) succeeds or throws, and why.
- The token's
expis one hour in the past. - An attacker edited
"role"to"admin"but left the signature untouched. - The token was signed with secret
"A"and you verify with secret"B".
โ Solution
- 1 โ throws
TokenExpiredError.jwt.verifychecksexpautomatically. - 2 โ throws
JsonWebTokenError: invalid signature. Changing the payload changes the HMAC, so the signature no longer matches. - 3 โ throws
invalid signature. Verifying with the wrong secret produces a different HMAC. This is exactly why the secret must stay on the server.
๐ฏ Quick Quiz
Question 1: Is the payload of a JWT encrypted?
Question 2: What prevents a client from changing their own role claim to "admin"?
Question 3: Which claim should every token carry to limit its lifetime?
Best Practices & Pitfalls
โ Do
- Treat the payload as public โ put an id and a role, never secrets or PII
- Always set a short
expon access tokens - Use a long, random secret (32+ bytes from a CSPRNG) for HS256, or a proper key pair for RS256
- Send tokens only over HTTPS, in the
Authorization: Bearerheader - Verify the signature and the claims (
exp, and where relevantiss/aud) on every request
โ Don't
- Don't store passwords, card numbers, or personal data in the payload
- Don't accept a token without verifying it โ never
jwt.decodeand trust it - Don't issue tokens with no expiry
- Don't hard-code the secret in source control โ use environment variables
โ ๏ธ The "alg: none" and algorithm-switching attacks
Some libraries historically accepted a token whose header said "alg": "none" โ meaning "no signature" โ and trusted it. A related trick sends an RS256-configured server a token signed with HS256 using the public key as the HMAC secret. The defence is to always pass an explicit allow-list: jwt.verify(token, key, { algorithms: ['HS256'] }). Never let the token's own header decide how it's verified.
Summary
๐ Key Takeaways
- A JWT is a signed, self-contained proof of identity:
header.payload.signature - The header and payload are base64url-encoded, not encrypted โ readable by anyone, so keep secrets out
- The signature guarantees integrity: alter the payload and verification fails, and only the secret holder can forge a valid one
- Standard claims like
sub,exp, andiatdescribe the user and the token's lifetime; always includeexp - JWTs enable stateless auth that scales across servers, at the cost of harder instant revocation
๐ Additional Resources
- jwt.io โ interactive JWT debugger and library directory
- RFC 7519 โ the official JSON Web Token specification
- OWASP โ JSON Web Token Cheat Sheet
- jsonwebtoken โ the JWT library for Node.js
๐ What's Next?
You understand what a token is โ now you'll build the machinery that issues and checks one. The next lesson, Implementing JWT Auth, wires up jwt.sign and jwt.verify, hashes passwords with bcrypt, and adds Express middleware that guards protected routes.
๐ Great start!
The token is no longer a black box โ you can read one, reason about its trust, and explain why the payload must stay clean.