Deep dive
Passkeys, 2FA and sessions without an auth SaaS
Published · 5 min read· Anton Shubin
In short
- Seed signs people in with a password, a passkey, Google, GitHub or a sign-in link, and adds an authenticator-app code on top. No auth service, no per-user bill.
- Wrong passwords lock the name for a growing time, and a lock refuses even the right password until it ends.
- A session is a random token the server stores only as a keyed hash, so a leaked database does not hand out sessions, and signing out works at once.
- You can list your sessions with their device and end any of them yourself.
Can you have passkeys, two-factor codes and sessions you can revoke without paying an auth SaaS? Yes. Seed does all three in its own API, on Postgres and Valkey, with the browser's WebCrypto and WebAuthn doing the hard maths. There is no outside service in the sign-in path and no bill that grows with your users.
This post is for developers choosing between "rent auth" and "own auth". I'll walk through each sign-in as it really happens, step by step, so you can judge what you would be taking on.
Why not just rent auth?
Renting is a fine choice, and for many teams the right one. My reasons for owning it in Seed were plain ones: the price climbs exactly when the product starts working, the user table lives in someone else's database, and the sign-in screen looks like someone else's product. A self-hosted starter that needs a third-party account to let anyone in is not quite self-hosted.
The cost of owning it is that you must get it right. So here is what "right" means in Seed.
What happens when you sign in with a password?
- You type your name and password. The page sends them to
POST /api/auth/password/check. - The server checks the lock first. Every wrong password for a name counts. The first five are free. After that the name is locked for a minute, and each new lock doubles, up to 15 minutes. While a lock runs, the server does not even look at the password, so guessing during a lock gains nothing.
- It hashes what you typed. PBKDF2 with SHA-256, 600,000 rounds, plus a secret pepper kept outside the database, all through WebCrypto. A name that does not exist still costs one full hash, so the answer takes the same time and does not tell anyone which names are real.
- It answers. Right password and no second factor: you get a session and your profile. Right
password and an authenticator app turned on: you get a session marked as waiting for its
second factor, and the answer
202 { "secondFactor": "Pending" }. That half-finished session can do one thing only: finish.
What happens at the two-factor step?
- You type the 6 digits from your authenticator app. The page sends them to
POST /api/auth/totp/check. - The server checks the code against the current 30-second step, and one step either side, so a phone clock a few seconds off still works.
- A code works once. The step a code belongs to must be later than the last one accepted, so a code read over someone's shoulder is dead the moment you have used it.
- Wrong codes lock too. Five free tries, then 15 minutes, doubling up to a day. Seven quiet days clear the count.
- The session is marked complete, and only now can it reach the rest of the API.
What happens with a passkey?
- The page asks for a challenge with
POST /api/auth/passkeys/sign-in/options. The server makes a random one and keeps it in Valkey for this one attempt. - Your device asks for your fingerprint, face or PIN and signs the challenge with a key that never leaves it. The server asks for that check every time ("user verification required").
- The page sends the signature to
POST /api/auth/passkeys/sign-in. The server takes the challenge out of Valkey in one atomic step, so the same signature cannot be replayed, and checks it against the public key it stored when you added the passkey. It accepts the two common key types, ES256 and RS256, and asks for no attestation, so it does not care which brand of device you own. - You are in, with no second factor to type. A passkey is already something you have plus your fingerprint or PIN. The one exception: an account waiting to be deleted, with an authenticator app on, still asks for the code before it can be restored.
The WebAuthn checks come from @simplewebauthn/server, wrapped in a small module of my shared
library, @spy4x/server/webauthn. I would not write signature parsing by hand, and neither
should you.
What is a session, exactly?
- A random token: 256 bits from a cryptographic random generator. The server stores only a keyed hash of it (HMAC-SHA-256 with the pepper). Someone who copies the sessions table gets hashes they cannot turn back into tokens.
- A cookie: signed,
HttpOnly,SameSite=LaxandSecure, so page scripts cannot read it. - Checked on every request. There is no long-lived signed ticket that stays valid after you sign out. Sign out, and the next request with that cookie is refused, and any live WebSocket of that session is closed.
- Two months long, renewed while you use it. "Last used" is written at most every five minutes, so busy pages do not hammer the database.
- Yours to manage. Each session records the device name and the IP address with its last part hidden. You can see the list and end one session or every other one. Changing your password ends your other sessions unless you choose to keep them; resetting it ends all of them, deletes your API tokens and removes your push subscriptions.
Every one of those events is written to an audit log in the same database transaction as the change itself, so the log cannot miss one.
What else signs people in?
Google, GitHub, sign-in links by email, and guest accounts for trying the product before signing up. They all end in the same kind of session, with the same list and the same "end it" button. The full list, and how to switch each on, is in the sign-in providers docs.
What did this cost me?
Honestly, more evenings than renting would have. The lockout and the sign-in flow became shared
libraries (@spy4x/server/lockout and @spy4x/server/sign-in), so the next product gets them
for free. That is the deal with a starter: someone pays the cost once.
Try it
Open the live demo, add a passkey in your profile, sign out and sign back in with it. Then end the session from another browser and watch the first one lose access on its next click. Everything is in the code, MIT-licensed, and the sign-in feature page has the short version.