Passwordless sign-in without a password table
Here is every column in our users table:
id uuid primary key
project_id uuid not null
email citext not null
name text
created_at timestamptz not null
updated_at timestamptz not null
last_sign_in_at timestamptz
banned_at timestamptz
external_provider text
external_provider_user_id text
unique (project_id, email)
No password_hash, no salt, no password_reset_token, no failed_login_count. Argon2 shows up in exactly one place and it's hashing OIDC client secrets, which are machine credentials and a different problem.
Dropping the column is the easy part.
One request, two credentials
A sign-in request generates two forms of the same credential: a token of 32 random bytes, base64url, which goes into the emailed link, and a six-digit code for when that link opens in the wrong browser on someone's phone. Both are hashed before storage.
They have to die together. Two forms of one credential, not two credentials. Store them independently and you've built a system where an intercepted link still works after the user has already signed in with the code.
The token is stored as a keyed BLAKE3 hash, with a ten-minute TTL. Configure it outside one to thirty minutes and the process refuses to boot rather than clamping quietly. Hashing something with a ten-minute life looks like theatre until you notice that ten minutes is the expiry and not the retention: expired rows sit there until cleanup runs, so a snapshot taken at an awkward moment holds live credentials for everyone who was mid-sign-in. The likelier exposure was never the database anyway. It's a log line, an error report with the row attached, a support tool that renders a table.
Keys are per-credential-class and the process won't boot unless all six are byte-distinct: the master key, the magic-link key, the refresh-token key, the authorization-code key, the hosted-login key and the recovery-code key. That check is there because reusing one key across classes is what happens during a rushed deployment and is invisible afterwards.
Single-use, enforced twice
In Redis it's one Lua script: fetch the presented form, compare its hash against the stored one, and on a match DEL both the token key and the code key in the same body, so redeeming either kills the other.
The companion write is also a single Lua body rather than MULTI/EXEC, and the reason is specific to that operation: it's a conditional two-key set whose EXISTS predicates aren't all-or-nothing across a multiplexed connection, so a partial outcome leaves one form live and the other missing.
Postgres carries the second guard, a conditional update inside the completion transaction:
update end_user_sign_in_attempts
set status = 'complete'
where id = $1
and status = 'needs_first_factor'
rows_affected() of 0 means somebody got there first, so the transaction rolls back and returns AlreadyComplete. What matters is that there's no SELECT to check the status before the UPDATE; read-then-check-then-write leaves a window exactly wide enough for a double-click on a slow connection. TOTP replay and recovery-code consume use the same conditional shape. Refresh rotation does read the row first, because reuse detection has to know that a presented token was already spent; the conditional update sits behind that as the race backstop.
Six digits is twenty bits
A six-digit code carries about 20 bits of entropy, which on its own is guessable. It survives because five attempts per sign-in attempt is the cap and the sixth submission moves the whole attempt to failed, so the code you were attacking stops existing.
Rate-limit counters in Redis sit around that:
| Limit | Keyed on |
|---|---|
| Send rate, per minute and per hour | app + email address |
| Send rate, per hour | app + client IP |
| Second-factor attempts, per attempt | the sign-in attempt |
| Second-factor attempts, per user | user + app |
The email buckets key on the address so flooding one mailbox stays bounded regardless of how many IPs you have, and the IP bucket keys on the client address so enumerating many addresses from one host stays bounded regardless of how many mailboxes you try. Neither covers both attacks alone.
Every step of a sign-in runs whether or not the account exists. The three rate-limit counters are charged before anything is looked up, both credentials are generated and hashed before we know whether there's a user to send them to, and the attempt row and the Redis write happen either way. Only at the end does the mailer get a Deliver or a Skip, and both spawn and return without being awaited. Skipping the work for unknown addresses makes the response measurably faster for them, which is an enumeration oracle, and it's what you get by writing the function the obvious way with an early return on "user not found". Two tests assert that the known and unknown branches produce an identical sequence of calls.
A bypass between two endpoints
Sign-in and sign-up are separate endpoints and TOTP is a second factor on sign-in, so the obvious implementation gates the second factor on the sign-in endpoint.
But /sign-up upserts on (project_id, email). A user who already exists and has TOTP enrolled can go through sign-up, get a magic link for the same account, and land in a session without being asked for a second factor. No exploit involved, just a documented public endpoint.
The gate has to key off the resolved user instead of the endpoint the request arrived on. Ours returns totp_confirmed from inside the same open transaction that resolves the user, and branches there, so both endpoints pass through one decision. What made it easy to miss is that the endpoint-based version behaves correctly under the tests you write per endpoint. Sign in with 2FA and you're prompted, without and you're not, enrol and disable and re-enrol all behave. You only see it if you go looking at the seam.
What you still have to build
Four things, none of which the missing password column saves you from:
- TOTP secrets are real secrets at rest. Ours are AES-GCM encrypted with the user's ID as additional authenticated data, so a ciphertext copied onto another user's row fails to unwrap instead of quietly working.
- Recovery codes are ten 160-bit values hashed under their own key, single-use through the same conditional update as everything else.
- A 30-second TOTP window with one step of skew either side leaves a code valid for 90 seconds, so you record the last step used and reject anything not strictly newer.
- Sessions still expire and still get stolen. Short-lived access token, rotating refresh token, and reuse detection that revokes the session when an already-used token comes back.
Timonier is EU-resident authentication with a self-hostable data plane. The Next.js quickstart gets a real user signed in.