Howdy

Passwords

Passwords are optional. Turn them on when the app starts:

let assert Ok(identity) = auth.with_passwords(identity)

Without that call the password operations answer 403, and the password pages do not exist. Email tokens keep working for everyone, including people who have a password: the two are alternative ways in, not two factors.

For a walk through a whole app, see Username and password sign-in.

Registering

POST /password/register takes an address and a password:

{"email": "ada@example.com", "password": "a long and unusual passphrase"}

It answers 202 and emails a token. The account and its password only exist once that token is exchanged at POST /session or POST /token, so an account cannot be made for an address its owner does not control. Until then only the password's hash is kept.

Registering an address that already has an account never replaces its password, and never attaches one to an account that signs in by email. The owner gets an AlreadyRegistered email instead; see Sending the email.

Because this request cannot be answered by a token already in the inbox, repeating it for one address is slowed down instead: a 60 second wait after the first, doubling up to an hour.

Signing in

  • POST /password/session with {"email":"…","password":"…"} answers with the user and sets the session cookie. Like /session, it requires your origin.
  • POST /password/token takes the same body and answers with a bearer token.

A wrong password, an unknown address, an account without a password and a suspended account all get the same 401, after the same amount of work, so the answer does not reveal which addresses have accounts.

Setting, changing and resetting

One operation covers all three:

POST /password
{"password": "a new long and unusual passphrase"}

It only works from a session that was created by exchanging an email token within the last ten minutes. A session made with a password, or an older one, gets 403. So whether somebody has forgotten their password, wants a new one, or signed up by email and now wants a password, the steps are the same:

  1. Ask for a sign-in token at POST /login.
  2. Exchange it at POST /session.
  3. Send the new password to POST /password.

Every other session of that user is ended. The starter /auth/account page does step three, and also lists sessions and signs out.

There is no "current password" check here, because the fresh email token is stronger proof than the password it replaces.

Changing with the current password

A user who knows their password can change it from any live session, without an email round trip:

POST /password/change
{"current": "the old passphrase", "password": "a new long and unusual passphrase"}

This also works for apps without email tokens. A wrong current password is a 400, not a 401, since the session itself is fine. Each guess counts against the same limits as a sign-in, so a borrowed session cannot search for the password any faster than the sign-in form allows. An account with no password gets 403, and uses POST /password instead.

Every other session and remembered device is ended, and the address is sent a PasswordChanged notice, whose token is empty. Tell the reader to reset their password from a fresh email sign-in if the change was not theirs; see Sending the email.

What makes a password acceptable

  • At least 15 characters, counted in Unicode code points, and at most 1024 bytes. The minimum is password_min_length in the policy, and cannot go below 8.
  • Not on the built-in list of common passwords, and not an obvious shape: a common word with decoration, one unit repeated, a keyboard run, or very few distinct characters.
  • Passwords are never trimmed. They are normalized to Unicode NFC, so the same password typed on different keyboards matches.

A refusal is a 400 with a message you can show.

The built-in list is small. To check against a corpus of breached passwords, load one at startup:

import howdy/auth/password_check

let identity =
  auth.with_password_check(identity, password_check.blocklist(corpus))

corpus is a list of passwords. The checker keeps only their SHA-256 digests, and sends nothing over the network. For anything else, pass your own fn(String) -> service.Result(Nil); it receives the new password, and an error refuses it. The check applies when a password is set, not when an existing one is used to sign in.

Guessing

Each pair of address and client gets five attempts. After that, each failure must wait: 60 seconds at first, doubling up to an hour. A success clears the history for that pair only, and resetting the password clears it for the address. A second, higher limit of 100 attempts a minute per address catches an attacker who keeps changing client. All four numbers are in the policy, and a refusal is a 429.

The limits follow the client the routes identify, so behind a proxy tell them which header to read. Failed attempts on real accounts are recorded in the audit trail as login.failed.

Storage

Passwords are hashed with Argon2id: a fresh 16 byte salt, 19 MiB of memory, two iterations. Only the encoded hash is stored. A successful sign-in quietly upgrades a hash made with weaker settings.

Hashing protects the passwords. It does not encrypt email addresses, the audit trail, the database or its backups.

Without HTTP

  • auth.register_password_from(identity, email, password, client)
  • auth.login_password_from(identity, email, password, client) returns the same auth.Session as auth.exchange
  • auth.set_password(identity, principal, password)
  • auth.change_password_from(identity, principal, current:, new:, client:)
  • auth.login_password_step, when second factors are on

As elsewhere, register_password and login_password without _from share one client bucket between all callers.