02Security5 min

Why I do guest access with a session token and not with codes

A six-digit code looks like the simple way to let someone in without an account. It is, until you have to revoke it, and then you find out you don't know who you're kicking out.

By Salva Castells

Cover image: Jessica, CC BY-SA 2.0.
In this post

Someone wants to show a project to a client who doesn't have an account. The obvious solution is a short code: six digits, expires in five minutes, the guest types it and gets in. I built it that way in a shared notes and files tool, and it worked from day one. The problem wasn't that it didn't work. It's that three months later I had to redo it twice.

Three iterations, and the third is the one that should have been the first.

What a short code does and what it doesn't

The first table was literally this:

CREATE TABLE guest_access_codes (
  id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY,
  user_id text NOT NULL,
  code char(6) NOT NULL,
  permission permission NOT NULL,
  session_duration_minutes integer NOT NULL,
  code_expires_at timestamptz NOT NULL,
  is_used boolean DEFAULT false NOT NULL,
  used_at timestamptz,
  session_expires_at timestamptz
);

Two different expiries, and that part was right from the start: code_expires_at is how long the code takes to die if nobody uses it (five minutes), and session_duration_minutes is how long the visit lasts once inside. They're different things and mixing them is a classic mistake. A code that lives eight hours over WhatsApp is a hole; a session that cuts off after five minutes is unusable.

With this you already have permissions (read or read_write), a join table to decide which projects the code grants access to, and expiry. Looks complete. It isn't: the code identifies nobody. It identifies the invitation, not the guest.

First attempt: pass the row id

The first correction was to stop sending the six-digit code on every request and send the row's id instead. The client stored a codeId, the backend resolved it, and requests went with Authorization: Bearer <codeId>.

This fixes something real: the six-digit code stops travelling. It's no longer in an access log or in a URL. But it introduces something worse. The codeId is an auto-incrementing integer. It's guessable. It's enumerable. Anyone with a valid id can try the one next door and, if that session is still alive, get in. I swapped a short secret for an identifier that isn't secret at all.

And it still doesn't solve the original problem, which was this: if two people use the same invitation, they share the same codeId. You can't kick one out without kicking the other. You can't tell which of the two deleted the file.

What the session token solves

The third version separates the two things that had been glued together for three months. The six-digit code is a redemption: used once, at the connect endpoint, and never again. What comes out of it is a session token:

const suffix = randomBytes(4).toString("hex");
const sessionToken = `${code}-${suffix}`;

await db
  .update(guestAccessCodes)
  .set({ isUsed: true, usedAt: now, sessionExpiresAt, sessionToken })
  .where(eq(guestAccessCodes.id, record.id));

The response no longer returns codeId, it returns sessionToken. From there the whole frontend carries it: where there was guestCodeId there's now guestSessionToken, and the !!guestCodeId that decided whether someone was a guest became !!guestSessionToken.

What actually changes:

  • Revoking. Setting sessionToken to null on one row kicks out that session and only that one. With the code you had nothing to point at.
  • Auditing. The token is on the row, so every action can be attributed to a specific redemption at a specific time.
  • Not guessable. It's no longer a sequential integer. Four random bytes in hex don't get enumerated.
  • One single place where it expires. sessionExpiresAt is written in the same update that issues the token. There aren't two clocks that can drift apart.

What the audit found, and I didn't

Months later I ran an audit over the code. Plenty came out — a PDF export route with no auth guard at all is the worst of the list — but the one that made me think hardest I wrote in the TODO myself: even though the six-digit code is consumed, what it returns is a six-plus-four token, and that's the one doing the authenticating.

That's the flaw. The short code is protected by its five-minute life and by the fact it can only be used once. The session token has neither of those: it lasts hours and is used on every request. Going from code to token removes one vector and opens another, and you only notice if you look at it later with a clear head. Four bytes are enough against casual enumeration, not against someone with eight hours and a loop.

The other thing that came out, straight from the textbook, is that the connect endpoint didn't wrap anything in a transaction. Between reading that the code isn't used and marking it used there's a window. Two simultaneous requests with the same code both got through.

Banning by IP is more fragile than it looks

The failed-attempt counter needs to know which IP the request came from, and that's where almost everyone gets it wrong the same way. In another project I had permanent IP bans and I took the first value of x-forwarded-for. That header is a list client, proxy1, proxy2: each proxy appends on the right, but nobody cleans up what was already written. Taking the first one means taking something the client can make up.

I changed it to count from the end based on how many trusted proxies sit in front:

const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map(s => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];

With one proxy in front, the last value is the one the proxy writes and the client can't touch. If you take the first, anyone dodges their ban by sending garbage, or worse, bans someone else.

The six-digit code was never the hard part. The hard part was accepting that an invitation and a session are two different objects, and that mixing them leaves you unable to revoke, unable to audit, and with a sequential identifier acting as a credential.