Open the IRCTC app to book a Rajdhani ticket. You log in once with your username and password. Then you search trains, add passengers, pick a berth, and go to payment — four or five separate screens, each one sending a fresh request to IRCTC's servers. Here is the question almost nobody asks: how does the payment page know you're the same person who logged in three screens ago? The web's core protocol, HTTP, has no memory at all. Every request arrives at the server as a stranger, with no built-in link to any request that came before it. So something has to travel with each of your requests, proving "this is Aditi, and she really did log in a few minutes back." That something, for most modern apps — including many built by Indian companies — is a JWT: a JSON Web Token. This chapter builds up exactly what a JWT is, how it's constructed byte by byte, why it can't be forged, and where it can still go wrong.
The Core Problem: HTTP Has No Memory
Every time your browser talks to a server, it sends an HTTP request and gets back an HTTP response. That's it — the connection doesn't stick around as a conversation. This is called being stateless, and it was a deliberate design choice: statelessness is exactly what let the web scale to billions of independent requests without every server having to remember every visitor forever.
But login systems need the opposite of that. Once you've proven who you are with a password, you don't want to retype it on every single page. So the system needs some way to carry proof of "I already authenticated" across otherwise-disconnected requests. There are two fundamentally different ways to solve this, and understanding the difference is the key to understanding why JWTs exist.
Approach 1 — the guest list. After login, the server creates a record in its own database: "session ID 8841 belongs to Aditi, logged in at 10:02 AM." It sends Aditi's browser just the ID, 8841, usually stored in a cookie. On every future request, the browser sends back "8841," and the server looks that ID up in its database to check who it belongs to and whether it's still valid. This is the traditional session approach — the server acts like a bouncer with a guest list, checking the list on every single request.
Approach 2 — the wristband. Think of a water park. You pay at the gate, and instead of a guard checking a paper list every time you walk past a slide, you get a wristband — a specific color, maybe with today's date printed on it, sealed so it can't be removed and re-attached without tearing. Every slide operator just glances at your wrist. No radio call to the front gate, no list lookup. The wristband itself carries enough tamper-proof information to prove you paid.
A JWT is the digital wristband. Instead of the server storing "this user is logged in" and making the client fetch that fact back with an ID, the server packs the actual facts — who you are, what you're allowed to do, when this expires — directly into a signed token and hands the whole thing to the client. The server doesn't have to remember anything. It just has to be able to check, in a fraction of a millisecond, that the wristband hasn't been tampered with.
What the Wristband Must Guarantee
Before looking at the actual format, it's worth being precise about what property a digital wristband needs, because this is exactly what a JWT is engineered to deliver:
- Self-contained — the token itself should carry the claims (who the user is, their role, when it expires) so the server doesn't need a database round-trip to check a session table on every request.
- Tamper-evident — if anyone changes even one character of the data inside the token, the server must be able to detect it instantly.
- Verifiable without secrecy of the data itself — the server needs to check authenticity fast, using simple math, not by re-encrypting and comparing.
Notice what's not on that list: keeping the data hidden from the user carrying it. That distinction — tamper-proof but not secret — is the single most important, most frequently misunderstood idea in this whole topic, and we'll return to it explicitly once you've seen the format.
Anatomy of a JWT
A JWT is a single string made of exactly three parts, separated by two dots:
header.payload.signature
Each of the three parts is a chunk of text produced by a specific process:
- Header — a small JSON object stating the token type and which signing algorithm was used, for example
{"alg":"HS256","typ":"JWT"}. HS256 means "HMAC using SHA-256," a specific way of computing a signature that you'll see built by hand below. - Payload — a JSON object holding the actual claims: facts about the user and the token itself, such as
sub(subject — usually a user ID),name,role,iat(issued-at time), andexp(expiry time). Bothiatandexpare stored as Unix timestamps — the number of seconds since 1 January 1970. - Signature — a cryptographic stamp computed from the header, the payload, and a secret key that only the server knows. This is what makes the token tamper-evident.
Both the header and the payload are converted from JSON text into a compact, URL-safe text encoding called Base64URL before being joined together. Base64URL takes arbitrary bytes (here, the raw characters of a JSON string) and re-expresses them using only the 64 characters A–Z, a–z, 0–9, -, and _ — safe to place inside a URL or an HTTP header with no special characters to escape. It is a completely mechanical, reversible substitution. It is not a cipher, and it needs no key to reverse: any Base64URL decoder in the world can turn it straight back into the original JSON.
Building a Token by Hand
Let's construct one real token step by step, the way a server actually would after Aditi logs in. Start with the header and payload as plain JSON:
Header: {"alg":"HS256","typ":"JWT"}
Payload: {"sub":"aditi2026","name":"Aditi Sharma","role":"student","iat":1755000000,"exp":1755003600}
Notice exp is exactly 3600 seconds — one hour — after iat. That's the server deciding this wristband expires in an hour, a design choice we'll come back to.
Step 1: Base64URL-encode the header JSON text:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
Step 2: Base64URL-encode the payload JSON text:
eyJzdWIiOiJhZGl0aTIwMjYiLCJuYW1lIjoiQWRpdGkgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3NTUwMDAwMDAsImV4cCI6MTc1NTAwMzYwMH0
Step 3: Join them with a dot to get the signing input: header_encoded.payload_encoded. This joined string is what actually gets signed — not the raw JSON, the encoded text.
Step 4: Run HMAC-SHA256 over that signing input, using a secret key that lives only on the server — say, the text cbse-ai-institute-secret. HMAC (Hash-based Message Authentication Code) mixes the secret key into a cryptographic hash function in a specific two-pass construction so that producing a correct output is only possible if you know the key, even though the hash function itself (SHA-256) is public and has no secrets in it. The output is a fixed-length string of bytes, which gets Base64URL-encoded too:
mGyjnYzXZxFhtZY9dvX1VhYRBD9hGsP4klYNn2b4WBI
Step 5: Join all three with dots. The final token — verified by actually running this exact construction in Node.js's built-in crypto module — is:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZGl0aTIwMjYiLCJuYW1lIjoiQWRpdGkgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3NTUwMDAwMDAsImV4cCI6MTc1NTAwMzYwMH0.mGyjnYzXZxFhtZY9dvX1VhYRBD9hGsP4klYNn2b4WBI
That's the whole wristband. Three dot-separated chunks, roughly 180 characters, small enough to fit in an HTTP header on every request.
Why the Signature Stops Cheating
Suppose Aditi is curious, decodes her own payload (trivial — it's just Base64URL, no key needed), and notices the field "role":"student". She edits it in a text tool to "role":"admin", re-encodes it, and swaps that new encoded chunk into her token, keeping the old signature at the end. Will the server be fooled?
No — and here is exactly why. The server's verification step is not "decode the payload and trust it." It's: re-run the same HMAC-SHA256 computation on whatever header and payload actually arrived, using its own secret key, and check whether the result matches the signature chunk that arrived with the token. Aditi's edited payload produces a completely different signature when hashed — HMAC-SHA256 is designed so that changing even one character anywhere in the input scrambles the entire output unpredictably. Since Aditi doesn't know the server's secret key, she cannot compute the new correct signature to go with her edited payload. She can only re-use the old signature, which now no longer matches. The server computes its own expected signature, compares it to what arrived, sees they differ, and rejects the token outright.
This was verified directly: encoding a payload with role changed to "admin", but reusing the original signature, and running it through a real verification function returns null — rejected. The forged token doesn't produce a corrupted user object or a partial login. It produces nothing at all; the server treats it as invalid from the first check.
Common Misconception: "A JWT Is Encrypted"
This is the single most common misunderstanding, and it causes real security bugs, so let's correct it precisely. A JWT signed with HS256 (or the similarly common RS256) is encoded and signed, but not encrypted. Encoding (Base64URL) is fully reversible by anyone with no key. Signing proves the data hasn't been altered and, if you trust the issuer's key management, who created it — but it does nothing to hide the data. Anyone holding a JWT — including the legitimate user it belongs to, or anyone who intercepts it — can decode the header and payload instantly, with nothing more than a Base64URL decoder (there is a website, jwt.io, built exactly for pasting in a token and reading its contents in plain text).
The practical consequence: never put a password, an OTP, a bank account number, or any other secret directly into a JWT payload. Put only things that are fine for the token-holder — and anyone who might steal the token — to read: a user ID, a username, a role, an expiry time. If you truly need to transmit encrypted (unreadable) claims inside a token, a different, less common standard called JWE (JSON Web Encryption) exists for that — but the JWTs used for everyday login systems, including the HS256 example built above, guarantee integrity, not confidentiality.
The Full Login Flow
Now place the token inside the complete system. Here is what actually happens when Aditi logs into a JWT-based app:
Six moments to notice in that diagram. First, the password is checked exactly once, at login — every later request proves identity with the token, not the password. Second, the token travels in the Authorization header as Bearer <token>, a convention almost every JWT-based API follows. Third, and this is the entire point of the design, the server's check on /my-tickets needs no database at all — it recomputes one HMAC and compares two strings, which is why JWT-based systems handle huge request volumes cheaply: no session table to query on every click, no shared session store to keep in sync across multiple servers. Fourth, that's also why the payload has to be self-contained — the server isn't looking anything up, so anything it needs to know (user ID, role) has to already be sitting inside the token. Fifth, the exp claim is checked as plainly as the signature: past its expiry, the token is rejected even though its signature is still perfectly valid, because validity of the signature only proves the token wasn't altered — it says nothing about whether it should still be honoured. Sixth, once expired, Aditi must log in again (or, in more advanced systems, use a separate long-lived "refresh token" to obtain a fresh access token without retyping her password — a pattern used by apps like UPI-linked banking apps, though the full mechanics of refresh-token rotation go beyond this chapter).
Verifying the Whole Construction in Code
Everything above was checked by actually running it, using only Node.js's built-in crypto module — no external library, so you can see every step:
const crypto = require('crypto');
function base64url(input) {
return Buffer.from(input)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function signToken(payload, secret) {
const header = { alg: 'HS256', typ: 'JWT' };
const encodedHeader = base64url(JSON.stringify(header));
const encodedPayload = base64url(JSON.stringify(payload));
const signingInput = encodedHeader + '.' + encodedPayload;
const signature = crypto
.createHmac('sha256', secret)
.update(signingInput)
.digest('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
return signingInput + '.' + signature;
}
function verifyToken(token, secret) {
const [encodedHeader, encodedPayload, signature] = token.split('.');
const expected = crypto
.createHmac('sha256', secret)
.update(encodedHeader + '.' + encodedPayload)
.digest('base64')
.replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_');
if (expected !== signature) return null; // tampered or forged
return JSON.parse(Buffer.from(encodedPayload, 'base64').toString());
}
const secret = 'cbse-ai-institute-secret';
const payload = {
sub: 'aditi2026', name: 'Aditi Sharma', role: 'student',
iat: 1755000000, exp: 1755003600
};
const token = signToken(payload, secret);
console.log(token);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhZGl0aTIwMjYiLCJuYW1lIjoi
// QWRpdGkgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3NTUwMDAwMDAsImV4cCI6
// MTc1NTAwMzYwMH0.mGyjnYzXZxFhtZY9dvX1VhYRBD9hGsP4klYNn2b4WBI
console.log(verifyToken(token, secret));
// { sub: 'aditi2026', name: 'Aditi Sharma', role: 'student',
// iat: 1755000000, exp: 1755003600 }
Trace it line by line to see why the output is exactly that. signToken builds the header object, JSON-stringifies it, Base64URL-encodes it — that's encodedHeader. It does the same for the payload — that's encodedPayload. It joins them with a dot to build signingInput, feeds that into crypto.createHmac('sha256', secret), and the resulting digest, Base64URL-cleaned, is the signature. All three pieces get joined with dots and returned — matching, character for character, the token built by hand earlier in this chapter, because it's the identical algorithm. Then verifyToken splits the token back into its three pieces, recomputes what the signature should be from the header and payload that arrived, and compares. If they match, it trusts the payload enough to decode and return it; if not, it returns null — deliberately not a half-decoded object, not an error that leaks information, just nothing.
A separate run, feeding in a token whose payload had been edited to say "role":"admin" while keeping the original signature, was passed through this exact verifyToken function and returned null — the forgery attempt outlined earlier caught in practice, not just in theory.
Sessions vs. JWTs: Choosing the Right Tool
Neither approach is universally "better" — they trade off differently, and CBSE-level understanding means knowing which problem each one solves:
- Revocation. With server-side sessions, logging a user out, or banning them, is one database delete — instant, everywhere. With JWTs, the token is valid until it expires, full stop, because the server never stored it in the first place. There's no row to delete. Systems that need to revoke JWTs early usually keep a small "blocklist" of cancelled token IDs — which quietly reintroduces some server-side state, undercutting part of the original appeal.
- Scaling across many servers. A site like IRCTC, during Tatkal booking rush, may be running many web servers behind a load balancer. With session cookies, every server needs access to the same shared session store. With JWTs, any server holding the same secret key can verify any token instantly, with zero coordination — a real advantage at scale.
- Token size. A session cookie can be a short ID, tens of bytes. A JWT carries its whole payload on every single request, which for a token with many claims can be noticeably heavier — a real bandwidth cost multiplied across millions of requests.
- Where the token lives in the browser. Storing a JWT in
localStoragemakes it readable by any JavaScript running on the page — including a malicious script injected through a cross-site scripting (XSS) bug. Storing it in anhttpOnlycookie hides it from JavaScript but reopens a different class of attack (cross-site request forgery, CSRF) that cookie-based sessions have always had to guard against. There is no storage location that is simply safe by default; each choice trades one risk for another, which is why production systems layer on additional defenses rather than relying on the token format alone.
Active Recall
- A JWT is split into three parts by two dots. Name each part, in order, and state in one line what each one is responsible for.
- Base64URL-encode this JSON payload by hand-tracing the process (you don't need the exact characters, just describe the steps):
{"sub":"raj99","role":"admin"}. Then explain: could a curious user read this payload without the server's secret key? Could they successfully change it to say"role":"superadmin"and have the server accept it? Justify both answers separately. - A classmate says, "JWTs are safe because they're encrypted, so nobody but the server can see what's inside." Identify the exact error in this claim and correct it in two sentences.
- Why does a JWT-based server not need to query a database on every request to check if a user is logged in, while a traditional session-cookie system does? What is one type of situation where this actually becomes a disadvantage of JWTs, instead of an advantage?
- An app's JWT has
"iat": 1755000000and"exp": 1755003600. How many seconds — and how many hours — is this token valid for? If the current server time in Unix-timestamp form is1755003700, will this token be accepted? Justify using the numbers. - Explain, in your own words, why HMAC-SHA256 needs a secret key on the server side, while Base64URL encoding needs no key at all. What would go wrong with the whole authentication scheme if the signature step were removed and a JWT were just
header.payloadwith no third part?
Summary
HTTP forgets you after every single request, so a login system needs a way to carry proof of identity forward without re-checking a password each time. A JWT solves this the way a sealed wristband solves it at a water park: it is a self-contained, tamper-evident string — header.payload.signature — where the header names the signing algorithm, the payload carries claims like user ID, role, and expiry time, and the signature is an HMAC-SHA256 stamp computed from the first two parts using a secret key that only the issuing server holds. Base64URL, used to encode the header and payload, is a reversible text encoding, not encryption — anyone can read a JWT's contents, so secrets never belong inside one. What a valid signature actually proves is narrower and more precise than "this is safe": it proves the header and payload have not been altered since the server signed them, nothing more — checking whether the token should still be honoured (via exp) and whether it's been explicitly revoked are separate concerns layered on top. That precision — self-contained, verifiable with one fast recomputation, and stateless across many servers — is exactly why JWTs power login on a huge share of the modern, high-traffic web, from ticket-booking systems to banking apps, while carrying trade-offs around revocation and storage that every real system has to design around deliberately rather than assume away.
Think About It
Think about this: How would you explain jwt authentication: secure login systems to a friend who has never seen a computer? What real-world analogy would you use? Imagine you had to build a system using these concepts — what would be your first step? Try this: before moving on, write down three things you learned and one question you still have.