Multi-factor authentication
Passkeys and an authenticator app (TOTP) with recovery codes at sign-in, per-role MFA enforcement on every sign-in path, and step-up auth on sensitive operations.
Multi-factor authentication
Pact has two second factors, and both work at sign-in:
- Passkeys (WebAuthn) sign you in on their own. A passkey is
phishing-resistant — it proves you hold the device and unlocked it — so it is
never followed by a code prompt. Credentials live in
tenant_user_credentialsand are managed under/v1/auth/passkey/*(api/routes/auth_passkey.py). - An authenticator app (TOTP) is asked for after your password, every time
you sign in with one, and 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).
Pact has no SMS codes. A text message can be redirected by a SIM swap or phished in real time, and both factors above avoid that.
On top of enrollment, Pact adds two enforcement layers: per-role MFA requirements (checked at every sign-in) and step-up (re-prompt) auth on sensitive operations.
Signing in with an authenticator app
- Enter your email and password as usual.
- If your account has an authenticator app, Pact asks for the 6-digit code instead of signing you in. On a computer the code field appears under the password; on a phone it is its own screen with the number keypad, and your phone may offer to fill the code in.
- Enter the code — the sixth digit submits it. Lost your phone? Choose Use a recovery code and type one of your ten saved codes. Each works once.
The code step lasts 5 minutes; after that, enter your password again. Five
wrong codes pause sign-in codes for 15 minutes. The step is a single-use token
(POST /v1/auth/login/mfa), stored only as a hash and bound to your account;
it is not a session and no other page accepts it.
Single sign-on never asks for a Pact code: your identity provider is in
charge of MFA. Pact records what it reports (the OIDC amr claim). API keys
and SCIM tokens are not sign-ins and are unaffected.
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 on Admin → IAM → Policies. /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 page
shows, per role, how many people have a passkey, an authenticator app, or
neither — so you can see who a rule will affect before you save it.
The rule is checked on every path that opens a session: password sign-in
and its code step, passkey sign-in, single sign-on (OIDC, Google/Microsoft
OAuth, SAML) and token refresh (core/iam/login_mfa.py). A sign-in satisfies
it by presenting a second factor at that sign-in:
| Sign-in | Satisfies a required role when… |
|---|---|
| Password + authenticator code or recovery code | always |
| Password only, user has a passkey on file | never — sign in with the passkey instead (MFA_USE_PASSKEY) |
| Passkey | always |
SSO, identity provider reports MFA (amr includes mfa, otp, hwk…) | always |
| SSO, identity provider reports nothing (most SAML, Google) | deferred to the identity provider, and recorded as idp_deferred |
SSO, identity provider reports a single factor (amr: ["pwd"]) | refused after the grace window (IDP_MFA_REQUIRED) |
| Token refresh | refused after the grace window if nothing is enrolled (MFA_REQUIRED — sign in again) |
Inside the grace window (grace_until in the future) people without a
factor sign in as normal; each such sign-in is audited as
auth.mfa.policy.grace.
After the grace window, someone with no factor gets an
enrollment-only sign-in: their password worked, so they land on a page that
can set up an authenticator app or sign out, and nothing else. Once the first
code checks out they get their recovery codes and a normal session. Nobody —
including the admin who turned the rule on — is locked out by it. (The
alternative, refusing sign-in outright, is a one-line setting,
UNENROLLED_AFTER_GRACE, kept off by default.)
Existing sessions end when a rule takes effect. Saving a rule with no grace
window (or one that has already passed) signs out everyone in that role who is
signed in now and has no passkey or authenticator app. Before you save, Pact
lists them by name and email; the save sends that exact list back, and it is
refused if anyone else would be signed out (someone signed in after you opened
the list), so nobody leaves whom you were not shown. People who sign in through
SSO are marked: their next SSO sign-in lets them straight back in if your
identity provider reports a second factor (the amr claim), and refuses them if
it reports a password alone. Each sign-out is audited as
iam.role_mfa.sessions_ended. If you hold the role and have no factor yourself,
the save is refused until you set one up, so you cannot sign yourself out by
accident. With a grace window in the future nobody is signed out; the rule then
applies at each person's next sign-in or token refresh once it ends.
If Pact cannot read the rule or someone's factors (a database fault), the
sign-in is refused with "We couldn't check your workspace's sign-in security
settings" (MFA_STATE_UNREADABLE) — it is never treated as "no rule". The same
holds for SSO required and passkey required: if they cannot be read,
password and passkey sign-in are refused (SIGNIN_CONTROLS_UNREADABLE).
Break-glass admins still get in during any of these, and every such sign-in
is written to the audit log as auth.break_glass.used. SSO required also
closes passkey sign-in, not only the password form.
Every decision where a rule applied — allowed, grace, enrollment-only, refused,
break-glass — is in the audit log as auth.mfa.policy.*, and every code
challenge as auth.login.mfa.*.
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.