Multi-factor authentication
Passkeys for sign-in, an authenticator app (TOTP) with recovery codes for step-up, per-role MFA enforcement, and step-up auth on sensitive operations.
Multi-factor authentication
Pact has two factors beyond your password, and they do different jobs:
- Passkeys (WebAuthn) sign you in. Credentials live in
tenant_user_credentialsand are managed under/v1/auth/passkey/*(api/routes/auth_passkey.py). - An authenticator app (TOTP) confirms sensitive changes — the step-up
prompt below. Codes come from Google Authenticator, 1Password, Microsoft
Authenticator, Authy or any RFC 6238 app. Managed under
/v1/auth/totp(api/routes/auth_totp.py).
On top of enrollment, Pact adds two enforcement layers: per-role MFA requirements and step-up (re-prompt) auth on sensitive operations.
What an authenticator app does not do (yet)
Sign-in does not ask for an authenticator code, and an enrolled authenticator app does not satisfy a per-role MFA requirement — only a passkey does. The authenticator app is a step-up factor.
Setting up an authenticator app
In Settings → Security & MFA → Authenticator app, choose Set up authenticator app. You confirm your password first (step-up), then:
- Scan the QR code, or type the key shown under it, into your app. The QR is drawn in your browser; the key never goes to a third-party service.
- Enter the 6-digit code the app shows. Nothing is switched on until this code checks out, so a mistyped key cannot lock you out later.
- Save the ten recovery codes. Each works once in place of a code if you lose your phone. They are shown once — Pact stores only a hash of each — and the dialog cannot be closed until you confirm you saved them.
The setup expires after 10 minutes if you don't finish it. One authenticator app per account: to move to a new phone, remove the old one first (behind step-up) and set up again.
The card shows when the app was turned on, when it was last used, and how many recovery codes are left, and warns at three or fewer. New recovery codes (behind step-up) replaces the whole set; the old codes stop working.
How codes are checked
- Standard parameters: HMAC-SHA1, 6 digits, a 30-second step — what every authenticator app uses.
- Clock drift: a code from one step either side of now is accepted (about ±30 seconds). If codes are rejected, set your phone's clock to update automatically.
- No replays: a code is accepted once. Using the same code again, or an older one, is refused with "That code was just used".
- Rate limit and lockout: six attempts per minute per user; five wrong codes in a row pause authenticator codes for 15 minutes. Your password still works during the pause.
- At rest: the shared secret is encrypted and bound to your account; recovery codes are stored as hashes. Neither is ever logged.
- If the server can no longer read the key (for example, after the deployment's encryption key changes), codes are refused with a message that says so, and none of them count toward the lockout. The card in Settings reads Needs setup again. Recovery codes and your password still work. Remove the app and set it up again to use codes from it.
- Audit: setup, confirmation, every verification (success or failure),
lockout, recovery-code use, new codes and removal are written to the audit
log as
auth.mfa.totp.*.
Enrolling and using passkeys
The passkey routes cover the full WebAuthn ceremony:
POST /v1/auth/passkey/register/beginand/register/complete— enroll a new authenticator.GET /v1/auth/passkey/listandDELETE /v1/auth/passkey/{credential_pk}— manage enrolled credentials.POST /v1/auth/passkey/login/beginand/login/complete(plus/login/available) — passwordless / second-factor sign-in.POST /v1/auth/passkey/enrollment-handoff/startand/redeem— hand off enrollment to another device.
Each enrolled authenticator is one row in tenant_user_credentials.
Per-role enforcement
Admins can require MFA per role. /v1/iam/role-mfa
(api/routes/iam_policies.py) reads and writes role_mfa_requirements
(one requires_mfa + optional grace_until per role, per tenant). The check
in core/iam/mfa_policy.py works like this:
user_has_mfa(...)is true when the user has at least one row intenant_user_credentials(i.e. at least one passkey enrolled).- If a role requires MFA and the user has none, they're blocked with a 403 —
unless
grace_untilis set and still in the future, which lets an admin turn the requirement on and give users a window to enroll before enforcement bites.
Step-up on sensitive operations
Step-up auth issues a short-lived, single-use token scoped to a specific
action_class (api/routes/auth_stepup.py):
POST /v1/auth/stepup/start— create a challenge bound to an action class (e.g.billing.change).POST /v1/auth/stepup/verify— prove a fresh credential and receive the one-shot token. Methods arepassword(re-prompt, compared against the existing hash) andtotp(an authenticator code, or a one-time recovery code inrecovery_code).startofferstotponly to a user who has an authenticator app set up.webauthnis not a step-up method yet: the route refuses it withwebauthn_assertion_requireduntil it verifies a signed assertion bound to the challenge, andstartnever offers it.
The sensitive-ops policy (core/iam/sensitive_ops.py) decides which
mutations demand a fresh step-up token. It is per-tenant and default-OFF
(stored under the step_up_sensitive_ops org-setting key): until a tenant
opts in, gated endpoints behave exactly as before — zero blast radius on
deploy. When enabled, it covers billing changes, data exports, and
integration-credential changes.