In this guide
A login system that stores a session row in the database for every logged-in user works fine at classroom scale. The moment your project has a separate frontend and backend — a React app talking to a Node API, or a mobile app calling the same endpoints — passing identity around with server-side sessions starts to fight you. JSON Web Tokens (JWTs) are the standard answer: at login the server hands the client a signed, self-contained token, and the client presents that token with every request. This guide explains the token format by decoding a real token by hand, walks through the complete login and refresh flow, and builds a working Node.js implementation you can adapt for your own project.
Short answer: what a JWT is and why your project uses one
A JWT is a compact, URL-safe string in three parts — header, payload, signature — separated by dots. The header says how the token was signed, the payload carries claims (who the user is, their role, when the token expires), and the signature proves the payload was issued by your server and not tampered with. Your API verifies the signature on every request instead of looking up a session in the database, which makes the API stateless: any server instance can verify any token using only the secret key. That is the whole trick, and everything else in this guide — refresh tokens, storage rules, revocation — is engineering around the consequences of that trick.
Anatomy of a token: decode one by hand
Here is a real, working token (signed below with the throwaway secret dev-secret-do-not-ship-this, so you can verify every step yourself):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQ4MjEiLCJuYW1lIjoiQWFyYXYgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3ODc2MTYwMDAsImV4cCI6MTc4NzYxNjkwMH0.WAS6DgCOUxdAqOYytXIrrkojMUisVTJoCvUuDNZvytM
Split it on the dots. You get three base64url-encoded segments:
| Segment | Encoded value | Decodes to |
|---|---|---|
| Header | eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 |
{"alg":"HS256","typ":"JWT"} |
| Payload | eyJzdWIiOiJ1c2VyXzQ4MjEiLCJuYW1lIjoiQWFyYXYgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3ODc2MTYwMDAsImV4cCI6MTc4NzYxNjkwMH0 |
{"sub":"user_4821","name":"Aarav Sharma","role":"student","iat":1787616000,"exp":1787616900} |
| Signature | WAS6DgCOUxdAqOYytXIrrkojMUisVTJoCvUuDNZvytM |
HMAC-SHA256 of header.payload, keyed with the secret |
Verify it yourself with Python — no libraries needed beyond the standard one:
import base64, hmac, hashlib
def b64u_decode(s):
return base64.urlsafe_b64decode(s + '=' * (-len(s) % 4))
token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzQ4MjEiLCJuYW1lIjoiQWFyYXYgU2hhcm1hIiwicm9sZSI6InN0dWRlbnQiLCJpYXQiOjE3ODc2MTYwMDAsImV4cCI6MTc4NzYxNjkwMH0.WAS6DgCOUxdAqOYytXIrrkojMUisVTJoCvUuDNZvytM'
header_b64, payload_b64, sig_b64 = token.split('.')
print(b64u_decode(header_b64))
print(b64u_decode(payload_b64))
expected = base64.urlsafe_b64encode(
hmac.new(b'dev-secret-do-not-ship-this',
(header_b64 + '.' + payload_b64).encode(),
hashlib.sha256).digest()).rstrip(b'=').decode()
print('signature valid:', expected == sig_b64)
Three things this exercise teaches you that no diagram can:
- The payload is readable by anyone. Base64url is an encoding, not encryption. Anyone holding the token can decode the payload — so never put passwords, phone numbers, or marksheets in claims. Keep claims to identifiers and roles.
- The signature is what matters. Change one character of the payload and the HMAC no longer matches; the server rejects the token. Integrity and authenticity come from the signature alone.
expis a Unix timestamp. Hereexp - iat = 900seconds: a 15-minute access token. Short lifetimes are a deliberate design choice, explained below.
Interview-ready line: a JWT is a signed assertion of identity, not an encrypted envelope. Verification is a cryptographic check, not a database lookup.
HS256 vs RS256: picking a signing algorithm
The header's alg field names the signing algorithm. For student projects the choice is between two:
| HS256 | RS256 | |
|---|---|---|
| Type | Symmetric (HMAC-SHA256) | Asymmetric (RSA signature) |
| Keys | One shared secret signs and verifies | Private key signs, public key verifies |
| Speed | Very fast | Slower, still trivial at student scale |
| Fits when | One backend service issues and verifies its own tokens | Separate services verify tokens, or a third party needs to verify without being able to mint |
| Main risk | Anyone holding the secret can forge tokens — guard it like a password | Key management is more work: two keys, rotation story |
Decision rule: if a single Node backend both issues and verifies tokens — the standard final-year setup — HS256 with a long random secret stored in an environment variable is the correct, honest choice. Reach for RS256 only when a genuinely separate service must verify tokens without being trusted to create them.
Generate the secret properly — never invent one by typing on the keyboard:
openssl rand -base64 64
Put the output in a .env file (JWT_SECRET=...), add .env to .gitignore, and load it with dotenv. A secret committed to a public GitHub repo is a compromised secret; rotating it means every issued token dies, which is exactly the outage you want to avoid during your demo.
The complete login flow, step by step
Here is the full lifecycle of a login in a JWT-based project, with the actual HTTP involved:
- Register —
POST /api/auth/registerwith email and password. The server hashes the password with bcrypt (cost factor 10–12) and stores only the hash. Plaintext passwords in a database are a project-failing defect, not a shortcut. - Login —
POST /api/auth/loginwith email and password. The server compares the password against the bcrypt hash, and on success issues two tokens: a short-lived access token (15 minutes) and a long-lived refresh token (7 days, stored hashed server-side). - Store — the client keeps the tokens according to the storage rules in the section below.
- Call APIs — every protected request carries the access token:
curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." https://your-api.example.com/api/profile - Verify — middleware on the server checks the signature, checks
exp, and attaches the decoded claims (req.user) for the route handler. No database hit. - Refresh — when the access token expires, the client sends the refresh token to
POST /api/auth/refreshand receives a brand-new pair. The old refresh token is invalidated — this is rotation. - Logout — the server deletes the refresh token; the access token simply expires within minutes. There is nothing to "unsign", which is why short access lifetimes matter.
The login exchange looks like this from the client's side:
curl -X POST https://your-api.example.com/api/auth/login -H "Content-Type: application/json" -d '{"email":"aarav@example.com","password":"correct-horse-battery"}'
and a healthy response:
{
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "dGhpcyBpcyBvcGFxdWUgbG9uZ2VyIGFuZCBvbmx5IHVzZWQgb25jZQ...",
"user": { "id": "user_4821", "name": "Aarav Sharma", "role": "student" }
}
A working Node.js implementation
This is a minimal but complete implementation using Express, jsonwebtoken, and bcryptjs (the pure-JavaScript bcrypt — it avoids the native compilation headaches bcrypt causes on student laptops, at a small speed cost that does not matter at project scale). Install with npm install express jsonwebtoken bcryptjs dotenv.
Login route — verify password, issue tokens:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const crypto = require('crypto');
const app = express();
app.use(express.json());
const JWT_SECRET = process.env.JWT_SECRET; // 64+ random chars, from .env
const ACCESS_TTL = '15m';
const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000;
function signAccessToken(user) {
return jwt.sign(
{ sub: user.id, role: user.role, name: user.name },
JWT_SECRET,
{ algorithm: 'HS256', expiresIn: ACCESS_TTL }
);
}
function newRefreshToken() {
return crypto.randomBytes(48).toString('hex'); // 96 hex chars, opaque
}
app.post('/api/auth/login', async (req, res) => {
const user = await db.users.findByEmail(req.body.email); // your data layer
if (!user) return res.status(401).json({ error: 'invalid credentials' });
const ok = await bcrypt.compare(req.body.password, user.passwordHash);
if (!ok) return res.status(401).json({ error: 'invalid credentials' });
const accessToken = signAccessToken(user);
const refreshToken = newRefreshToken();
// store only a SHA-256 hash of the refresh token, with expiry
await db.refreshTokens.save({
userId: user.id,
tokenHash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
});
res.json({
accessToken: accessToken,
refreshToken: refreshToken,
user: { id: user.id, name: user.name, role: user.role },
});
});
Two deliberate choices worth defending in your viva: the error message is identical for "unknown email" and "wrong password" (different messages leak which emails are registered), and refresh tokens are stored as hashes — a database leak then does not hand attackers live sessions.
Auth middleware — verify on every protected route:
function requireAuth(req, res, next) {
const token = (req.headers.authorization || '').replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'login required' });
try {
req.user = jwt.verify(token, JWT_SECRET); // { sub, role, name, iat, exp }
next();
} catch (err) {
return res.status(401).json({ error: 'session invalid, please log in again' });
}
}
app.get('/api/profile', requireAuth, async (req, res) => {
const user = await db.users.findById(req.user.sub);
res.json({ id: user.id, name: user.name, role: user.role });
});
Note algorithms: ['HS256'] passed explicitly — this pins the expected algorithm and blocks algorithm-confusion attacks where a forged token claims alg: none.
Refresh route — rotation with reuse detection:
app.post('/api/auth/refresh', async (req, res) => {
const presented = req.body.refreshToken;
if (!presented) return res.status(401).json({ error: 'missing refresh token' });
const hash = crypto.createHash('sha256').update(presented).digest('hex');
const stored = await db.refreshTokens.findByHash(hash);
if (!stored || stored.expiresAt < new Date()) {
// Either never issued, already rotated, or expired.
// If it was already rotated, someone may be replaying a stolen token:
// safest response is to revoke the whole token family for that user.
if (stored && stored.used) await db.refreshTokens.revokeAllForUser(stored.userId);
return res.status(401).json({ error: 'refresh token invalid' });
}
const user = await db.users.findById(stored.userId);
await db.refreshTokens.markUsed(hash); // invalidate the old one
const accessToken = signAccessToken(user);
const refreshToken = newRefreshToken();
await db.refreshTokens.save({
userId: user.id,
tokenHash: crypto.createHash('sha256').update(refreshToken).digest('hex'),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
});
res.json({ accessToken: accessToken, refreshToken: refreshToken });
});
Reuse detection is the mechanism that makes refresh-token theft detectable: if an attacker uses a stolen refresh token, the legitimate client's next refresh attempt finds its token already marked used, and the server burns the whole family. The legitimate user gets logged out — annoying, but the alternative is a silent account takeover.
Access tokens vs refresh tokens: why two tokens exist
| Access token | Refresh token | |
|---|---|---|
| Format | JWT (self-contained, verifiable without DB) | Opaque random string (meaningless without DB) |
| Lifetime | Short: 5–15 minutes | Long: 7–30 days |
| Sent on | Every API request | Only to /refresh |
| Revocable instantly? | No — valid until exp |
Yes — delete the DB row |
| If stolen | Attacker has minutes of access | Attacker has days — hence hashing, rotation, reuse detection |
The design logic: the token that travels everywhere (access) dies quickly, so theft buys little; the token that lives long (refresh) travels rarely and is guarded by server-side state. One token trying to do both jobs does neither well.
Where to store tokens: the decision students get wrong most often
| Storage | Survives reload? | XSS risk | CSRF risk | Verdict |
|---|---|---|---|---|
localStorage / sessionStorage |
Yes / tab-only | High — any injected script in your page can read it | None — not auto-sent | Convenient but fragile; acceptable only with rigorous XSS hygiene |
httpOnly cookie (Secure, SameSite=Lax) |
Yes | Low — JavaScript cannot read it | Present — needs CSRF defense | The right default when frontend and API share an origin |
| In-memory JS variable | No | Low (gone on reload) | None | Safest at runtime; pair with silent refresh on load |
Two attacks, precisely distinguished:
- XSS (cross-site scripting) steals tokens from
localStorage. If an attacker can get script content running in your page — through an unsanitised comment field, a malicious npm package, or an event-handler attribute someone pasted into a profile bio — that script readslocalStorage.getItem('token')and sends it home.httpOnlycookies are invisible to such scripts, which is why they are the recommended default. - CSRF (cross-site request forgery) abuses cookies. The browser automatically attaches cookies to requests, so a malicious site can trigger a
POSTto your API in the victim's browser with their cookies attached. Defenses:SameSite=LaxorStrict(modern browsers then withhold the cookie on cross-site requests), plus an anti-CSRF token for state-changing endpoints.
Practical rule for your project: if your frontend is served by the same backend (one origin), use httpOnly, Secure, SameSite=Lax cookies and add CSRF tokens on mutations. If the frontend and API live on different origins, keep the access token in memory, the refresh token in a Secure; SameSite=None cookie or guarded storage, and implement silent refresh. Either way, serve everything over HTTPS in production — a token sent over plain HTTP is readable by anyone on the network, and no storage choice survives that.
Revocation: the one thing JWTs cannot do
A signed token is valid until its exp, period — there is no "unsign" operation. Three honest strategies, in increasing strength:
- Short access lifetimes (5–15 min). Logout and password-change then take effect within minutes. For most student projects, this plus refresh-token deletion is sufficient and honest.
- Refresh-token blocklist. Deleting the refresh row logs the user out fully on next refresh. This is your real logout mechanism.
- Token version claim. Add
tv: <n>to access tokens, store the current version per user, bump it on password change or admin ban. Middleware rejects tokens with a stale version — one indexed DB read per request. This gives you near-instant revocation while keeping verification cheap.
What not to do: maintain a blocklist of every revoked access token and check it on each request. At that point you have rebuilt server sessions with extra steps and lost the stateless benefit that justified JWTs.
Reading the error messages: a diagnosis table
When auth breaks, the library tells you exactly what is wrong — if you read the messages instead of guessing:
| Symptom | Likely cause | Check |
|---|---|---|
TokenExpiredError: jwt expired on every call |
Access lifetime elapsed; client never refreshes, or server clock skew | Confirm the client calls /refresh; check iat/exp by decoding the token |
JsonWebTokenError: invalid signature |
Secret differs between signing and verifying — e.g. .env loaded in one process but not the other, or staging vs production secret |
Print (locally, never in logs) the first 4 chars of the secret in each process and compare |
JsonWebTokenError: jwt malformed |
Token truncated in transit, or the Bearer prefix missing/doubled |
Log the received header length; compare with what the client sent |
| Login succeeds but every protected route 401s | Middleware not mounted, or the client sends the token in the body/query instead of the header | Check route order in Express; inspect the outgoing Authorization header |
| Refresh works once, then loops forever | New refresh token not stored/returned, or old one not invalidated | Trace one full refresh cycle with logs at each DB step |
Token verifies but role check rejects |
Claim name mismatch (role vs roles), or stale claims issued before a role change |
Decode the token and read the actual claim names |
Security checklist for your project report
- Passwords hashed with bcrypt (cost factor 10–12), never stored or logged in plaintext.
- JWT secret is 64+ random characters from
openssl rand, in.env, never committed to git. -
algorithms: ['HS256']pinned in everyjwt.verifycall. - Access tokens expire in 15 minutes or less; refresh tokens rotate on every use.
- Refresh tokens stored as SHA-256 hashes with expiry; reuse triggers family revocation.
- Identical login error for unknown email and wrong password.
- Tokens sent only over HTTPS; cookies flagged
SecureandHttpOnly. - No sensitive data (passwords, phone numbers, internal IDs you would not show the user) in token claims.
-
Authorization: Bearerparsing rejects malformed headers instead of crashing.
Where JWT fits in real student projects
JWT auth is the login layer under a large share of web projects: a secure password manager pairs bcrypt-hashed master credentials with short-lived access tokens; a real-time encrypted chat application authenticates both its REST calls and its socket connections with the same bearer token; an online examination system leans on the role claim to separate student routes from admin routes without extra database lookups. More web-build concepts in this space live in the Computer / IT branch hub.
Token size, clock skew and key rotation: the details that bite later
Three production details that student projects usually meet for the first time during the demo:
- Token size travels with every request. Each claim adds bytes, and the access token rides along on every API call in the
Authorizationheader. A 2 KB token on a slow hostel network is noticeable overhead across dozens of requests — keep payloads lean (identifiers and roles only) and target under ~1 KB. If you find yourself stuffing user preferences into claims, that data belongs behind an API endpoint, not in the token. - Clock skew breaks expiry checks.
iatandexpare absolute Unix timestamps, so a server whose clock has drifted a few minutes ahead will reject tokens that are legitimately fresh. Keep server time synced with NTP, and pass a smallleeway(for example 30 seconds) tojwt.verifyso minor drift does not fail logins. In development this surfaces as the classic "works on my machine, 401 on the deployed server" mystery. - Plan key rotation before you need it. If your secret ever leaks, every outstanding token must die. The clean mechanism is a
kid(key id) header claim: the server holds a small map of key ids to secrets, signs new tokens with the current key, and during a rotation window accepts both old and new. Student projects can keep this simple — one active key plus one previous key — but having thekidplumbing from day one turns a leak from a crisis into a config change.
Putting it together
The mental model to carry into your viva: the server signs a tamper-evident assertion of identity at login; the client presents it with each request; the server verifies the signature cryptographically instead of consulting a session table. Short-lived access tokens bound the damage of theft, rotating refresh tokens make theft detectable, httpOnly cookies blunt XSS, and CSRF tokens blunt the cookie's own weakness. Build the implementation above once, by hand, and "how does login work in your project" becomes the easiest question in the room.