Prufy notes
The app · 10 min

Sessions without sign-up

Nobody signs up. The first domain you enter creates an account and signs that browser in with one cookie. This chapter is how that works, what each part buys, and what it costs.

Why there are accounts at all

The proof that matters is in DNS, not in a password, so there's nothing to sign up for. But "only one account can own a domain at a time" needs an account to point at, and your claims have to come back when you do. So accounts stay and sign-up goes. An is the link between a browser and an account.

A first claim signs the browser in

From no cookie to a sessionStep through it
Browser
createClaim
sessions
Postgres
Step 1 of 10
A visitor who has never been here
The browser has no prufy_session cookie, so the request carries nothing that says who it's from.
apps/web/lib/api-client.ts
Predict it first
A first-time visitor enters co.uk and presses Enter. How many rows does that add?

Only the hash is stored

Two halves of one sessionPress the button
The browser holds
The cookie's value: 32 random bytes as 43 characters
CzBVep_E6Q4zWH2ix-wRNluApcrvFDleg6jN8hc8YYY
The database holds
sessions.token_hash: its SHA-256, as 64 hex characters
17ef3772b84221008ed7bb1149f9056a713955e6514fe5dc94b61f7202acd79d
A fixed example. Each press makes a different pair.
This runs your browser's own crypto: the same two steps as newSessionToken and hashSessionToken in session.ts, which use Node's. Nothing leaves this page.
If the table held tokens
Anyone who read it could sign in as every visitor: a leaked backup, a query pasted into a ticket, a log line. Each row would be a working key.
With only hashes
A row is enough to find a session when the real token arrives, and useless for making that token. Reading the table lets nobody in.
Predict it first
Compare this with the DNS challenge token from The proof. Why is that one stored in plain text?

What each cookie attribute buys

The Set-Cookie header, piece by pieceClick an attribute
prufy_session=CzBVep_E6Q4z…;
Another site can't make a signed-in POST. This is the CSRF protection.
The situation
A page on another site submits a hidden form to /api/claims/<id>/abandon while you're signed in.
Without it
The browser would attach your cookie, and the request would look exactly like one you made.
Lax still sends the cookie when you follow a link to the site, so the link in an alert email opens your claim. Strict wouldn't.
sessionCookie() in apps/web/lib/server/session.ts builds this string.
Predict it first
An alert email links to /claims/<id>. You click it. Does the page load signed in?

Who gets in where

Every route, by what it asks forPick a door
withAccount: no cookie, or an unknown or expired one, is a 401. Claim routes then go through loadOwnedClaim, which answers 404 for a claim that isn't yours.
GET /api/claims/<id>
POST /api/claims/<id>/checks
POST /api/claims/<id>/challenge/rotate
POST /api/claims/<id>/abandon
GET /api/claims/<id>/zone-file
POST /api/claims/<id>/domain-connect/url
POST /api/claims/<id>/shares
PUT, DELETE /api/notifications/email
POST, DELETE /api/notifications/push
POST /api/notifications/test
GET /api/dns/inspect (dev only)
All of these decisions are made in apps/web/lib/server/with-account.ts and api.ts. Route files are one line each.

Two things here are deliberate. Listing claims with no session returns an empty list, because a new visitor having no domains isn't an error. And visiting the site writes nothing: only creating a claim makes an account.

The per-IP limit, and what it costs

Each account can start 20 claims an hour. Without sign-up, a client could get around that by throwing its cookie away and becoming a new account each time. So new sessions are limited to 10 an hour per IP address. It's the only thing in the way.

DetailWhat it means
The key is the first address in x-forwarded-forWith no such header, every request shares the key sessions:unknown.
It's only spent when a session is madeA browser that already has a cookie never touches it.
Everyone behind one address shares itAn office or a mobile carrier can look like one IP. The eleventh new visitor that hour gets a 429.
The counter lives in PostgresEvery function instance shares one tally in rate_limit_hits, and an advisory lock per key stops two requests both reading 'under the limit'. It used to be in memory, where each instance counted on its own.

What this design costs

  • Clearing cookies loses access from that browser. The domain stays verified and the daily re-checks carry on. Claiming it again from the new session and passing a check moves ownership over, because DNS is the authority.
  • No second device yet. The table allows many sessions per account, and nothing creates the second one. It matters most on iPhone, where an installed web app has its own cookies.
  • An account is not a person. Every account is named "Guest". Nothing here should be used to show a name or to decide what a team is.
  • There's no sign-out. No route deletes a session, and expired rows aren't cleaned up. They just stop matching.

What else was considered

OptionWhat it would buyWhy not
A signed cookie holding the account idNo table to keepNeeds a server secret, and one session can't be revoked without changing it for everyone
A session column on accountsSimpler todayOne account could never be open on two devices
Magic-link email sign-inA real identity, and it fits this same tablePuts an inbox between a visitor and their first claim. Left for later
The claim URL as the credentialNothing to store at allAnyone the link is forwarded to could get a new code or stop the claim. Sharing needs its own, weaker link

The decision is written up in docs/decisions/0008-sessions-without-sign-up.md.