Authentication, authorization, sessions and JWTs
Wednesday, 8:40: a researcher writes that she logged into the app as herself, changed /bookings/4410 to 4411, and saw another guest's name, phone and dates. Booking numbers simply count up, one after another.
Who are you, and what are you allowed to do? Two different questions. Here's how login works, how the app remembers you, and where most breaches really happen.
- Problem
Guests, hotel staff and partner apps all call the same booking API. Each request must prove who sent it, and the API must decide whether that identity may see or change this booking.
- Why the simple system fails
HTTP is stateless, so without a credential on every request the server can't tell callers apart. Naive fixes (a user id in a cookie, a never-expiring token in localStorage, checking only 'is logged in') let attackers impersonate users or read other guests' bookings.
- Engineering concept
Authenticate once with a strong secret, then issue a credential: either a random session id that points to server-side state, or a signed self-contained token. Authorize every request separately, against roles and resource ownership.
- Technology
Password hashing (Argon2/bcrypt), HttpOnly Secure SameSite cookies, a session store (Postgres/Redis), JWT access tokens with short expiry, rotating refresh tokens, RBAC plus ownership checks, OAuth/OIDC for third parties.
- Trade-offs
Sessions are easy to revoke but need a lookup per request; JWTs verify without a lookup but are hard to revoke and easy to misuse. Cookies are automatic (hence CSRF), bearer headers are explicit (hence storage and XSS concerns).
The problem
2 min- Maya · Product managerShe logged into the app as herself, changed /bookings/4410 to 4411, and saw another guest's name, phone and dates.
- You · Engineer at StayHubSo our login is broken?
- Lina · Senior engineer, your mentorYou take the first step. Guess which part failed.
The big idea
4 minLogged in is not allowed. Check ownership on every request.
Authentication happens once per login. Authorization happens on every request, for every single thing you touch.
How it works
5 minproves who you are (password, passkey). decides what you may do to this thing. HTTP forgets you between requests, so after login the client sends a credential every time: a id (random, in an , looked up on the server) or a (signed claims like "user 981, expires 10:15", readable by anyone, checked without a lookup).
- 1Send the credential
The browser attaches the session cookie (or the app a token) automatically.
- 2Authenticate
Look up the session id, or check the token's signature and expiry: guest 981. Fails? 401.
- 3Authorize
May guest 981 do this to this booking? Role, and ownership in the database. Fails? 403, or 404 to hide it.
- 4Do the work
Only now read or change the booking.
Our handler skipped step 3: an (insecure direct object reference). The fix puts ownership in the query, with the id from the verified session: WHERE id = $1 AND guest_id = $2. Another guest's booking matches nothing: 404.
Watch a decision being made
4 minLina makes the app's tokens revocable
The app beta issues 30-day JWTs to guests and hotel staff, with no refresh token. Hotels must cut off a staff member within minutes.
- 1Set the revocation target
Nadia: "When I disable someone, minutes, not days." Target: ≤ 10 minutes. Access tokens now expire after 10 minutes.
Why: A token's lifetime is the longest a stolen or outdated one keeps working. Pick it on purpose.
- 2Add a refresh token we can check
At login, also issue a long random refresh token; store only its hash in PostgreSQL. Every 10 minutes the app sends it; we check the row and that the account is still active.
tsapp.post("/auth/refresh", async (req, res) => { const row = await db.refreshTokens.findByHash(sha256(req.body.refreshToken)); if (!row || row.revoked || !(await db.users.isActive(row.userId))) return res.status(401).end(); await db.refreshTokens.rotate(row); // old one becomes unusable res.json({ accessToken: signAccess(row.userId), refreshToken: row.next }); });Why: One lookup every 10 minutes, not on every request. Disabling takes effect at the next refresh.
- 3Rotate on every use
Each refresh token works once. If an old one shows up again, someone copied it: revoke the whole chain.
Why: Rotation turns a silent theft into a detectable event.
- 4Keep the authorization check
Tokens only answer who. Handlers still check
AND hotel_id = ANY($staffHotels)for staff,AND guest_id = $userIdfor guests.Why: A perfect token system with a missing ownership check is still the 4411 bug.
In real life
3 minDjango: server-side sessions
- Its documented default stores sessions on the server
- The cookie holds only a session key
- Logging someone out everywhere: delete their sessions
Fits web apps talking to their own backend.
Firebase Authentication: signed tokens
- Google documents ID tokens as JWTs expiring after about an hour, renewed with a refresh token
- Many services verify a token without a central store
Fits mobile apps and many independent services.
What goes wrong
3 minThe payload is encoded, not encrypted: anyone holding it can read it.
Minimal claims (subject, role, expiry).
Any injected script () can read and send them away.
HttpOnly, Secure, SameSite cookies for browsers.
{"userId": 1, "role": "admin"} is text the client typed.
Identity only from the verified session or token.
Trade-offs
3 min| Option | Fits | You pay | Main risk |
|---|---|---|---|
| Session cookie | Browser apps on your domain | A lookup per request | CSRF if misconfigured |
| JWT + refresh token | Mobile apps, many services | Delayed revocation | Long expiry, bad storage |
| Managed identity provider | SSO, small teams | Cost, lock-in | Thinking it does authorization |
- Karim · CTOJWT everywhere: website, extranet, app. One mechanism, no lookups, ready for services.
- Lina · Senior engineer, your mentorOne backend, a 1 ms lookup. For the extranet I want instant cut-off, not "within 10 minutes".
- Karim · CTOTwo mechanisms means two things to secure. That's how bugs like 4411 happen.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- How fast access must stop when someone leaves: "minutes, not 30 days" rules out long-lived tokens with no server check.
- Who may do what to which booking: guests their own, staff only their hotel's, written as rules before any code.
- Whether to build login yourself or use a proven library or provider. Password hashing and token rotation are not for improvising.
Hotel Alfama disabled a receptionist at 10:00; at 16:20 her 30-day app token still cancelled 3 bookings. Replace the app's 30-day JWTs with 10-minute access tokens and rotating refresh tokens stored hashed in our database; a reused refresh token revokes the whole family. Check every booking endpoint: staff only their hotel's bookings, guests only their own. Keep the web session login. Plan first, then tests: a disabled account is refused at next refresh; other-hotel staff get 403 or 404.
- Claude walks through 11 minutes after an account is disabled: it ends in "refused", with the line that checks.
- Tests log in as staff of hotel A and try to cancel a booking of hotel B.
- Every route taking a booking id shows its ownership check. One missing route is enough for a leak.
- "Logged in" treated as permission for any booking id.
- Personal data in the JWT payload because "it's signed".
- "Logout" that only deletes the token on the phone, called revocation.
Check yourself
2 minGET /bookings/4411 (guest B's booking) and gets B's name, phone and payment summary. The endpoint has requireAuth. What's the bug?Next episode: sending each guest's confirmation email and PDF inside the booking request makes checkout take 6 seconds, and Maya's conversion chart dips.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
A is three base64url parts: header, payload, signature. The signature proves who issued it and that nobody changed it. It does not hide the contents. Here's what the contractor's app beta issues:
// header
{ "alg": "ES256", "typ": "JWT", "kid": "2026-09" }
// payload: readable by anyone holding the token
{ "sub": "staff-77", "role": "hotel_staff", "hotel": "alfama", "iat": 1790000000, "exp": 1792592000 }
// signature = sign(header + "." + payload, StayHub's private key)Lina makes the app's tokens revocable
The app beta issues 30-day JWTs to guests and hotel staff, with no refresh token. Hotels must be able to cut off a staff member within minutes. Redesign the token lifecycle.
- 1Set the revocation target
Ask the business: how fast must access end? Nadia: "When I disable someone, minutes, not days." Target: ≤ 10 minutes.
Why: The lifetime of a token is the longest a stolen or outdated one keeps working. Pick that number on purpose.
- 2Shorten the access token
Access tokens now expire after 10 minutes (
exp = iat + 600). The API still checks them without a lookup: signature, algorithm, issuer, audience, expiry.Why: Short tokens keep the speed benefit of JWTs while capping the damage of a leak.
- 3Add a refresh token we can check
At login, also issue a long random refresh token. Store only its hash in PostgreSQL with the user id. Every 10 minutes the app sends it to
/auth/refresh; we check the row and that the account is still active.tsapp.post("/auth/refresh", async (req, res) => { const row = await db.refreshTokens.findByHash(sha256(req.body.refreshToken)); if (!row || row.revoked || !(await db.users.isActive(row.userId))) return res.status(401).end(); await db.refreshTokens.rotate(row); // old one becomes unusable res.json({ accessToken: signAccess(row.userId), refreshToken: row.next }); });Why: This is the one place a token is looked up, every 10 minutes instead of on every request. Disabling an account takes effect at the next refresh.
- 4Rotate on every use
Each refresh token works once. If an old one shows up again, someone copied it: revoke the whole chain and force a new login.
Why: Rotation turns a silent theft into a detectable event.
- 5Keep the authorization check
Tokens only answer who. Every handler still checks role and ownership in the query:
AND hotel_id = ANY($staffHotels)for staff,AND guest_id = $userIdfor guests.Why: A perfect token system with a missing ownership check is still the 4411 bug.
| Server-side session | JWT access token (+ refresh token) | |
|---|---|---|
| What the client holds | Random id (meaningless alone) | Signed claims (readable by anyone) |
| Per-request cost | Store lookup (~1 ms) | Signature check, no lookup |
| Revocation | Immediate: delete the session | At expiry, or at the next refresh |
| Role changes | Effective on next request | Effective when the token is reissued |
| Fits best | Browser apps on your own domain | Mobile apps, many services, sign-in providers |
| Main risks | CSRF if the cookie is misconfigured | Long expiry, tokens in localStorage, secrets in the payload |
Interactive simulation
Visual diagram
Login, then a request with each design, then authorization. Start the walkthrough.
Analogy
- At a hotelShowing your passport at check-inIn an appAuthentication
- At a hotelA plastic key card the door checksIn an appA session ID
- At a hotelA signed, dated letter from the managerIn an appA JWT
- At a hotelYour booking confirmation kept at receptionIn an appA refresh token
- At a hotelCard for 412 must not open 413In an appAuthorization on every request
Where the analogy breaks: a stolen key card is noticed fast. A stolen token can be copied silently and used from anywhere in the world, so keep tokens short-lived and stored safely.
Check your understanding
Practice ladder
Trace three requests
Scenario Warm-upStayHub's API, after the fixes: the app uses 10-minute access tokens and rotating refresh tokens; every booking handler uses WHERE id = $1 AND guest_id = $2 (guests) or AND hotel_id = ANY($3) (staff).
For each request, say which check runs, whether it passes, and the status code:
- Guest 981 sends
GET /bookings/4411(Kenji's booking) with a valid access token. - Staff-12 of Hotel Alfama sends
GET /bookings/4410, a booking at Alfama, with an access token that expired 2 minutes ago. - Staff-77, disabled at 10:00, sends
POST /auth/refreshat 10:06 with her latest refresh token.
Finish the staff endpoint
Code PracticeHotel staff can change a booking's room. Lina wrote most of the handler; two lines are missing. Fill in (A) and (B).
The session gives req.auth = { userId, roles, hotelIds }.
app.patch("/bookings/:id/room", requireAuth, async (req, res) => {
if (!req.auth.roles.includes("hotel_staff")) return res.status(403).end();
const booking = await db.one(
"SELECT * FROM bookings WHERE id = $1 AND ___(A)___",
[req.params.id, ___]
);
if (!booking) return ___(B)___;
await bookings.changeRoom(booking.id, req.body.roomId, { by: req.auth.userId });
res.status(204).end();
});Partner channel manager access
Design PracticeA channel manager (a company that syncs rooms and prices for 30 of our hotels) wants API access. Their engineer suggests: "Give us the login of one Alfama staff member and we'll use it for all 30 hotels."
Design how this partner authenticates and what it may do. Cover: the credential, its lifetime and revocation, and the authorization rule for each request.
Remember me for 90 days?
Decision StretchMaya: "Guests hate logging in again. Can the app keep them signed in for 90 days?" Omar: "Long logins are how stolen phones become stolen accounts."
The facts: - The app uses 10-minute access tokens and rotating refresh tokens stored in the phone's secure storage. - Guests can see their bookings, change dates, and pay with a saved card. - About 4% of app sessions start with a forced re-login today (after 14 days).
Write a short decision in this form: "We chose X over Y because…, accepting the risk of…", and say what extra protection you'd add.
Explain it yourself
Explain to a new backend engineer the difference between sessions and JWTs, and why "we use JWTs" doesn't mean the API is secure.