PPact
Administration

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_credentials and 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:

  1. 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.
  2. 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.
  3. 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/begin and /register/complete — enroll a new authenticator.
  • GET /v1/auth/passkey/list and DELETE /v1/auth/passkey/{credential_pk} — manage enrolled credentials.
  • POST /v1/auth/passkey/login/begin and /login/complete (plus /login/available) — passwordless / second-factor sign-in.
  • POST /v1/auth/passkey/enrollment-handoff/start and /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 in tenant_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_until is 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 are password (re-prompt, compared against the existing hash) and totp (an authenticator code, or a one-time recovery code in recovery_code). start offers totp only to a user who has an authenticator app set up. webauthn is not a step-up method yet: the route refuses it with webauthn_assertion_required until it verifies a signed assertion bound to the challenge, and start never 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.