WSWhat Scene?

Guide · 8 min read

Authentication, sessions and access rules

Logging someone in is the easy half. The hard half is deciding what they may touch, on every request, including the ones you add next year. This is the three layer arrangement we run: a cheap check at the Edge, an authoritative one on the server, and database rules underneath both.

Two questions, and only one of them is loud

Authentication asks who someone is. Authorisation asks what they may do once you know. Both are needed and they fail in completely different ways, which is the reason to keep them separate in your head.

Authentication failures are loud. Somebody gets in who should not, and afterwards it is usually obvious. Authorisation failures are quiet: a real, logged-in, entirely legitimate user reads or changes something that was never meant to be theirs. Every log line looks correct, because the request genuinely came from them and they genuinely are who they say.

That asymmetry should decide where your effort goes. In our own rules suite, 78 of 106 assertions check that an operation is refused rather than allowed. It is not a stylistic preference. A rule that fails open passes every test that only checks the happy path, so a suite that is mostly positive tells you nothing about the case you care about.

Why more than one layer

A single check in the right place is enough right up until somebody adds a route that does not go through it. That is not a hypothetical failure mode, it is the normal way applications grow: a new API handler, a background job, a server action written on a Friday.

So the arrangement worth building is one where the outer layers are cheap and fast, and the innermost layer is the one that does not depend on anyone remembering anything. Ours has three:

LayerWhereWhat it decidesCost
1. Edgemiddleware.tsIs this cookie plausible, and may this role see this path at all?No round trip
2. Serverlib/auth/session.tsIs this session still valid right now, and what is the real role?One verification
3. Rulesfirestore.rules, storage.rulesMay this identity touch this specific record?Runs on every read and write

Read bottom up, that is the order of trustworthiness. Layer 3 holds even when 1 and 2 are bypassed entirely, because it is enforced by the database rather than by our code. Read top down, it is the order of cheapness, which is why the arrangement is worth having rather than just using layer 3 everywhere.

Layer 1: the cheap check at the Edge

Our middleware runs before any dashboard route renders. It reads the session cookie, verifies its signature using jose and Web Crypto, pulls the uid and role claim out, and consults a path access table to decide whether that role may see the requested path.

It is deliberately coarse. It answers "should this person be anywhere near this URL", not "may this person read record 4821". Getting an unauthenticated visitor bounced before any rendering happens is worth doing, and it is all this layer is for.

Two details that matter more than they look. First, the matcher is scoped to the guarded surface only, so the marketing site never pays for middleware it does not need. Second, this layer is explicitly not authoritative: it checks a signature, and a signature stays valid until it expires.

A signature check tells you a token was issued and has not been tampered with. It does not tell you the token should still work. Those are different questions and only one of them can be answered without asking the issuer.

Layer 2: the server check that honours revocation

The authoritative check happens server-side, and the important part is one boolean:

const decoded = await adminAuth.verifySessionCookie(
  cookie,
  true /* checkRevoked */,
);

With revocation checking off, a session cookie keeps working until it expires no matter what has happened to the account behind it. Suspend someone at 10am and they keep their access until their cookie runs out. Demote an admin and they stay an admin, holding a token that still says so.

With it on, the check consults the issuer, so a suspended account and a post-role-change token are both rejected on the next request. It costs a round trip. For anything where roles change or people leave, that is the correct trade, and it is the difference between removing access and scheduling it.

The other rule at this layer: the role comes out of the verified token, never from the client. A role in a request body, a query parameter or a header is a suggestion from an interested party. Ours is read from the decoded cookie and normalised, and there is no path where a client-supplied role reaches an authorisation decision.

Layer 3: rules, which do not trust your application

The bottom layer is enforced by the database. Firestore and Cloud Storage call them security rules; Postgres calls the same idea row-level security. They evaluate on every read and write, including the ones your application never checked, because they do not know or care which code path arrived.

This is the layer that survives the mistake you have not made yet. A new endpoint that forgets the session check is still refused. A server action with a missing guard is still refused. That is the entire value proposition, and it is why the rules deserve tests even though the feature above them appears to work.

Ours key off a custom claim on the token rather than off a lookup, deny everything by default, and open only the narrowest paths that work. The server, using the Admin SDK, bypasses rules entirely, which is the correct arrangement and also a reason to keep server-side authorisation honest at layer 2.

It is also the layer where we shipped a real hole. The rule said clients could never modify an uploaded attachment. It never ran, because Cloud Storage evaluates an upload over an existing object as a create rather than an update. The person being audited could quietly replace their own submitted evidence.

  • The full write-up is at /research/an-overwrite-that-was-a-create, including the fix and why it holds however the platform classifies the write.

What a session actually is, and where to keep it

HTTP has no memory. Every request arrives as a stranger, so a session is whatever you hand the browser to prove it has been here before: a signed cookie or a token, sent back with each request.

Keep it in an HTTP-only cookie. An HTTP-only cookie cannot be read by JavaScript, so a script injected into your page cannot exfiltrate it. Put the same token in localStorage and any cross-site scripting bug becomes account takeover. That single storage decision removes the most common route from an injection bug to a stolen account.

Then decide your expiry honestly. Short sessions annoy people into working around you; long ones widen the window in which a stolen token is useful. Revocation checking is what lets you choose a longer expiry without the usual cost, because you are no longer relying on expiry as your only way to end a session.

The parts that are not layers

Two things sit alongside this arrangement rather than inside it, and both are places we have published our own failures.

Rate limiting is not authorisation. It caps how often something may happen, which protects against brute force and against cost, and it is only as good as the thing it is keyed to. Ours was keyed on a header the caller writes, so rotating that header bought a fresh budget on every request. That is written up at /research/a-rate-limiter-one-header-could-defeat, including the part we deliberately did not fix and why.

Uniqueness is not authorisation either, but it can carry the same weight. Where a record is evidence about a named person, the id that identifies it is doing the work a constraint would otherwise do. Ours was built by joining three fields with an underscore, which is ambiguous. That is at /research/an-id-that-could-collide, and the honest headline is that it could not happen with our current inputs and we rejected it anyway.

How to check your own

  1. Confirm your rules layer is actually loaded when you test it. Ours was not: the test command ran with a flag that started only one emulator, so an entire ruleset was never exercised and the pipeline stayed green throughout.
  2. Count how many of your authorisation assertions are denials. If most of them assert success, they will not catch a rule that fails open, which is the only failure mode that matters here.
  3. Try every identity against every surface, including the boring ones. Anonymous, a user on their own data, a different user, staff, and whatever your highest role is. The interesting bugs are almost always the second user.
  4. Check whether revocation is on. Suspend a test account and see whether it can still load a page. If it can, you have expiry rather than revocation.
  5. Grep for places a role is read from a request rather than from a verified token. There should be none.
  6. Ask what your rules are guarding on. If a rule names an operation type, check that the platform classifies the operation the way you assume. That assumption is where ours broke.

The fifth and sixth of those are the ones that found real defects here, and neither needed a security tool. They needed somebody reading a rule and asking what it was actually being asked.

What this does not cover

  • This describes one arrangement on one stack. The three-layer split transfers to most systems; the specific mechanisms, Firebase session cookies and Firestore rules, do not.
  • The assertion census counts what we thought to test. A rule nobody wrote a case for is invisible to it, and that is the gap worth worrying about rather than the ones it reports.
  • We have not had an external penetration test. Everything here is our own reading of our own system, which is exactly the position in which the storage defect survived for months.
  • This covers authentication, sessions and access rules. It does not cover input validation, dependency supply chain, secrets rotation or privacy obligations, each of which is a separate piece of work.
  • Revocation checking costs a round trip on every server-side session verification. We consider that correct for a system with roles that change, and it is a real cost rather than a free win.

Sources

  • The whatscene.in authorisation surface: middleware.ts, lib/auth/, firestore.rules, storage.rules. A census of the 106 security-rule assertions in scripts/test-rules.mjs, categorised by area and by whether each asserts an allow or a denial. Run: scripts/authz-evidence.mjs, 2026-08-24.
  • Manage session cookies, Firebase. Retrieved 24 August 2026.
  • Using HTTP cookies, MDN Web Docs. Retrieved 24 August 2026.

Revisions

  • 24 August 2026 First published, with the authorisation assertion census recorded and three findings of our own linked.

This page is revised in place rather than replaced, so its address does not change.

Next step

Want this built, not just explained?