Multi-Factor Auth (MFA)

Last updated: 04/17/2026 · Written by Agent0

Multi-Factor Authentication (MFA)

StackCTL includes built-in multi-factor authentication. When enabled, users who have MFA set up on their account are required to enter a one-time code after their password before being granted access. It supports email-based OTP codes, and includes a trusted device feature so users aren't challenged on every login from a familiar browser.


Enabling MFA

Set mfa_enabled to true in config/auth.php:

'mfa_enabled'            => true,
'mfa_driver'             => 'email', // 'email', 'app', or 'both'
'mfa_expiration_minutes' => 5,

Enabling MFA at the app level doesn't force it on every user — it makes MFA available. Each user opts in from their profile page. Only users who have enabled and verified their MFA method will be challenged at login.


MFA Drivers

  • email — A 6-digit code is generated and emailed to the user. No additional package required. Requires mail to be configured in config/mail.php.
  • app — TOTP support for authenticator apps like Google Authenticator or Authy. Requires the robthree/twofactorauth package: composer require robthree/twofactorauth
  • both — Makes both methods available

The Login Flow with MFA

  1. User enters email and password as normal
  2. If credentials are valid and the user has MFA enabled, the system checks for a trusted device cookie
  3. If the browser is trusted — a new code is not sent and the user is logged in directly
  4. If the browser is not trusted — a 6-digit code is generated, stored in mfa_challenges with the configured expiry, and emailed to the user
  5. The user is redirected to /otp to enter the code
  6. On success, the challenge is marked as used and the user is logged in
  7. If the user checks "Trust this device", a secure token is stored and a 30-day cookie is set — future logins from that browser skip the MFA challenge

User Setup — From the Profile Page

Users enable MFA themselves from their profile page at /profile. The flow is:

  1. User clicks "Enable MFA" — a verification code is sent to their email
  2. User enters the code on their profile page to confirm the setup
  3. MFA is now active on their account — they'll be challenged on next login from an untrusted browser
  4. User can disable MFA at any time from the same profile page

The profile page handles this entirely — no code changes needed.


Trusted Devices

When a user successfully completes an MFA challenge and checks "Trust this device", a hashed token is stored in the trusted_browsers table and a cookie is set in their browser for 30 days. On future logins, the cookie is compared against the stored hash — if it matches and hasn't expired, the MFA step is skipped entirely for that browser.

Trusted device records are user-specific — clearing them from the database (or letting them expire) will require the user to complete MFA again on that browser.


Code Expiry & Cleanup

MFA codes expire based on mfa_expiration_minutes in config/auth.php. Expired and used codes accumulate in the mfa_challenges table over time. Set up a cron job to clean them up periodically:

DELETE FROM mfa_challenges
WHERE expires_at < NOW()
   OR used_at IS NOT NULL;

A cleanup interval of every 5–15 minutes is recommended.


Disabling MFA

Set mfa_enabled to false in config/auth.php. The MFA check is skipped entirely during login and the MFA section is hidden on the profile page. Existing mfa_methods records are left in the database but have no effect.

'mfa_enabled' => false
Was this helpful?