Skip to content

Multi Factor Authentication

The Grit SDK provides a Multi-Factor Authentication (MFA) system that adds a second verification step to the login flow. The built-in method is email one-time passcode (OTP); backup codes are issued at setup so users can recover if they lose access to their email.

Overview

Lifecycle Stage What happens
Setup User visits mfa_setup, receives an email OTP, and confirms it to activate an MFADevice
Backup codes On successful setup, a batch of single-use backup codes is generated and shown once
Login challenge At login, if the user has an active MFA device, a fresh OTP is emailed and the user is redirected to mfa_verify
Verification User submits the OTP (or a backup code); on success they are logged in
Enforcement Optional middleware redirects users without MFA to mfa_setup when MFA_ENFORCEMENT = 'mandatory'
Disable User confirms their password to remove the MFA device and all backup codes

Enforcement Modes

Set via AUTH_SETTINGS['MFA_ENFORCEMENT'] in app/settings.py.

Mode Behavior
disabled MFA is effectively off. Existing MFADevice rows are still honored at login, but no new enrollment is expected.
optional Users may enroll via mfa_setup and will then be challenged at login. No enforcement — unenrolled users log in normally.
mandatory Authenticated users without an active MFADevice are redirected to mfa_setup on every request (except MFA, login, logout, and signup paths). Requires MFAEnforcementMiddleware to be installed.

Settings

Overridable under the AUTH_SETTINGS key.

Setting Default Description
MFA_ENFORCEMENT 'optional' One of mandatory, optional, disabled. See Enforcement Modes.
MFA_METHODS ['email'] Enabled MFA methods. Email OTP is currently the only built-in method.
MFA_BACKUP_CODE_COUNT 10 Number of backup codes issued at setup and on regeneration.
MFA_CODE_EXPIRY_SECONDS 600 Email OTP lifetime, in seconds. Applies to both setup and login challenges.

Data Models

MFADevice

Represents a user's registered MFA method. A device is is_active=False between the time a setup OTP is sent and the time the user confirms it; only active devices trigger the login challenge.

Fields:

Field Type Description
user ForeignKey(CustomUser) The owning user. Reverse accessor: user.mfa_devices.
name CharField(max_length=255) Human-readable label. Defaults to 'Authenticator App'.
method CharField The MFA method — see MFAMethod below.
is_active BooleanField True only after the user confirms setup with a valid code.
metadata JSONField Arbitrary key-value data.

MFADevice.MFAMethod

class MFAMethod(models.TextChoices):
    EMAIL = 'email', 'Email OTP'

BackupCode

Single-use recovery codes for when the user cannot receive the email OTP. Codes are stored hashed — plaintext is shown to the user exactly once, at the moment of generation.

Fields:

Field Type Description
user ForeignKey(CustomUser) The owning user. Reverse accessor: user.backup_codes.
code_hash CharField(max_length=255) Hashed with make_password. Verified with check_password.
is_used BooleanField Marked True after a successful redemption. Used codes are never re-issued.

How the Login Flow Works

The login flow is split into two phases. Until the OTP is verified, the user is not authenticated — they are in an MFA-limbo state tracked entirely in the session.

  1. Phase 1 — Credentials. The user submits email and password to login. If the credentials are valid and the user has an active MFADevice:
  2. A fresh 6-digit code is generated via generate_otp_code() and emailed via send_mfa_email().
  3. Session limbo keys are written (see below).
  4. The user is redirected to mfa_verify.
  5. Phase 2 — Verification. At mfa_verify, the user submits the OTP (or an unused backup code). On success the session limbo keys are cleared, login() is finally called, and the user is redirected to LOGIN_REDIRECT_URL.

If the user has no active MFADevice, phase 2 is skipped and login() runs immediately after phase 1.

Session limbo keys (internal)

During the limbo window, the following keys live on request.session. These are internal implementation details, not a stable API — do not read or write them from application code:

Session key Purpose
_mfa_user_id The CustomUser.pk awaiting verification.
_mfa_backend The auth backend dotted path, replayed into login() after success.
_mfa_code_hash Hashed OTP, verified with check_password.
_mfa_code_expires ISO-8601 expiry timestamp, compared against timezone.now().

Middleware

MFAEnforcementMiddleware

Install in MIDDLEWARE after SessionActivityMiddleware. It handles two concerns:

  1. Limbo redirect. If _mfa_user_id is present on the session but the user is not yet authenticated, all non-exempt requests are redirected to mfa_verify. This prevents an attacker who somehow acquired a half-authenticated session from browsing the site.
  2. Mandatory enforcement. When MFA_ENFORCEMENT = 'mandatory', authenticated users without an active MFADevice are redirected to mfa_setup on every non-exempt request.

Exempt path prefixes (always bypass both checks):

MFA_EXEMPT_PATHS = ['/auth/mfa/', '/auth/login/', '/auth/logout/', '/auth/signup/']

MFA URLs

All MFA URLs are mounted under the auth URL prefix.

Setup

Emails the user an OTP, then activates a new MFADevice once the OTP is confirmed. On success, renders the backup codes page exactly once.

Detail Value
URL name mfa_setup
Methods GET (send code) / POST (confirm code)
Template customauth/mfa_setup.html

Verify

Phase 2 of login. Accepts either the emailed OTP or an unused backup code.

Detail Value
URL name mfa_verify
Methods GET / POST
Template customauth/mfa_verify.html

Resend

Regenerates and re-emails the login OTP for a user in MFA limbo.

Detail Value
URL name mfa_resend
Methods GET (performs resend and redirects)

Disable

Removes the user's MFADevice rows and all BackupCode rows. Requires password re-entry.

Detail Value
URL name mfa_disable
Methods GET / POST
Template customauth/mfa_disable.html

Regenerate Backup Codes

Deletes existing backup codes and issues a fresh batch. Requires password re-entry.

Detail Value
URL name mfa_backup_codes
Methods GET / POST
Template customauth/mfa_regenerate_backup_codes.html

Requiring MFA in App Code

Use these when you want specific views to require an active MFA device — regardless of the global MFA_ENFORCEMENT setting. Users without MFA are redirected to mfa_setup.

mfa_required decorator

For function-based views. Composes with login_required.

from grit.auth.decorators import mfa_required

@mfa_required
def sensitive_view(request):
    ...

MFARequiredMixin

For class-based views. Extends LoginRequiredMixin.

from grit.auth.decorators import MFARequiredMixin

class BillingSettingsView(MFARequiredMixin, View):
    ...

Session Metadata

After a successful MFA verification, the corresponding UserSession.metadata JSON is stamped with:

Key Value
mfa_method_used 'email' if verified via OTP, 'backup_code' if a backup code was redeemed.
mfa_verified_at ISO-8601 timestamp of the successful verification.

See Session Management for the surrounding UserSession model and device-list UI.