0053. Account security: two-factor sign-in, passkeys, organization policies and reset links
Questi contenuti non sono ancora disponibili nella tua lingua.
- Status: Proposed for owner review
- Date: 2026-11-01
- Extends: ADR 0017, ADR 0030, ADR 0040, ADR 0041, ADR 0047, ADR 0049
Observable acceptance criteria
Section titled “Observable acceptance criteria”- A person turns on an authenticator app (TOTP) from their account with their password: the page shows a QR code, the key, and ten recovery codes once; a first code confirms it. A password sign-in then asks for a code or a recovery code. Recovery codes work once and can be regenerated with the password. Turning two-factor off needs the password.
- A person registers passkeys from their account, signs in with one without typing an e-mail address, renames and removes them.
- An organization owner or administrator sets the organization’s security policy: nothing, a second factor (authenticator, recovery code or passkey), or a passkey; a grace period in days; whether a remembered browser counts; a maximum session age. They see which members comply, which are within their grace period and which are overdue, and the integration credentials created by members who do not comply. Every change is audited.
- An organization owner or administrator issues a one-time password reset link for a member, copies it like an invitation, and can withdraw it. The member sets a new password with it; every session of the member ends. An instance administrator does the same from the command line for any account. Every step is audited.
- Browser sign-in and the pairing of mobile devices keep working; the mobile app is unchanged.
Decisions
Section titled “Decisions”Better Auth plugins
Section titled “Better Auth plugins”Better Auth’s built-in two-factor plugin and @better-auth/passkey, both at the pinned 1.7.10
(MIT), are added to the identity configuration, with their tables in guestcraft_auth under
Better Auth’s own names (migration 0061, an expand migration: twoFactor, passkey,
user.twoFactorEnabled). The guard allows exactly these routes, method by method: two-factor
enable, disable, verify-totp, verify-backup-code, generate-backup-codes; passkey
generate-register-options, verify-registration, generate-authenticate-options,
verify-authentication, list-user-passkeys, update-passkey, delete-passkey. Codes by
e-mail or SMS (send-otp, verify-otp) stay closed: there is no e-mail. So do the second
showing of the TOTP secret (get-totp-uri) and the server-only endpoints.
GuestCraft’s rules around the plugins are a plugin of its own (packages/auth/src/account-security.ts):
- Secrets. The TOTP secret is encrypted with the auth secret by Better Auth. Recovery codes
are 16 characters of Crockford base32 (80 bits), stored as SHA-256 digests through Better
Auth’s custom backup-code storage; a submitted code is replaced by its digest before Better Auth
compares it. Every verification identifier (two-factor challenges, passkey challenges) is stored
hashed (
verification.storeIdentifier: "hashed"). None of them reaches a log, an audit entry or an error. - Brute force. Better Auth limits two-factor routes to 3 requests in 10 seconds per client
and a sign-in challenge to 5 attempts. Its account lock reads the counter before a code and
counts after it, and skips codes checked on an existing session; it is turned off. Instead,
GuestCraft reserves every code attempt of an account, at sign-in and on a session alike, in one
conditional update: at most 10 in a row, then none for 15 minutes, answered 429 with
Retry-After; a right code clears them. Attempts in flight never exceed the budget, on any number of instances and client addresses. Passkey routes have their own per-client rules (20 option requests and 10 verifications a minute). - Passkeys. The relying party is the hostname of the base URL; a passkey is accepted only
from the configured origins where passkeys work, never from a request’s own Origin. Better
Auth 1.7.10’s passkey plugin has one static relying party, so passkeys work only on origins
in a secure context (HTTPS, or HTTP on
localhost), on a host name rather than an IP address, that are the base host or one of its subdomains (passkeyAvailability). Every other trusted origin, such as a.vercel.appalias next to a custom production domain or a second custom domain, stays trusted for cookies and sign-in, and its exclusion is loud, never silent, while startup never fails for it (an upgrade must not take a working deployment down):- the pages list the origins where passkeys work, and the browser offers none elsewhere;
- a passkey ceremony from such an origin (registration or sign-in, options or verification,
placed by its Origin or Referer) answers 403
PASSKEY_ORIGIN_UNAVAILABLE, naming the base URL, before any database work; - startup logs one warning naming each such origin, its reason and the fix (redirect it to the base URL or serve it under the base domain; for an IP address, a host name; for plain HTTP, HTTPS);
- the security policy’s answer lists them (
passkeys_unavailable_on, withother_domain,ip_addressornot_secure), and the organization security screen shows them next to the passkey requirement, with a warning when the policy requires passkeys; - a member on such an origin who must prove or add a passkey is pointed to the base URL.
Credentials are discoverable and must verify their user (
userVerification: "required", also checked after verification), so a passkey alone is two factors. WebAuthn needs HTTPS orlocalhost: an installation reached by an IP address or plain HTTP cannot use passkeys, and a passkey registered for one host does not work on another. A registration never signs anyone in (createSessionis removed). Names have at most 100 characters. The list and the answers of registration and renaming show no key material. Two removals at the same instant can both pass the last-factor check below; they only harm the person, who signs in with a password and adds a factor again.
- Factor changes. Once a person has a second factor, changing their factors (enabling or
disabling TOTP, regenerating recovery codes, registering or removing a passkey) needs a session
that proved a second factor (
STEP_UP_REQUIREDotherwise). Without this, a stolen password of a person who signs in with passkeys could register the attacker’s passkey. A factor that a policy of one of the person’s organizations needs cannot be removed while it is the only one (FACTOR_REQUIRED_BY_POLICY). - Remembered browsers. “Remember this browser” skips the code for at most 30 days after the
code was entered: Better Auth renews the record at each sign-in, and GuestCraft caps the renewed
record at the first one’s expiry. A session it opens records when that code was entered
(
assuredAt), and meets a policy that allows remembered browsers only until 30 days after it; a device it pairs inherits the same instant. Its identifiers are stored hashed behind atrust-device:prefix so that a password reset, “sign out other sessions”, a password change that signs out the others, and turning two-factor off forget them.
Session assurance
Section titled “Session assurance”Every browser session records how it was signed in (session.signInMethod: password,
trusted_browser, two_factor, passkey) and when it last proved it (assuredAt). The method
is set when the session is created, from the route that creates it, and a session that replaces
another (two-factor enrolment, turning it off, a password change that signs out the others)
keeps the stronger of both, and when the replaced one proved it.
Sessions from before this change are password sessions.
Organization security policy
Section titled “Organization security policy”One policy per organization, in guestcraft_auth.organization_security_policies (no row-level
security, like the other identity tables: a person’s sessions are judged against the policies of
every organization they belong to, before any tenant context exists). Its settings:
| Setting | Values | Meaning |
|---|---|---|
requirement |
none, second_factor, passkey |
What a member’s session must have proved in the organization. second_factor: an authenticator code, a recovery code, a passkey, or a remembered browser when allowed. passkey: a passkey. |
grace_days |
0–90 | Days a member keeps working without it, from the later of the requirement’s raising and their joining. Raising the requirement restarts every member’s grace; lowering it or changing other settings does not. |
allow_trusted_browsers |
boolean | Whether a remembered browser meets second_factor. |
session_max_hours |
1–720 or none | The longest a session of a member may be used. |
Owners and administrators read and replace it (GET/PUT /v1/organizations/{id}/security_policy,
strong If-Match on its version, "0" before the first change), audited as
organization.security_policy_updated with the old and new requirement.
Enforcement, per organization, on every request. Better Auth’s session is read again on every request that GuestCraft serves (there is no cookie cache, ADR 0017), and the policy is applied there, in the one resolver of browser sessions that the API’s principal, the team, tenancy, Activity, account security and device pairing all use:
- A session older than the shortest
session_max_hoursamong the person’s organizations is deleted and the request is unauthenticated. Better Auth’s own routes read the session the same way first (apart from sign-in, sign-up and sign-out), so an old cookie neither reports a session nor changes a factor, a password or a name. - In an organization whose requirement the session does not meet, past the member’s deadline,
every operation answers 403
auth.step_up_requiredwithrequirementandorganization_id. The step-up happens on the same session: a code or a recovery code (verify-totp,verify-backup-codewith a session) raises it totwo_factor; a passkey sign-in replaces it. Sign-in itself never challenges for every organization up front (the coordinator’s decision): entering an organization that needs more asks at that moment. The dashboard shell carries the standing of each workspace’s organization so that the web sends the person to the step-up page instead of an error. - Within the grace period, the session works and the screens show the deadline.
What the policy means for each credential.
| Credential | The requirement | The session age |
|---|---|---|
| Browser session | Step-up in the organization, as above | Signed out |
| Paired device session (ADR 0041) | The device records the sign-in method of the browser session that paired or authorized it (device_sessions.sign_in_method) and the organization’s policy version then. Past the grace period of a stricter requirement, its requests answer 401 auth.reauthentication_required until the person signs the app in again from a browser that meets the policy (a passkey browser session satisfies a passkey policy for the phone). Pairing itself needs a browser session that meets the policy. |
Refused the same way |
| API key, organization token, client session (ADR 0040, ADR 0047) | Not a sign-in: never challenged. Creating or rotating one needs a browser session that meets the policy (they are created only by browser-session routes, through the principal). Existing ones keep working within their creator’s live grants; the compliance list names those whose creator does not meet the policy so that an administrator can revoke them. | Not applied: they have their own expiry |
Compliance. GET /v1/organizations/{id}/security_compliance lists every member with their
factors (an authenticator, the number of passkeys), compliant / grace / overdue judged by
the factors they have set up, their deadline, the expiry of a live reset link, and the live API
keys and organization tokens created by members who are not compliant.
Password reset without e-mail
Section titled “Password reset without e-mail”ADR 0017 closed Better Auth’s password reset until an e-mail adapter existed; there is still no e-mail. A reset link is GuestCraft’s own capability, like an invitation (ADR 0030):
- Issuance. An organization owner or administrator issues a link for a member of a lower
organization role: owners reset administrators and members, administrators reset members,
nobody resets themselves (
POST /v1/organizations/{id}/members/{user_id}/password_reset). A person who also belongs to another organization, or who administers the instance, is refused (password_reset.operator_only): a person is one account across organizations, and an administrator of one must not be able to take over an account that reaches another, or the instance’s administration (ADR 0049 keeps it apart from organization roles). Only an instance administrator resets them, from the command line. The roles are read live under the organization’s lock. - The link. 32 random bytes, in the fragment of
/password-reset#…, shown once; only its SHA-256 is stored, inguestcraft_auth.password_resets, one live link per person (a new one replaces it), 60 minutes. An administrator withdraws it (DELETE …/password_reset). - Budgets, in the database through Better Auth’s per-client storage, shared by every
instance: 5 links per person an hour, whoever issues them (an administrator or the command
line), and 30 per issuer, the command line’s links sharing the one issuer budget of the
installation operator; 10 redemptions per client address a minute. Each refusal answers 429
password_reset.rate_limitedwithRetry-After(the command line printsinstance_admin.password_reset.rate_limitedwith the wait), spends nothing and records nothing. - Redemption (
POST /v1/password_resets/redeem, public, the instance Origin required) sets the password (12 to 128 characters) and, in one transaction that locks the link, then the person as every device writer does (ADR 0041): consumes the link, deletes every browser session, every device session and device code of the person, forgets their remembered browsers and lifts their sign-in lock, and auditsaccount.password_resetin each of their organizations. The password is hashed only for a link that was found, before the transaction. Unknown, expired and used links all answer 400password_reset.invalid. Two-factor and passkeys are not reset: a person who lost them uses a recovery code; nothing removes another person’s factors. A password sign-in that Better Auth completes at the very moment of the redemption may keep its new session; Better Auth offers no lock to order them. - The command line.
instance-admin reset-password EMAIL, with the setup code on stdin like the other instance-administrator commands (ADR 0049), prints the link once; each organization of the person audits it as the system’s, withsource: installation_operator. Likelist, a wrong setup code is refused without a record: the operator cannot be attributed, and the change history’s operations are grants and revocations. A person who belongs to no organization has no audit log to record the link in. It runs in the migrate image, which loads neither Better Auth nor an ORM: it issues links through@guestcraft/db/password-resets, plain SQL that the web’s issuance shares (token, budgets, the one live link). A boundary rule lets only the auth adapter and the operator’s command import that module, as that command already administers accounts in SQL; the rest ofpackages/db/src/auth/stays the auth adapter’s (ADR 0010). - Audit.
account.password_reset_link_issued(with the expiry),…_withdrawn,account.password_reset(with who issued the link and how many sessions ended), never the link, the token or the password.
Factor changes are audited in each organization of the person: account.two_factor_enabled,
account.two_factor_disabled, account.recovery_codes_regenerated,
account.recovery_code_used, account.passkey_added, account.passkey_renamed,
account.passkey_removed (with the passkey’s identifier, never its name or key). Identity
changes have no domain events: no webhook subscriber needs them, and the events are per
workspace.
Personal data
Section titled “Personal data”Every new column is staff account data or an identifier, in guestcraft_auth, and none holds
guest data: the personal-data catalog of ADR 0052, which covers guest data in workspace tables,
does not cover them, deliberately (it lists staff identifiers among the other columns). Classified
one by one:
| Columns | Class | Kept and removed |
|---|---|---|
twoFactor.secret (encrypted by Better Auth), twoFactor.backupCodes (SHA-256 digests), passkey.publicKey, passkey.credentialID, passkey.counter, passkey.aaguid, passkey.deviceType, passkey.backedUp, passkey.transports |
Account data: the person’s authenticators, never shown again or exported | While the factor exists; deleted with it, and with the user (cascade) |
passkey.name |
Account data: a label the person chose | As above |
twoFactor.verified, failedVerificationCount, lockedUntil, user.twoFactorEnabled |
Account data: the state of a factor | As above |
session.signInMethod, session.assuredAt, device_sessions.sign_in_method, assured_at, policy_version |
Account data: how and when a session proved who it is | With the session or device session |
password_resets.token_hash (a digest, never the token), created_at, expires_at |
Account data: one live reset link | Until used, withdrawn or replaced; at most one per person |
password_resets.user_id, issued_by, organization_id; twoFactor.userId, passkey.userId |
Identifiers only | With their row |
organization_security_policies.* |
Organization settings: no personal data | With the organization |
Audit entries of account security hold identifiers and safe facts only (method, expiry, counts),
never a secret, a code, a token or a link, so ADR 0052’s pseudonymization has nothing of theirs to
replace. Activity lists and exports read the stored data, which pseudonymize_details rewrites
in place: an entry pseudonymized under ADR 0052 is listed and exported with its digest.
Consequences
Section titled “Consequences”- API 6.11.0 adds the
account_securityoperations and the problemsauth.step_up_required(403),auth.reauthentication_required(401),security.*andpassword_reset.*. Every operation of a browser session can answerauth.step_up_required; every operation of a device token can answerauth.reauthentication_required. They are documented on the security schemes rather than on each operation. The SDK’sGuestCraftErrorgainsretryAfter. - Each browser request reads the policies of the person’s organizations: one indexed query.
- Tests run on PostgreSQL 17 through PgBouncer as the app role: enrolment, the sign-in challenge, recovery codes, remembered browsers and their cap, factor-change refusals, policy step-up and grace, session age, device refusal, reset issuance rules, budgets, redemption and its single use, and the command line.
Alternatives considered
Section titled “Alternatives considered”- Better Auth’s encrypted backup codes. Decryptable with the auth secret, so a database and secret disclosure reveals them; a digest does not. The brief asks for hashed codes.
- The strictest policy of all the person’s organizations, applied up front. Simpler, but it would challenge for organizations the person is not entering; the coordinator chose step-up per organization.
- Better Auth’s
requestPasswordReset. It stores the token in clear inverificationand delivers it through an e-mail callback. - Removing two-factor and passkeys with a reset. It would let whoever holds a reset link bypass the second factor; recovery codes cover a lost authenticator.
- Enforcing the policy on API keys and organization tokens per request. It would stop integrations whenever a creator’s factors change; revocation from the compliance list is explicit instead.