Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.praxis-ai.com/llms.txt

Use this file to discover all available pages before exploring further.

Email MFA adds a second factor — a 6-digit code emailed at login — between identity verification (password or OAuth) and full session access. Phase 1 is opt-in, with a post-login nudge for admin and super accounts and a self-serve toggle in every user’s profile.
Configuration prerequisite. MFA only activates when the platform environment has MFA_ENABLED=true AND a valid MFA_PEPPER (see Operational requirements below). The worker hard-fails boot if MFA_ENABLED=true and MFA_PEPPER is missing — that’s intentional, so MFA can’t run in a half-configured state.

How it works

User sign-in (password / OAuth)


   Identity verified


  Trusted-device cookie + matching network?

        ├── yes → straight into the app

        └── no  → email a 6-digit code (5 min validity)


              User submits code

                      ├── correct  → trusted-device cookie set,
                      │             JWT issued, into the app
                      ├── wrong    → up to 5 tries per code
                      └── 5 burned codes / hour → 10 min account lock
Key choices:
  • 6-digit numeric code (matches every other auth code users see — banks, Google, etc.)
  • 5-minute code TTL
  • 7-day default trusted-device window (per-instance tunable via MFA_TRUST_DAYS)
  • Trust matches require both a signed cookie and an IP-subnet (/24) check
  • LTI, SDK, and API-key sign-ins are exempt — they have their own identity proofs

Managing MFA per user

From Admin → Users, click a user’s row to open the editor, then switch to the Credentials tab. The Two-step verification block exposes:
  • Require email MFA for this user — toggle that enables / disables MFA on the target account
  • Enabled <date> caption when MFA is on
  • Revoke trusted devices — invalidates every browser the user has previously verified. The next login from any of those browsers will re-prompt for a code
Disabling does NOT log the user out. Per the grandfather rule, the user’s current JWT keeps working until natural expiry (~6 hours). Disable only revokes the trusted-device cookies, so the user’s next fresh sign-in proceeds without MFA. If you need to force an immediate logout, use the Sessions panel separately.

When to disable a user’s MFA

  • The user is locked out (lost email access, new email address pending, mailbox bounced)
  • A security incident requires temporarily reducing friction so the user can recover their account
  • A standard user opted in voluntarily and changed their mind

When to revoke trusted devices

  • The user reported a device lost or stolen
  • A shared / public computer was used during a recent session
  • After a suspected password compromise (combined with a password reset)

Operational requirements

The following environment variables are read by the worker at boot:
VariableDefaultRequiredNotes
MFA_ENABLEDfalseoptionalSet to "true" to activate the entire MFA surface. When false, the gate short-circuits and behaves as if MFA never existed.
MFA_PEPPER(none)required when MFA_ENABLED=trueAt least 32 characters. Mixed into the SHA-256 hash of every 6-digit code stored in the database. Generate with node -e "console.log(require('crypto').randomBytes(32).toString('hex'))".
MFA_TRUST_DAYS7optionalBounded to [1, 30]. Sets the trusted-device cookie lifetime. Customers with tighter compliance can shorten it.
Rotating MFA_PEPPER invalidates all pending challenges (any 5-minute window with codes already issued). Acceptable trade-off because rotation should be rare. To rotate cleanly: deploy new pepper, wait 5 minutes for outstanding challenges to drain, then verify no mfa_challenge rows remain in the pending state.

Audit log

Every MFA event is recorded in a dedicated mfa_audit_log collection. The taxonomy:
EventTrigger
mfa.enableUser self-enabled MFA
mfa.disableUser self-disabled MFA
mfa.admin_overrideAdmin enabled or disabled MFA on another user
mfa.code.issuedA new 6-digit challenge was created
mfa.code.resentUser clicked Resend; old challenge invalidated, new one issued
mfa.code.verifiedCode accepted, sign-in completed
mfa.code.failedWrong code submitted (per-code attempts incremented)
mfa.challenge.cancelledUser clicked Cancel on the verification screen
mfa.challenge.expiredVerify or Resend hit an already-expired challenge
mfa.lockout5 burned challenges in 1 hour → 10-minute account lock
mfa.trusted_device.addedNew device cookie issued after successful verification
mfa.trusted_device.revokedUser or admin revoked one or all trusted devices
mfa.prompt.dismissedUser clicked Remind me in 7 days on the opt-in screen
Super admins can retrieve a user’s audit log via the API at GET /api/admin/users/{userId}/mfa-audit — see the Administrator API. The response is paginated by ?limit= and ?before= (ISO timestamp).

Brute-force protection

Two layers, both enforced server-side:
  1. Per-code: 5 attempts. The 6th wrong submission invalidates the challenge — the user must click Resend for a fresh code. Resends are cooldown-limited to once per 30 seconds.
  2. Per-account: 5 burned codes in 1 hour. “Burned” means invalidated and had at least one wrong attempt (so a user spam-clicking Resend without submitting wrong codes doesn’t trip the lock). After 5 burns the account is locked from MFA verification for 10 minutes — /api/auth/signin returns HTTP 423 with retryAfter: 600. Admin intervention via the Credentials tab can disable MFA for the user if needed sooner.

Exempt paths

The MFA gate is bypassed for sign-ins that already have a strong alternate proof of identity:
  • LTI launches — the LMS (Canvas / Moodle / Brightspace) has already authenticated the user
  • SDK launches — the embedding application’s launch token proves identity
  • API-key sign-ins — the API key itself is a strong credential, intended for server-to-server use
Standard password and OAuth (Google / GitHub / Facebook / generic OAuth2 SSO) flows all go through the MFA gate when the user has it enabled.

Frequently asked questions

Not in this phase. MFA is opt-in per user. The post-login nudge encourages admin and super accounts to enable it (with a 7-day “Remind me later” cooldown), but enforcement is the user’s choice. Mandatory MFA is on the roadmap.
Their current JWT stays valid until natural expiry (default 6 hours). Their trusted-device cookies are revoked. So the user keeps working through their current session, then re-signs in normally without MFA. If you need to force an immediate logout, terminate their session from the Sessions panel separately.
Yes. The verify form uses the autocomplete="one-time-code" attribute, so iOS Safari surfaces the 6-digit code from the Mail app directly in the keyboard suggestion bar — one tap to fill all six boxes. Android Chrome and Firefox have equivalent suggestions when SMS Retriever-style attributes are honored.
Not in this phase. Recovery is admin-assisted (an admin or super admin can disable MFA on the locked-out account). Backup codes are tracked for a follow-up release.
Not in this phase either. The current implementation is email-only, which keeps the recovery surface simple (admin override) and avoids managing a TOTP secret store. TOTP and WebAuthn / passkeys are on the longer-term roadmap.