Skip to main content

๐Ÿ” 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 why exp matters
  • 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
A JWT is three base64url segments โ€” header, payload, signature โ€” separated by dots Header alg + typ base64url . Payload claims (data) base64url . Signature HMAC / RSA over header + payload + secret ๐Ÿ”“ anyone can read Header & Payload  ยท  ๐Ÿ”’ only the secret holder can forge the Signature
The first two parts are merely encoded (reversible, public). The third part is what makes the token trustworthy.

โš ๏ธ 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 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.

ClaimNameWhat it means
issIssuerWho created and signed the token
subSubjectWho the token is about โ€” usually the user id
audAudienceWhich service the token is intended for
expExpirationUnix time after which the token is invalid
nbfNot BeforeUnix time before which the token isn't valid yet
iatIssued AtUnix time the token was created
jtiJWT IDUnique 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.

graph LR A["Header + Payload"] --> B["Sign with secret"] B --> C["Signature attached"] C --> D["Token sent to client"] D --> E["Client returns token later"] E --> F["Server re-signs Header + Payload"] F --> G{"Recomputed signature
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.

sequenceDiagram participant C as Client participant S as Server C->>S: POST /login with email + password S->>S: Verify credentials against database S->>S: Build payload, sign with secret S-->>C: Return signed JWT Note over C,S: Later, for every protected request C->>S: GET /profile with Authorization Bearer token S->>S: Verify signature S->>S: Check exp claim not passed S-->>C: Return protected resource

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.

ConcernJWT (stateless)Server session
Where state livesIn the token, on the clientIn a store, on the server
Scaling across serversTrivial โ€” any server can verifyNeeds a shared/sticky session store
Per-request database lookupNot requiredRequired to load the session
Revoking access instantlyHard โ€” needs a deny list or short expiryEasy โ€” delete the session
Best fitAPIs, mobile clients, microservices, SSOTraditional 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.

  1. The token's exp is one hour in the past.
  2. An attacker edited "role" to "admin" but left the signature untouched.
  3. The token was signed with secret "A" and you verify with secret "B".
โœ… Solution
  • 1 โ€” throws TokenExpiredError. jwt.verify checks exp automatically.
  • 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 exp on 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: Bearer header
  • Verify the signature and the claims (exp, and where relevant iss/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.decode and 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, and iat describe the user and the token's lifetime; always include exp
  • JWTs enable stateless auth that scales across servers, at the cost of harder instant revocation

๐Ÿ“š Additional Resources

๐Ÿš€ 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.