TILLAUTH · FLOWS

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=3 iterations, p=4 lanes, 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 /signin and /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:

  1. Reject obvious password failures (length, sentinel-list of breached values) before touching the DB.
  2. Normalise the email (lowercase domain, strip +tags if your app opted in).
  3. Argon2id-hash with the pepper applied. Persist tillauth_users.password_hash and password_set_at.
  4. Send a verification email (15-minute, single-use link).
  5. 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 rowsignin.password.bad_credentials.
  • Lockout reached → audit rowsignin.password.locked, response is a 423.
  • Unverified email → audit rowsignin.password.unverified, response includes a resend hint.
  • Rate-limited → audit row signin.password.rate_limited, 429.

Reset

  1. User hits POST /v1/password/reset with their email. We alwaysrespond 204 — no oracle for whether an email exists.
  2. If the email matches a user, we generate a 32-byte random token, storesha256(token) with purpose='password_reset', 1-hour TTL.
  3. 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 setting settings.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.