Skip to content

14. Authentication and Sessions

Video map

Covers Section 4, lessons 4.1–4.6: choosing an authentication strategy, choosing a session strategy, setting up an auth library, reviewing the setup, and seeding the first admin instead of allowing public registration.

Authentication is where "the AI wrote it and it seems to work" is most dangerous. A login form that works is easy; one that is secure depends on decisions the code cannot show you. This chapter is about those decisions.

API key authenticationClient sends a key; middleware resolves the owner; handlers see only that owner's dataClientauth middlewarehandlerstoreGET /books (x-api-key: k_…)lookup key → ownerowner: ada / null401 if nullreq.owner = adalist(owner = ada)ada's books only

Two words people mix up

  • Authentication — who are you? (prove your identity)
  • Authorization — what are you allowed to do? (permissions, roles)

Shelf separates them in code: authenticate() produces an identity, and each route then decides what that identity may do.

Decision 1 — build it or buy it?

Managed service (Auth0, Clerk, …) Library in your app (Better Auth, Lucia, Passport, …) Hand-rolled
Speed Fastest Fast Slow
Control Least High Total
Cost Per user, forever Free Your time
Risk Vendor lock-in You operate it You own every mistake

The course picks an in-app library because it wants you to understand sessions, tokens, hashing and roles — and notes that moving to a managed service later is easy, while the reverse is hard. A sensible rule for real projects: never hand-roll password handling or session logic; choose a well-maintained library or a managed service and spend your effort on using it correctly.

Let the library teach the agent

Some libraries publish instructions designed for AI assistants (the video installs one for the auth library it picks). Look for "AI" or "skills" sections in docs; they steer the agent away from outdated patterns. Run interactive installers yourself in a terminal — an agent cannot answer a prompt that waits for keyboard input.

Decision 2 — how does the server remember you?

After login the server must recognise you on the next request. Two common designs:

Database sessions Signed tokens (JWT)
State lives On the server (a table) Inside the token the client holds
Log a user out Delete the row — instant Wait for expiry, or keep a deny-list (which is state again)
Change a role Effective immediately Stale until the token expires
Cost per request One lookup Signature check only
Best for Most web apps Stateless services, service-to-service calls

For a customer-support style tool where revoking access immediately matters, database sessions win. The key lesson from the video: the decision depends on your situation, not on what is fashionable — and the agent will not weigh it for you unless you ask it to.

Shelf's answer: API keys

Shelf's clients are scripts and a small internal page, so it uses API keys that map to { owner, role }. The recorded reasoning is in the decision log: sessions or JWT would be the right call for a public browser app.

src/auth.js
export const ROLES = ["member", "admin"];

export function parseKeys(spec = "") {
  const keys = new Map();
  for (const pair of spec.split(",").map((s) => s.trim()).filter(Boolean)) {
    const [key, owner, role = "member"] = pair.split(":");
    if (key && owner && ROLES.includes(role)) keys.set(key, { owner, role });
  }
  return keys;
}

const safeEqual = (a, b) => {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && timingSafeEqual(x, y);
};

/** Returns { owner, role } for a request, or null when the key is missing/unknown. */
export function authenticate(keys, headers) {
  const presented = headers["x-api-key"];
  if (typeof presented !== "string" || !presented) return null;
  let found = null;
  for (const [key, identity] of keys) {
    // Check every key (no early exit) so timing does not reveal how many characters matched.
    if (safeEqual(key, presented)) found = identity;
  }
  return found;
}

Details worth noticing:

  • Constant-time comparison (timingSafeEqual) so an attacker cannot learn a key character by character from response times.
  • No early exit in the loop — the same reason.
  • Unknown or missing key → null, which the router turns into 401.
  • Roles come from the server's configuration, never from anything the client sends.

The two rules that matter most are verified by tests (test/features.test.js):

test/features.test.js
test("roles: members get 403 on /admin, admins see everyone's books", async () => {
  const app = await boot({ keys: parseKeys("k_ada:ada:admin,k_bob:bob") });
  try {
    const ada = { "x-api-key": "k_ada" }, bob = { "x-api-key": "k_bob" };
    await app.call("/books", { headers: ada, body: { title: "A", author: "x" } });
    await app.call("/books", { headers: bob, body: { title: "B", author: "y" } });
    assert.equal((await app.call("/admin/books")).status, 401); // no key: 401
    assert.equal((await app.call("/admin/books", { headers: bob })).status, 403); // wrong role: 403
    assert.equal((await app.call("/admin/books", { headers: ada })).body.length, 2);
    assert.equal((await app.call("/admin/stats", { headers: ada })).body.total, 2);
    assert.equal((await app.call("/books", { headers: ada })).body.length, 1); // admins still see only their own on /books
  } finally { app.close(); }
});

test("each user sees only their own books; others get 404 not 403", async () => {
  const app = await boot({ keys: parseKeys("k_ada:ada,k_bob:bob") });
  try {
    assert.equal((await app.call("/books")).status, 401);
    const ada = { "x-api-key": "k_ada" }, bob = { "x-api-key": "k_bob" };
    const made = await app.call("/books", { headers: ada, body: { title: "Dune", author: "FH" } });
    assert.equal(made.status, 201);
    assert.equal(made.body.owner, "ada");
    assert.equal((await app.call("/books", { headers: bob })).body.length, 0);
    assert.equal((await app.call(`/books/${made.body.id}`, { headers: bob })).status, 404);
    assert.equal((await app.call("/stats", { headers: bob })).body.total, 0);
    assert.equal((await app.call("/health")).status, 200); // health stays open
  } finally { app.close(); }
});

No public registration

Setting up the library gives you a sign-up endpoint for free. The video's project is an internal customer-support tool, so an open sign-up is a hole: anyone could register and read customers' data. The fix is a design decision, not a code trick:

  1. Disable public sign-up.
  2. Create the first admin with a seed script that reads its email and password from environment variables.
  3. Let that admin create other users later.

Shelf reaches the same goal differently — there is no sign-up at all; an operator provisions keys in SHELF_KEYS (see chapter 13). Both designs answer the question "who is allowed to have an account?" explicitly, which is the point.

Reviewing what the agent set up

After the library is configured, spend ten minutes looking at:

  • The database tables it created — users, sessions, accounts. Can you say what each column is for?
  • The config file — session lifetime, cookie flags, which fields users can set.
  • Environment variables — are the example values obviously fake? A copy-pasted example secret that ships to production is a classic vulnerability (the video's later security audit flags exactly this).
  • Extra fields you added (such as role) — can a client set them during sign-up? It must not.

Then commit: "Set up authentication". Small commits make the next review smaller.

Common mistakes

  • Choosing auth by tutorial popularity instead of by who your users are.
  • Trusting the client for the role. Roles live on the server.
  • Different errors for "unknown user" and "wrong password." Use one message, so attackers cannot discover which emails exist.
  • Leaving example secrets in place.
  • Using 403 for other people's data. Prefer 404 so ids do not leak.

Check yourself

  • I can state the difference between authentication and authorization.
  • I can compare database sessions with JWTs and pick one for a given project.
  • I can explain why Shelf compares keys in constant time.
  • I know why public registration was removed, and how the first admin is created.
// share