Skip to content
Episode 20 · Booking 4411 is not yours

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.

30 min intermediateAuthentication (who you are) vs authorization (what you may do)Server sessions vs self-contained tokens (JWT)Access/refresh tokens, expiry and revocationCookie security: HttpOnly, Secure, SameSite, CSRFEnforcing authorization on every request (roles + ownership)Never trust the client
  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. 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).

01

The problem

2 min
Wednesday, 8:40. An email from a security researcher.
  1. Maya · Product manager
    She logged into the app as herself, changed /bookings/4410 to 4411, and saw another guest's name, phone and dates.
  2. You · Engineer at StayHub
    So our login is broken?
  3. Lina · Senior engineer, your mentor
    You take the first step. Guess which part failed.
02

The big idea

4 min
Who are you, and is this yours?
The big idea

Logged 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.

03

How it works

5 min

proves 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).

What happens on every request
  1. 1
    Send the credential

    The browser attaches the session cookie (or the app a token) automatically.

  2. 2
    Authenticate

    Look up the session id, or check the token's signature and expiry: guest 981. Fails? 401.

  3. 3
    Authorize

    May guest 981 do this to this booking? Role, and ownership in the database. Fails? 403, or 404 to hide it.

  4. 4
    Do 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.

04

Watch a decision being made

4 min
Worked example

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 cut off a staff member within minutes.

  1. 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.

  2. 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.

    ts
    app.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.

  3. 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.

  4. 4Keep the authorization check

    Tokens only answer who. Handlers still check AND hotel_id = ANY($staffHotels) for staff, AND guest_id = $userId for guests.

    Why: A perfect token system with a missing ownership check is still the 4411 bug.

The whole thing in one line: Decide how fast you must revoke; that sets the token's lifetime. Short access tokens, rotating refresh tokens checked in the database, ownership on every request.
05

In real life

3 min
Two well-known platforms, two defaults

Django: 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.

06

What goes wrong

3 min
Putting personal data in a JWT

The payload is encoded, not encrypted: anyone holding it can read it.

Instead

Minimal claims (subject, role, expiry).

Storing tokens in localStorage

Any injected script () can read and send them away.

Instead

HttpOnly, Secure, SameSite cookies for browsers.

Trusting identity or role from the request body

{"userId": 1, "role": "admin"} is text the client typed.

Instead

Identity only from the verified session or token.

07

Trade-offs

3 min
OptionFitsYou payMain risk
Session cookieBrowser apps on your domainA lookup per requestCSRF if misconfigured
JWT + refresh tokenMobile apps, many servicesDelayed revocationLong expiry, bad storage
Managed identity providerSSO, small teamsCost, lock-inThinking it does authorization
Friday, 11:00. Planning the auth clean-up.
  1. Karim · CTO
    JWT everywhere: website, extranet, app. One mechanism, no lookups, ready for services.
  2. Lina · Senior engineer, your mentor
    One backend, a 1 ms lookup. For the extranet I want instant cut-off, not "within 10 minutes".
  3. Karim · CTO
    Two mechanisms means two things to secure. That's how bugs like 4411 happen.
08

Your job as tech lead

4 min

Claude 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.

Decisions you own
  • 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.
Tell Claude Code
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.
Check before you accept
  • 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.
Red flags
  • "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.
09

Check yourself

2 min
Q1/3Debugging
A security researcher logs in as guest A, calls GET /bookings/4411 (guest B's booking) and gets B's name, phone and payment summary. The endpoint has requireAuth. What's the bug?
How confident are you?

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.

Finished?

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.
10

Technical deep dive

Sessions, tokens, and taking access back

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:

json
// 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)
exp − iat = 2,592,000 s: thirty days. Nothing is looked up while it's valid.
Worked example

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.

  1. 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.

  2. 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.

  3. 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.

    ts
    app.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.

  4. 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.

  5. 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 = $userId for guests.

    Why: A perfect token system with a missing ownership check is still the 4411 bug.

The whole thing in one line: Lina set a revocation target (10 minutes), made access tokens that short, added a rotating refresh token checked in the database, and kept per-request ownership checks, so a disabled account loses access within minutes without a lookup on every call.
Server-side sessionJWT access token (+ refresh token)
What the client holdsRandom id (meaningless alone)Signed claims (readable by anyone)
Per-request costStore lookup (~1 ms)Signature check, no lookup
RevocationImmediate: delete the sessionAt expiry, or at the next refresh
Role changesEffective on next requestEffective when the token is reissued
Fits bestBrowser apps on your own domainMobile apps, many services, sign-in providers
Main risksCSRF if the cookie is misconfiguredLong expiry, tokens in localStorage, secrets in the payload
Neither is "more secure" by default; each fails differently.
11

Interactive simulation

12

Visual diagram

Click a component

Login, then a request with each design, then authorization. Start the walkthrough.

13

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.

14

Check your understanding

Q1/4Reasoning
Match each cookie attribute to what it protects against:
HttpOnly
Secure
SameSite=Lax
Max-Age / Expires
How confident are you?
15

Practice ladder

Step 1 · Trace

Trace three requests

Scenario Warm-up

StayHub'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:

  1. Guest 981 sends GET /bookings/4411 (Kenji's booking) with a valid access token.
  2. Staff-12 of Hotel Alfama sends GET /bookings/4410, a booking at Alfama, with an access token that expired 2 minutes ago.
  3. Staff-77, disabled at 10:00, sends POST /auth/refresh at 10:06 with her latest refresh token.
Step 2 · Complete

Finish the staff endpoint

Code Practice

Hotel 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();
});
Step 3 · Solve

Partner channel manager access

Design Practice

A 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.

Step 4 · Decide

Remember me for 90 days?

Decision Stretch

Maya: "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.

16

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.

0 words