Multi-Factor Authentication (MFA)

TOTP-based two-factor authentication compatible with Google Authenticator, Authy, 1Password, and other authenticator apps. Built on the pragmarx/google2fa-laravel package.

How It Works

  • User enables MFA from their profile settings
  • A TOTP secret is generated and displayed as a QR code
  • User scans the QR code with their authenticator app
  • User confirms by entering a 6-digit code from the app
  • 8 recovery codes are generated as a backup
  • On subsequent logins, a 6-digit code is required after entering credentials

Enable MFA

Step 1: Generate Secret

POST /api/auth/mfa/enable

Generates a TOTP secret and returns it along with a QR code URL for the authenticator app.

{
    "secret": "JBSWY3DPEHPK3PXP",
    "qr_code_url": "otpauth://totp/SaasKitFy:john@example.com?secret=JBSWY3DPEHPK3PXP&issuer=SaasKitFy",
    "qr_code_svg": "..."
}

At this stage, MFA is not yet active. The user must confirm with a valid code.

Step 2: Confirm Setup

POST /api/auth/mfa/confirm
{
    "code": "123456"
}

Validates the 6-digit code against the pending secret. On success, MFA is activated and 8 single-use recovery codes are returned.

{
    "message": "MFA has been enabled.",
    "recovery_codes": [
        "abcde-fghij",
        "klmno-pqrst",
        "uvwxy-zabcd",
        "efghi-jklmn",
        "opqrs-tuvwx",
        "yzabc-defgh",
        "ijklm-nopqr",
        "stuvw-xyzab"
    ]
}

A MfaStatusNotification is sent to the user confirming MFA has been enabled.

Verify MFA on Login

POST /api/auth/mfa/verify
{
    "mfa_token": "temp_token_from_login",
    "code": "123456"
}

When a user with MFA enabled logs in, the login endpoint returns a short-lived mfa_token (valid for 10 minutes) instead of a full session token. The client must call this endpoint with the token and a valid 6-digit TOTP code.

Using a Recovery Code

If the user cannot access their authenticator app, they can use one of their 8 recovery codes instead of a TOTP code:

{
    "mfa_token": "temp_token_from_login",
    "code": "abcde-fghij"
}

Recovery codes are single-use — each code is consumed when used and cannot be reused. The system automatically detects whether the submitted code is a 6-digit TOTP code or a recovery code.

Disable MFA

POST /api/auth/mfa/disable
{
    "code": "123456"
}

Requires a valid TOTP code to confirm the action. Clears the TOTP secret and all recovery codes. A MfaStatusNotification is sent confirming MFA has been disabled.

Regenerate Recovery Codes

POST /api/auth/mfa/recovery-codes
{
    "code": "123456"
}

Requires a valid TOTP code. Invalidates all existing recovery codes and generates a fresh set of 8 codes. Use this if the user has lost their recovery codes or has used several of them.

{
    "recovery_codes": [
        "newco-de001",
        "newco-de002",
        "newco-de003",
        "newco-de004",
        "newco-de005",
        "newco-de006",
        "newco-de007",
        "newco-de008"
    ]
}

Recovery Code Storage

Recovery codes are encrypted at rest using Laravel's encryption. They are stored in the mfa_recovery_codes column on the User model as an encrypted JSON array. When a code is consumed, it is removed from the array.

Organization-Wide Enforcement

Organization owners and admins can require MFA for all members of their organization:

  • Set Organization.mfa_required to true
  • The mfa_enforced_at timestamp records when enforcement began
  • Members without MFA are prompted to set it up on their next login
  • Members cannot access organization resources until MFA is configured
  • The enforcement check runs via middleware on all org-scoped routes

Admin Toggle

MFA can be globally enabled or disabled from the admin panel:

  • Setting: auth.mfaenabled or disabled
  • When disabled globally, MFA setup endpoints return 403 Forbidden
  • Users who already have MFA enabled are not affected — they can still verify and disable
  • Organization-level enforcement is independent of the global toggle