Email + password, slowly.
Argon2id at OWASP parameters, peppered, with progressive lockout and rate-limited unauthenticated endpoints. The boring details, made boring on purpose.
What ships out of the box
- Hash:
argon2id,m=65536(64 MiB),t=3iterations,p=4lanes, 32-byte tag. - Pepper: a server-side secret HMACed in before hashing, sourced from your env (
TILLAUTH_PASSWORD_PEPPER). Stolen DB ≠ usable hashes. - Lockout: progressive lockout after repeated failed attempts, then the counter resets on next success.
- Rate limit: sliding-window per-IP and per-account on unauthenticated
/signinand/signup. - Rehash on signin: if the stored hash uses old parameters, it's transparently rehashed to the current set without a password prompt.
- Reset: 32-byte sha256-hashed token, single-use, 1-hour TTL.
- Verification: 32-byte sha256-hashed token, single-use, 15-minute TTL. Required before a full session.
Signup
Hit POST /v1/signup with { email, password }. We:
- Reject obvious password failures (length, sentinel-list of breached values) before touching the DB.
- Normalise the email (lowercase domain, strip
+tagsif your app opted in). - Argon2id-hash with the pepper applied. Persist
tillauth_users.password_hashandpassword_set_at. - Send a verification email (15-minute, single-use link).
- Write an audit row:
signup.password.ok.
Until the verification link is clicked, the user can complete signin but only gets a limited session — your app gates restricted actions via the email_verified claim in the JWT.
Signin
POST /v1/signin with { email, password }. We constant-time compare against the Argon2id stored hash, regardless of whether the user exists — defeats account-enumeration timing oracles.
Possible outcomes:
- Success → if MFA is enrolled, we return
{ ok: false, mfa_required: true, challenge_token }for the second step. Otherwise we mint the session. - Bad credentials → counter increments, audit row
signin.password.bad_credentials. - Lockout reached → audit row
signin.password.locked, response is a 423. - Unverified email → audit row
signin.password.unverified, response includes a resend hint. - Rate-limited → audit row
signin.password.rate_limited, 429.
Reset
- User hits
POST /v1/password/resetwith their email. We alwaysrespond 204 — no oracle for whether an email exists. - If the email matches a user, we generate a 32-byte random token, store
sha256(token)withpurpose='password_reset', 1-hour TTL. - The user clicks the email link, sets a new password. We Argon2id-rehash and write
password.reset.ok. Existing refresh tokens are not revoked by default — the customer can opt in to a global revoke on reset by settingsettings.revoke_sessions_on_reset = true.
Change vs. reset
Authenticated users use change:POST /v1/password/change with both the old and new password. The old one acts as proof-of-knowledge — no email round-trip needed. Writes password.changed.
When you should turn it off
Password auth is on by default, but you can flip settings.password_enabled = false for an app to disable signup-with-password and force users through OAuth / OIDC / passkeys only. Existing password users keep working (you don't want to lock anyone out) — they can still sign in until they migrate.
Next: passkeys — the password-free path. Or MFA if you want a second factor on top of passwords.