Module 04API Designhard17 min

Module 4 — Auth, For Real

Auth is two jobs people blur together: proving who you are, and checking what you may do. Build them as two layers — authentication that issues a trustworthy session, and authorization that gates every action on ownership — and the whole class of 'I saw someone else's data' bugs disappears.

So far the owner has been a hardcoded test user. Now we make identity real, using the authentication and authorization deep dives. The two are separate jobs and we build them as separate layers: authentication (who are you?) issues a session; authorization (what may you do?) gates each action.

Goal

  • POST /auth/signup and POST /auth/login — hash the password, verify it, issue a session.
  • A session carried in an httpOnly cookie, verified on each request to identify the user.
  • Write endpoints require a logged-in user; the test-user shortcut is gone.
  • An object-level ownership check: editing/deleting/reading a private snippet checks it's yours.

Step 1: Store passwords correctly

Never store a password, never store anything reversible. Store a slow hash. Use a memory-hard algorithm built for this — argon2 or bcrypt — not a general-purpose hash like SHA-256.

import argon2 from "argon2";

// signup
const passwordHash = await argon2.hash(plaintextPassword);
await insertUser({ email, passwordHash });

// login
const user = await getUserByEmail(email);
const ok = user && (await argon2.verify(user.password_hash, plaintextPassword));
if (!ok) return c.json({ error: "invalid_credentials" }, 401);

Why a slow, memory-hard hash specifically

SHA-256 is fast — which is exactly wrong for passwords, because "fast" means an attacker who steals your database can try billions of guesses per second. Argon2/bcrypt are deliberately slow and memory-hungry, so each guess costs real time and RAM, making offline brute-force impractical. They also salt automatically, so two users with the same password get different hashes and a precomputed rainbow table is useless. Use the purpose-built tool; rolling your own here is how breaches turn catastrophic.

Return the same error for 'no such email' and 'wrong password'

Both failures return 401 invalid_credentials — never "no account with that email." Distinguishing them lets an attacker enumerate which emails are registered. Same response, same timing where you can manage it.

Step 2: Issue a session

The deep dive's core distinction: a session is state the server remembers (revocable, but needs a lookup); a JWT is a signed token the server verifies without remembering (stateless, but hard to revoke). For a service like this — where "log me out everywhere" and "ban this user now" must work instantly — a server-side session is the right default.

  1. Create a session record on login

    Insert a row: id (random, unguessable), user_id, created_at, expires_at. The session id is a long random string, not anything derived from the user.

  2. Send it as an httpOnly, Secure, SameSite cookie

    Set-Cookie: sid=<id>; HttpOnly; Secure; SameSite=Lax; Path=/. HttpOnly keeps JavaScript (and thus XSS) from reading it; Secure keeps it off plaintext HTTP; SameSite blunts CSRF.

  3. Verify it on each request

    Middleware reads the sid cookie, looks up the session, checks it hasn't expired, and attaches user to the request. No valid session → 401 on protected routes.

// auth middleware: turn a cookie into a known user, or 401
app.use("*", async (c, next) => {
  const sid = getCookie(c, "sid");
  if (sid) {
    const session = await getValidSession(sid);
    if (session) c.set("user", await getUserById(session.user_id));
  }
  await next();
});

function requireUser(c) {
  const user = c.get("user");
  if (!user) throw new HttpError(401, "auth_required");
  return user;
}

Why httpOnly is the cookie setting that matters most

If an attacker can run JavaScript on your page (an XSS bug), a token stored in localStorage or a non-httpOnly cookie is theirs — game over. HttpOnly means the browser sends the cookie automatically but never exposes it to JavaScript, so even an XSS can't steal the session itself. Storing auth tokens where JS can read them is one of the most common avoidable mistakes; the cookie does this correctly by default.

Step 3: Authorization — the layer that stops data leaks

Authentication now tells you who is calling. Authorization decides what they may do, and per the authz deep dive we put the decision in one place every handler calls, and we never forget the object-level check.

// the single decision point — every handler calls this, none decide inline
function authorizeSnippet(user, snippet, action: "read" | "write") {
  if (action === "read") {
    if (snippet.is_public) return;            // anyone may read public
    if (user && snippet.owner_id === user.id) return; // owner may read private
    throw new HttpError(404, "not_found");    // else: 404, don't leak existence
  }
  // write (edit/delete): only the owner
  if (!user || snippet.owner_id !== user.id) throw new HttpError(404, "not_found");
}

Now retro-fit it into the endpoints from Module 3: load the snippet, call authorizeSnippet, then act.

app.delete("/snippets/:publicId", async (c) => {
  const user = requireUser(c);
  const snippet = await getByPublicId(c.req.param("publicId"));
  if (!snippet) return c.json({ error: "not_found" }, 404);
  authorizeSnippet(user, snippet, "write");   // throws 404 if not theirs
  await deleteSnippet(snippet.id);
  return c.body(null, 204);
});

The IDOR you must test for

The bug the authz deep dive calls the most common serious vulnerability: an endpoint that checks you're logged in but not that this snippet is yours. Test it deliberately — log in as user B, pass user A's private public_id to read and delete, and confirm both return 404. The request looks completely legitimate (B is properly authenticated), which is exactly why the missing object-level check is so easy to ship.

Step 4: Remove the shortcut

Go back through Module 3's create/list endpoints and replace the hardcoded ownerId with requireUser(c).id. Create and list now operate on the authenticated user's snippets. The test user is gone for good.

Acceptance check

# signup + login set a session cookie
curl -XPOST localhost:3000/auth/signup -d '{"email":"a@x.com","password":"hunter2hunter2"}'
curl -c jar.txt -XPOST localhost:3000/auth/login -d '{"email":"a@x.com","password":"hunter2hunter2"}'

# creating without a session → 401
curl -XPOST localhost:3000/snippets -H "Idempotency-Key: $(uuidgen)" -d '{"body":"x"}'   # 401

# with the session cookie → 201
curl -b jar.txt -XPOST localhost:3000/snippets -H "Idempotency-Key: $(uuidgen)" \
  -d '{"body":"secret","isPublic":false}'

# as a DIFFERENT user, GET/DELETE that private public_id → 404 (not 403, not the data)

You're done when: wrong passwords get a generic 401, the password is stored as an argon2/bcrypt hash (look at the row — it must not be plaintext), protected routes reject sessionless requests, and another user gets 404 on your private snippet. Commit it.

What you just internalised

Authentication and authorization are different layers doing different jobs. Authentication proves identity — hash passwords with a slow purpose-built algorithm, carry a revocable session in an httpOnly cookie. Authorization gates actions — one decision function every handler calls, with the object-level "is this actually theirs?" check that the whole "I saw someone else's data" class of incidents comes down to. Build them as two layers and each stays simple.

Before you leave — how confident are you with this?

Your honest rating shapes when you'll see this again. No grades, no shame.

Comments

to join the discussion.

Loading comments…