Repository navigation
feat: add two-factor sign-in with authenticator apps - #103
Merged
kalaspuff merged 6 commits intoOct 19, 2026
Merged
Conversation
Sign-ins with a password ask for a code from an authenticator app (TOTP, RFC 6238) once the account has one, and Google sign-ins do when the person or the instance chooses that. Recovery codes stand in for a lost phone. Admins can require two-factor of everyone, and reset it for people who lost their phone.
Clear and dim the code boxes while sign-ins wait, tighten copy, test the code input, the QR code and the terminal QR, fix the on/off argument of fctl account two-factor google, and document the design (ADR 0022).
- Only a browser session that signed in within 15 minutes skips the password when setting up an authenticator app; CLI and access tokens never do. - Replacing the app takes a current code, as turning it off does. - Wrong codes count in one statement, so codes sent at once all count. - The wait after wrong codes holds back codes from the app only: recovery codes still work, so the password alone can't lock anyone out, and the first wait emails the person. - A code is used up only by the request that claims the waiting sign-in. - The terminal QR code gets a quiet zone of four modules, and fctl admin set says when requiring two-factor stopped the admin too.
This was referenced Oct 19, 2026
Adds passkeys. People sign in with their fingerprint, face or screen lock: no password, no code, and nothing a fake site could catch. Where admins require two-factor sign-in, a passkey also counts as the second factor, so meeting the requirement takes a tap. **Stacked on #103** (two-factor sign-in): review that one first. This pull request's own changes are the commits after it. Once #103 merges, `main` gets merged in here. ## What people get - **Settings → Passkeys.** "Add a passkey" opens the browser's passkey sheet at once (the options are fetched ahead, so Safari's click rule holds). The new passkey appears highlighted, named after its provider when the authenticator says ("1Password", "Apple Passwords", "Windows Hello"), else the device ("Chrome on macOS"). Each shows whether it syncs, when it was added and last used, renames inline, and removes with a confirmation that says to remove it from the provider too. With one passkey, Settings suggests a second on another device. - **Signing in.** The browser offers passkeys right in the email field's suggestions (conditional mediation), and **Sign in with a passkey** opens its sheet. No second factor follows: the device already verified the person. A passkey fctl no longer knows gets a plain message, and the browser is told to stop offering it. - **After a password sign-in**, on a device that can keep passkeys, a small card offers one: "Sign in faster next time". "Not now" keeps it away for 30 days in that browser. - **Where two-factor is required**, the setup-only page offers a choice: "Add a passkey" (marked "Quickest", one tap) or "Use an authenticator app". After a password, the second step then asks for the passkey ("One more step. Confirm it's you with your passkey."). For someone with an authenticator app as well, the code step offers "Use a passkey instead". Without a requirement, adding a passkey changes nothing about password sign-ins, so nobody turns two-factor on by accident. - **Keeping providers in step.** Where browsers support WebAuthn's signals, Settings tells the passkey provider which passkeys the account still has and what it is called, and a removed passkey stops being offered. - **Admin → Users** shows who signs in with passkeys. **Reset two-factor** also removes passkeys where they are the second factor the instance requires, but never someone's only way in. - **CLI.** `fctl account passkeys list|rename|remove` (adding takes a browser), `fctl whoami` lists passkeys, `fctl admin users` counts them, `fctl account two-factor` says when passkeys are the second factor. The client also gets methods for the options, add and sign-in endpoints, which take an authenticator's answer. Like #103's second step, the passkey second step is cookie-based and browser-only. ## How it works - WebAuthn Level 3 with discoverable credentials and `userVerification: "required"`, and no attestation (`"none"`). Keys: ES256, EdDSA, RS256. The relying party id is the host name of `FCTL_PUBLIC_URL`; in development, any `localhost` port. - `fctl_domain::webauthn` checks what browsers send (client data, origin, relying party hash, user presence and verification, backup flags, COSE keys, DER signatures). It uses a strict, bounded CBOR decoder in `fctl_domain::cbor`. WebCrypto in the Worker checks the signatures (`crypto::verify_passkey`), so the Worker gains no crypto code. - **Challenges** work once, for ten minutes, and are found by what the browser signs (`clientDataJSON`), so nothing else travels with them. Registration and second-factor challenges are stored by their SHA-256 (`passkey_challenges`). Sign-in challenges are signed with the master key instead: the sign-in page asks for one on every load, so page loads write nothing and cost no rate limit. A sign-in challenge is recorded once used, so it works once. - **Adding a passkey takes proof**, since a passkey skips both password and code: a browser sign-in from the last 15 minutes, else the password, else (no password) a code from the authenticator app. The dialog asks for whichever is needed, then hands the click to the browser. Adding one emails the person. - The signature counter must move on unless it stays 0, as synced passkeys' does. One that goes back refuses the sign-in and goes in the audit log. - Adding counts against the limit (20) and refuses duplicates in its own insert. Removing re-checks "last way in" and "the required second factor" in its own delete, so racing requests can't get around them. - Migration `0012_passkeys.sql` adds two tables. The policy in `fctl_domain::two_factor` now takes `Factors { authenticator, passkeys }`, and `TwoFactor.on` says when password sign-ins ask for a second factor. Housekeeping, account deletion and the export cover passkeys. The reasoning is in [ADR 0023](docs/adr/0023-passkeys-sign-in-on-their-own.md). docs/architecture.md (Passkeys), docs/security.md, docs/cli.md, docs/self-hosting.md (passkeys belong to the host name) and docs/getting-started.md describe the behavior. ## How I verified it - `cargo xtask check` passes. New tests: RFC 8949's CBOR examples, plus refusals of truncated, indefinite, oversized and deeply nested input; WebAuthn registration and assertion checks (wrong type, origin, relying party, presence, verification, backup flags, trailing bytes, unsupported keys); DER signatures; counters; provider names; the passkey policy; base64url and option conversion in the web app. - In Chromium with a virtual platform authenticator (DevTools WebAuthn) against `cargo xtask dev`: - adding a passkey in Settings; - signing in from the email field's suggestions and from the button; - the after-sign-in card, which stays hidden for someone who already has a passkey; - the setup-only page with "Add a passkey"; - a password sign-in finished with the passkey, and the code step's "Use a passkey instead"; - a credential restored with an old counter, refused as a copy. - A software authenticator in Node registered **RS256** and **Ed25519** passkeys (which Chrome's virtual authenticator doesn't make) through the API and signed in with them. It also checked that a reused challenge, a wrong signature, another origin, missing user verification and a counter going back are all refused, and that the rate limit holds. - CLI: list, rename by name, `whoami`, `admin users`, and an admin reset that removes a required passkey (and keeps passkeys elsewhere). - A code review pass found nine issues, fixed here (signed sign-in challenges, atomic limits and removal, the reset's scope, `TwoFactor.on`, options outliving their challenge, retries in the proof dialog, a refetch racing a successful verify, a duplicated helper, client methods). One more, the cookie-based second-step endpoints, stays browser-only by design. ## Screenshots At 27bc8bc, from `cargo xtask dev`'s accounts. Signing in: the passkey button, which the email field's suggestions join where the browser has them: | Light | Dark, 390 px | |---|---| |  |  | Settings → Passkeys, just after adding one (highlighted), and the page in the dark theme beside two-factor sign-in:   After a password sign-in on a device that can keep passkeys:  Where two-factor is required, the setup-only page offers a passkey first: | Light | Dark, 390 px | |---|---| |  |  | The second step with a passkey, alone and next to an authenticator app: | Passkey only | App and passkey | |---|---| |  |  | Admin → Users with passkeys (fingerprint) next to authenticator apps (shield):  ``` $ fctl account passkeys list Studio laptop this device · added 5m ago · used just now · pky_01M58S0JQZAYKWHFPREFPNYWFN Add passkeys in your browser, with your fingerprint, face or screen lock: https://app.fctl.sh/settings ``` ## Related - #103: two-factor sign-in, which this builds on. - hyperstructure/fctl-site#23: the privacy policy says what passkeys keep, stacked on hyperstructure/fctl-site#22. Merge it with this one. Session: https://app.fctl.sh/s/ses_01M58KEJWPFJKBCENF5HKRV5HV --------- Co-authored-by: fctl-sh[bot] <343971779+fctl-sh[bot]@users.noreply.github.com>
kalaspuff
approved these changes
Oct 19, 2026
kalaspuff
pushed a commit
to hyperstructure/fctl-site
that referenced
this pull request
Oct 19, 2026
The privacy policy says what app.fctl.sh keeps for two-factor sign-in, which hyperstructure/fctl#103 adds: - **What we collect**: for people who turn it on, or whose admins require it, the secret their authenticator app makes codes from (encrypted), a hash of each recovery code and when it was used, when the app was set up and last used, the count of wrong codes in a row, and whether Google sign-ins ask for a code. While a sign-in waits for its code, who it is for and why it waits, and for a password reset a hash of the new password until the code checks out. - **Cookies**: the third cookie, `__Host-fctl-2fa`, which holds a sign-in waiting for its code for 10 minutes. - **Email**: the notices about two-factor changes, sign-ins with a recovery code and wrong codes after a password, with the purpose and legal basis (legitimate interests: account security) and Resend's row. - **Retention**: two-factor data until it's turned off, reset or the account deleted; new recovery codes replace the old; waiting sign-ins expire after 10 minutes. - **Rights and security**: the export includes two-factor sign-in without its secrets, Settings corrects it, and the security summary names the encrypted secrets and hashed recovery codes. Merge it together with hyperstructure/fctl#103, when that deploys. #23 adds passkeys, with hyperstructure/fctl#104. Session: https://app.fctl.sh/s/ses_01M58KEJWPFJKBCENF5HKRV5HV --------- Co-authored-by: fctl-sh[bot] <343971779+fctl-sh[bot]@users.noreply.github.com>
kalaspuff
approved these changes
Oct 19, 2026
kalaspuff
deleted the
fctl/49-add-optional-2fa-totp-enforcement-of-all
branch
October 19, 2026 14:38
This was referenced Oct 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds two-factor sign-in with authenticator apps (TOTP). Anyone can turn it on for themselves, and admins can require it of everyone on the instance. Until now a password, or a password reset link, was enough to get into an account that holds a Claude subscription, starts agents that push to the project's repositories and reads its secrets.
What people get
otpauth://links to the passwords app or the authenticator). The code field is a single input drawn as two groups of three boxes, so pasting, the phone's one-time-code suggestions and password managers fill it at once, and it submits on the sixth digit. Then come ten recovery codes to copy, download as a text file, or print on a page of their own. Replace (new phone) asks for a code from the current app or a recovery code first. Turn off asks for a code too. Recovery codes show how many are left and suggest new ones at three. A switch, Ask for a code when I sign in with Google too, sits beside them./two-factor, which finishes the sign-in and goes on to where it started.two_factor_setup_required.fctl account two-factor(or2fa) hasstatus,enable(prints the QR code in the terminal, asks for the code, shows recovery codes;--jsonplusconfirmwithout a terminal),disable,recovery-codesandgoogle on|off. Admins getfctl admin set --require-two-factor on --two-factor-google onandfctl admin reset-two-factor <email>.fctl whoamiandfctl admin usersshow two-factor too.fctl loginapproves the device in the browser, so the codes are asked for there.How it works
TOTP per RFC 6238 (HMAC-SHA1, six digits, 30-second steps, one step of drift either way), in
fctl-domainwith RFC 4226 and RFC 6238 test vectors. The QR code is made in Rust (qrcodecrate, no default features), so the web app and the terminal draw the same modules.totp-setup:<user>, 15 minutes). D1 then keeps it sealed again (authenticator_apps, bound tototp:<user>), with SHA-256 hashes of the recovery codes (recovery_codes). Setting up an app takes the password unless the browser signed in within 15 minutes; CLI and access tokens always count as old.sign_in_challenges, ten minutes, kept by its hash) in anHttpOnly__Host-fctl-2facookie, so the second step works the same after a password, after Google's redirect and after a reset link, and survives a reload. Each code works once (last_step). A code is checked, then the challenge claimed, then the code used up, so racing requests yield one session and burn nothing.The reasoning, and what it leaves open (enrollment races, older CLIs showing
two_factor_setup_requiredas an HTTP 403), is in ADR 0022. docs/architecture.md, docs/security.md, docs/cli.md, docs/self-hosting.md and docs/getting-started.md describe the behavior.Compatibility.
AuthResponse.mebecomes optional, since login and reset answer withtwo_factorwhen a code is needed. Successful sign-ins answer exactly as before, and nothing but the web app reads it. New fields (Me.two_factor,User.two_factor, the instance settings) are optional with defaults. Migration0011_two_factor.sqladds three tables and a column.ErrorCode::TwoFactorSetupRequiredis new; a CLI from before it shows such errors as an HTTP 403 with the message.How I verified it
cargo xtask checkpasses: fmt, generated types, Biome and TypeScript, Clippy native and wasm,cargo test, Vitest, production builds. New tests cover the TOTP vectors, base32, replay, the lockout schedule, the policy (who needs it, when Google asks), recovery codes, the QR matrix, the terminal QR (read back module by module), the code input (paste, autofill, six-digit cap), the QR component and the web helpers.cargo xtask dev, with the CLI, curl and Playwright, computing codes from the secrets:otpauth://links (jsQR), with the key shown under "Can't scan it?".fctl admin setwhen the requirement also stops the admin.Screenshots
At 31c7878, from
cargo xtask dev's accounts.The code step after the password, and after five wrong codes:
Setting it up in Settings: the QR code with the key behind "Can't scan it?", then the recovery codes, and the section once it's on:
When the instance requires it, someone without it sees this instead of the app (dark theme, and at 390 px):
Admin → Settings → Sign-in, the confirmation that names who isn't set up, and Admin → Users:
The CLI's
enable, in a terminal (the QR code prints dark on a light ground with ANSI colors):Related
Session: https://app.fctl.sh/s/ses_01M58KEJWPFJKBCENF5HKRV5HV