Skip to content

feat: add two-factor sign-in with authenticator apps - #103

Merged
kalaspuff merged 6 commits into
mainfrom
fctl/49-add-optional-2fa-totp-enforcement-of-all
Oct 19, 2026
Merged

kalaspuff merged 6 commits into
mainfrom
fctl/49-add-optional-2fa-totp-enforcement-of-all

Conversation

@fctl-sh

@fctl-sh fctl-sh Bot commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Settings → Two-factor sign-in. "Set up" opens a dialog with a QR code that ripples in from the middle, the key in groups of four for apps that can't scan, and an "Open in your app" link on phones (iOS and Android hand 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.
  • Signing in. After a correct password the page asks for the code, showing who is signing in ("Not you?"). A wrong code shakes the boxes, and after the second miss the page says to check that the phone sets its clock automatically. A recovery code works in place of the app code. Five wrong codes in a row make codes from the app wait a minute, then twice as long each time, up to an hour, with a live countdown; recovery codes still work meanwhile. Signing in with a recovery code says how many are left.
  • Google. Google sign-ins don't ask for a code, since Google has its own two-step verification, unless the person turns that on or the admins do for everyone. Then Google's callback lands on /two-factor, which finishes the sign-in and goes on to where it started.
  • Password resets. A reset link for an account with two-factor asks for a code, and the new password takes effect only once the code checks out. Whoever reads someone's email can't lock them out with a reset link alone.
  • Admin → Settings → Sign-in. Require two-factor sign-in applies to everyone who signs in with a password. Ask for codes on Google sign-in too applies to Google sign-ins, and with the requirement on, to people who sign in only with Google. A meter shows how many people use it and who doesn't yet. Turning either switch on when it would stop people asks first and names them, including the admin. Someone it is required of who hasn't set it up sees a page that sets it up instead of the app, and the control plane refuses their other requests, from browser sessions and CLI and access tokens alike, with two_factor_setup_required.
  • Admin → Users shows who has two-factor, notes who still has to set it up, and has Reset two-factor for people who lost their phone and their recovery codes. Only owners can reset an owner's.
  • CLI. fctl account two-factor (or 2fa) has status, enable (prints the QR code in the terminal, asks for the code, shows recovery codes; --json plus confirm without a terminal), disable, recovery-codes and google on|off. Admins get fctl admin set --require-two-factor on --two-factor-google on and fctl admin reset-two-factor <email>. fctl whoami and fctl admin users show two-factor too. fctl login approves the device in the browser, so the codes are asked for there.
  • Email, when it's set up: turning two-factor on, replacing the app, turning it off, an admin's reset, a sign-in with a recovery code, and the first wait after wrong codes (whoever it was knew the password) email the person. All of these go in the audit log.

How it works

TOTP per RFC 6238 (HMAC-SHA1, six digits, 30-second steps, one step of drift either way), in fctl-domain with RFC 4226 and RFC 6238 test vectors. The QR code is made in Rust (qrcode crate, no default features), so the web app and the terminal draw the same modules.

  • Setup. Nothing is stored until the app's first code confirms the secret. Until then the secret travels sealed for the person (AES-GCM under the master key, bound to totp-setup:<user>, 15 minutes). D1 then keeps it sealed again (authenticator_apps, bound to totp:<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. A sign-in that asks for a code starts no session. It leaves a challenge (sign_in_challenges, ten minutes, kept by its hash) in an HttpOnly __Host-fctl-2fa cookie, 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 requirement is checked on every request without an extra round trip: the token lookup reads the instance's settings in the same query.

The reasoning, and what it leaves open (enrollment races, older CLIs showing two_factor_setup_required as 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.me becomes optional, since login and reset answer with two_factor when 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. Migration 0011_two_factor.sql adds three tables and a column. ErrorCode::TwoFactorSetupRequired is new; a CLI from before it shows such errors as an HTTP 403 with the message.

How I verified it

  • cargo xtask check passes: 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.
  • End to end on cargo xtask dev, with the CLI, curl and Playwright, computing codes from the secrets:
    • setting up in Settings and from the CLI (with and without a terminal), and replacing the app (current code first);
    • signing in with password and code, wrong codes, five wrong codes sent at once (all counted), the wait (app codes refused, recovery codes accepted), a recovery code used twice (refused);
    • a reset link with two-factor (the old password keeps working until the code is entered);
    • Google's second step (challenge inserted the way the callback leaves it);
    • requiring two-factor (the confirmation names who isn't set up), the setup-only page, an access token refused until setup, and an admin reset putting someone back on that page;
    • the downloaded recovery codes file, and the print view, which shows only the codes, from the dark theme too.
  • The QR codes in the screenshots decode to the expected otpauth:// links (jsQR), with the key shown under "Can't scan it?".
  • A code review pass found seven issues, all fixed here: tokens counting as fresh sign-ins, replacing without a code, wrong codes counted by read-modify-write, the wait being usable to lock owners out, a code burned when a racing request claimed the challenge, the terminal quiet zone, and fctl admin set when 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:

Code Waiting
The code step after the password, light After five wrong codes: a countdown, and a button that switches to recovery 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:

Scan Recovery codes
The setup dialog: QR code, steps, code boxes Two-factor is on: ten recovery codes to copy, download or print

Settings → Two-factor sign-in once it's on: the app, recovery codes left, replace and turn off

When the instance requires it, someone without it sees this instead of the app (dark theme, and at 390 px):

Desktop Phone
The page that sets up two-factor, dark The same page on a phone, with "Open in your app"

Admin → Settings → Sign-in, the confirmation that names who isn't set up, and Admin → Users:

Admin sign-in settings: require two-factor, the meter of who uses it, Google sign-ins

Confirmation Users
Require two-factor sign-in? Grace Hopper hasn't set it up yet Users with two-factor badges, Reset two-factor, and who sets it up next

The CLI's enable, in a terminal (the QR code prints dark on a light ground with ANSI colors):

$ fctl account two-factor enable
Scan this with your authenticator app:

    █▀▀▀▀▀█ █ ▄ ▄█▄  ▀█▄▄▀  █▀▀▀ ▀█▄  █▀▀▀▀▀█
    █ ███ █ ▄▀█ ▀▄▀▀ ▀ ▀▄▄ █▄  ▄ ▀▄▄▀ █ ███ █
    …

Or type in this key: M3K3 E46S BSGG LMTY KBWJ TFXL APJ3 K57W

Code from the app: 123456
✓ Two-factor sign-in is on: signing in with your password now also takes a code.

Recovery codes: each one signs you in once if you lose your phone.
Save them somewhere safe now, such as your password manager: they are shown once.

  WCQJK-QRVVQ    DJHTP-FXPTT
  …

Related

Session: https://app.fctl.sh/s/ses_01M58KEJWPFJKBCENF5HKRV5HV

fctl-sh Bot added 5 commits October 18, 2026 23:27
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.
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 |
|---|---|
| ![The sign-in page with "Sign in with a
passkey"](https://app.fctl.sh/api/v1/published/sAyE1zMwwkOuUJRqyQCLxYfjGAKKipKY2wQ2HuIPxL2/login-passkey-light.png)
| ![The same page on a phone,
dark](https://app.fctl.sh/api/v1/published/qXp7snm5pIUaCXsBjqCvAVM6QodxiZfAA8O3pr96bkd/login-passkey-dark-phone.png)
|

Settings → Passkeys, just after adding one (highlighted), and the page
in the dark theme beside two-factor sign-in:

![Settings, Passkeys: a new passkey, "Add another
passkey"](https://app.fctl.sh/api/v1/published/2ayx8hOP6cGop4cotG5UJZP4SFi8UmQEh4WiZlzOkZY/passkeys-one-light.png)

![Settings in the dark theme: Passkeys and Two-factor
sign-in](https://app.fctl.sh/api/v1/published/wWOiuAn2UIdiJ31C9pHNGyBqgAlCsespvbZslHu9E2s/settings-passkeys-dark.png)

After a password sign-in on a device that can keep passkeys:

![The card "Sign in faster next
time"](https://app.fctl.sh/api/v1/published/uUeERkTWVG2VA67GJWsVdudsWKxzDPZgqxTYnqWNfIU/passkey-offer-light.png)

Where two-factor is required, the setup-only page offers a passkey
first:

| Light | Dark, 390 px |
|---|---|
| ![Add a passkey (quickest) or use an authenticator
app](https://app.fctl.sh/api/v1/published/WEg8KTFO3lDXwbS0CdTaueot2tj1913HjbVgigpZq5L/gate-choose-light.png)
| ![The same choice on a phone,
dark](https://app.fctl.sh/api/v1/published/QmbM6xx8cotuiG51LQXFpXy16Jjko4uYT1uGg3dSJXL/gate-choose-dark-phone.png)
|

The second step with a passkey, alone and next to an authenticator app:

| Passkey only | App and passkey |
|---|---|
| ![One more step: Use your
passkey](https://app.fctl.sh/api/v1/published/6yfu8rZAHEmUALzcuwShGWXRmC5zWrRAsUjB0LAtm2V/second-factor-passkey-light.png)
| ![Enter your code, or "Use a passkey
instead"](https://app.fctl.sh/api/v1/published/qpQFqz5wVZg9YdDqRdPN4DZyigJN0cfp5ww1C2sJhak/code-or-passkey-light.png)
|

Admin → Users with passkeys (fingerprint) next to authenticator apps
(shield):

![Users with two-factor and passkey
badges](https://app.fctl.sh/api/v1/published/mU9SRw0wgSfRBxbqvQj3B8SNPKGrtk3YvvXea64kG8v/admin-users-passkeys-light.png)

```
$ 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 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
kalaspuff merged commit 65dcbe1 into main Oct 19, 2026
5 checks passed
@kalaspuff
kalaspuff deleted the fctl/49-add-optional-2fa-totp-enforcement-of-all branch October 19, 2026 14:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant