Salta ai contenuti

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
  • 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.

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.app alias 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, with other_domain, ip_address or not_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 or localhost: 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 (createSession is 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_REQUIRED otherwise). 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 a trust-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.

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.

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_hours among 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_required with requirement and organization_id. The step-up happens on the same session: a code or a recovery code (verify-totp, verify-backup-code with a session) raises it to two_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.

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, in guestcraft_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_limited with Retry-After (the command line prints instance_admin.password_reset.rate_limited with 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 audits account.password_reset in 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 400 password_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, with source: installation_operator. Like list, 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 of packages/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.

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.

  • API 6.11.0 adds the account_security operations and the problems auth.step_up_required (403), auth.reauthentication_required (401), security.* and password_reset.*. Every operation of a browser session can answer auth.step_up_required; every operation of a device token can answer auth.reauthentication_required. They are documented on the security schemes rather than on each operation. The SDK’s GuestCraftError gains retryAfter.
  • 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.
  • 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 in verification and 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.