Flow and URLs
Once basic setup (see Getting started) is done, django-mfa exposes the following URLs under whatever prefix you mounted django_mfa.urls at (e.g. /mfa/). All names below are reversed with the mfa: namespace, e.g. reverse("mfa:security_settings").
Name |
Path |
Purpose |
|---|---|---|
|
|
Overview: enabled factors, factors still available to add, recovery codes remaining. |
|
|
POST-only: delete one of the user’s own authenticators. |
|
|
The second-factor picker (see below). |
|
|
Challenge screen for one specific factor ( |
|
|
Enrollment screen for one specific factor ( |
|
|
View (and, on first visit, generate) recovery codes. |
|
|
GET: start a passwordless WebAuthn ceremony (see below). |
|
|
POST: finish it and log the resolved user in. |
factor_type is whatever Authenticator.Type values are registered – out of the box, totp, webauthn, and recovery_codes.
Enrolling a second factor
An already-logged-in user visits
mfa:security_settings, which lists the factor types they don’t yet have (available_adapters) as links tomfa:enroll_factor.GET mfa:enroll_factor/<type>/renders that factor’s enrollment page – for TOTP, a freshly generated secret and QR code; for WebAuthn, a registration ceremony driven bydjango_mfa/static/django_mfa/webauthn.js.Completing the ceremony (entering the six-digit code, or a browser completing
navigator.credentials.create()) POSTs back to the same URL. On success this creates anAuthenticatorrow, marks the current session’s second factor as satisfied (the user just proved possession), and redirects tomfa:recovery_codesif the user has no recovery codes yet, ormfa:security_settingsotherwise.
Logging in with a second factor
The host project’s own login view authenticates the user as usual (username/password, SSO, etc.) and calls
django.contrib.auth.login(). Auser_logged_insignal handler (django_mfa/signals.py) then marks the session pending a second factor, if – and only if – the user has at least one factor counted as a primary factor (totp,webauthn, or the opt-inemailfactor – see Security model; recovery codes alone do not count, since they’re exhaustible). This replaces the old contract where a host project’s login view had to set session keys by hand.From that point on,
MfaMiddlewareredirects every request from that session tomfa:verifyexcept the picker itself, each registered factor’s own verify page, and anything listed inMFA_EXEMPT_PATHS(see the warning in Settings reference – this should include your logout URL).mfa:verify(the picker) looks at which factors this user actually holds. If there’s exactly one, it redirects straight tomfa:verify_factor/<type>/– no need to make a user with a single factor choose from a list of one. Otherwise it renders a list to choose from.GET mfa:verify_factor/<type>/renders that factor’s challenge screen; POSTing the code/assertion marks the session fully verified on success (or a generic error on failure – see the rate-limiting note below) and redirects tonext(validated against open redirects) orLOGIN_REDIRECT_URL.
Failed attempts against a single factor are rate-limited (MFA_VERIFY_RATE_LIMIT, default 5 per 5 minutes per user per factor type); a locked-out attempt gets the exact same response as a wrong code, so the lockout itself never reveals whether MFA is even enabled for that account.
Being required to enroll
Everything above only applies to a user who already holds a factor – enrollment is
opt-in by default, and a user who never enrolls is never prompted or blocked. A
separate, opt-in wall changes that: if MFA_REQUIRED (django_mfa.policy) applies
to a user and they hold no primary factor yet, MfaMiddleware redirects every
request from that user to mfa:security_settings instead of to the picker –
there is nothing to challenge them on until they enroll something. See
Enforcing MFA for the settings, the exact pages still reachable while walled,
and the equivalent per-view @mfa_required decorator.
Passwordless (passkey) login
A user whose only factor is a WebAuthn passkey doesn’t have to go through “password, then second factor” at all – see Getting started for the AUTHENTICATION_BACKENDS setup this requires.
GET mfa:passkey_beginstarts a WebAuthn authentication ceremony with an empty credential allow-list. That emptiness is what makes the browser prompt offer any discoverable credential it holds for this site, rather than asking for a specific already-known user – i.e. what makes the login “usernameless”.The browser resolves a credential and its assertion (including the opaque user handle the credential was registered with) is POSTed to
mfa:passkey_complete. The view resolves the handle back to a user, validates the assertion (reusing the exact same verification code path asmfa:verify_factor/webauthn/, including clone detection), and logs the user in viadjango_mfa.backends.WebAuthnBackend.Whether this satisfies both factors in one step depends on whether the assertion carried the WebAuthn User Verification flag (PIN/biometric), not just User Presence:
User-verified assertion: the session is marked fully verified immediately. A user whose only factor is this same passkey reaches
LOGIN_REDIRECT_URLdirectly.Presence-only assertion (e.g. a security key with no PIN set up): the user is logged in, but the second factor is left pending exactly as it would be after a plain password login. If that passkey is the user’s only factor,
MfaMiddlewaresends them to the picker, which sees exactly one factor – this same passkey – and redirects straight back tomfa:verify_factor/webauthn/, where re-tapping the same key completes the challenge normally. This is deliberate, not a bug: WebAuthn login only strictly requires User Presence, so django-mfa cannot assume User Verification happened without checking.
Every failure mode on the passwordless path – an unknown handle, an unknown credential, a bad signature, missing/expired ceremony state, a tampered payload – returns the exact same generic response, so a client can never use timing or response differences to enumerate which accounts exist.
Removing a factor
POST mfa:manage with the authenticator’s primary key deletes it (a user may only delete their own). If MFA_OWNED_BY_ENTERPRISE is set, WebAuthn authenticators cannot be removed this way – typically because they represent an organization-issued security key an administrator manages instead of the end user.