The Python surface a host project can depend on. Anything not listed here — the
adapters' internals, django_mfa.otp, django_mfa.totp, the view functions
themselves — is an implementation detail and may change without a deprecation cycle.
Read and write the MFA state on a session. Always use these rather than touching
request.session["mfa"]: the dict's shape is not a public contract and has changed
once already.
| Function | Purpose |
|---|---|
is_verified(request) |
True if this session has completed a second factor. The one you'll actually use. |
is_pending(request) |
True if the session is awaiting a second factor. |
start_pending(request) |
Mark the session as awaiting verification. |
mark_verified(request, method) |
Mark it satisfied by method (a factor type string). |
verified_at(request) |
Unix timestamp of this session's last successful challenge, or None. |
is_fresh(request, max_age) |
True if the session verified a factor within the last max_age seconds. A verified session with no verified_at() (e.g. one from before 4.2.0) counts as stale, not fresh — see {doc}enforcement's step-up section. This is the primitive mfa_recent_required/MfaRecentRequiredMixin are built on; use it directly if you're writing your own step-up policy instead of the decorator/mixin. |
reset(request) |
Remove MFA state entirely. |
Example:
from django_mfa import session
if session.is_verified(request):
...
You rarely need the writers: the user_logged_in receiver calls start_pending()
and the verify view calls mark_verified().
The registry decides which factors exist and what each user holds. Import the singleton — do not construct your own:
from django_mfa.registry import registry
| Method | Returns |
|---|---|
primary_enabled_for(user) |
Adapters that mean this user is protected. Excludes recovery codes. This is the predicate for "does this user have MFA". |
has_primary_factor(user) |
The same question as a bool, in one query rather than one per registered adapter. Use this when you only need yes/no — the enrollment wall, @mfa_required and the notification receivers all do, and on a busy site the difference is per request. Both methods read Adapter.counts_as_primary_factor, so they cannot disagree. |
enabled_for(user) |
Adapters this user can verify with right now. Includes recovery codes. This is what to offer on a challenge screen. |
available_for(user) |
Adapters the user could still add. Excludes singletons they already hold and factors that aren't enrolled at all. |
all() |
Every registered adapter. |
get(type) |
One adapter by type string. Raises KeyError if absent. |
register(adapter) |
Add an adapter instance. Raises ValueError on a duplicate type. |
unregister(type) |
Remove one. Raises KeyError if it wasn't registered. |
The distinction between the first two is the one that matters. A user holding only
recovery codes appears in enabled_for() but not primary_enabled_for(), because
recovery codes are exhaustible and must never be someone's sole factor. Deciding
whether to challenge from enabled_for() would challenge users you cannot protect;
deciding what to offer from primary_enabled_for() would hide the recovery option
from exactly the people who need it.
Adapter is the base class for factor types — see {doc}custom_factors.
One row per enrolled factor.
| Field | Notes |
|---|---|
user |
FK to AUTH_USER_MODEL, related name mfa_authenticators. |
type |
"totp", "webauthn", or "recovery_codes" — see Authenticator.Type. |
name |
User-supplied label. WebAuthn only, so several keys are distinguishable. |
data |
JSONField of factor-specific state. Shape is owned by the adapter; don't reach into it. |
created_at, last_used_at |
Timestamps. last_used_at is None until first use. |
record_usage() stamps last_used_at. Adapters call it on successful verification.
A database constraint (mfa_one_singleton_authenticator_per_user) allows at most one
totp and one recovery_codes row per user; WebAuthn is unlimited.
To remove a user's MFA — the administrative recovery path:
Authenticator.objects.filter(user=user).delete()
A user MFA_REQUIRED does not apply to, despite the predicate — see
{doc}enforcement's "Exempting a user" section. Written only by the
mfa_disable management command (or by deleting the row, to revoke — see
{doc}operations), never through a web view: exempting somebody from a
security requirement is an operator action, not something a user can do to
themselves.
| Field | Notes |
|---|---|
user |
OneToOneField to AUTH_USER_MODEL, related name mfa_exemption. |
reason |
Required, free text. mfa_disable refuses to grant an exemption without one. |
created_at |
Timestamp. |
expires_at |
None means permanent. Otherwise the exemption stops applying once this passes. |
| Method | Returns |
|---|---|
MfaExemption.objects.active_for(user) |
This user's exemption if it is currently in force (expires_at is None or in the future), else None. What policy.mfa_required_for() consults. |
is_active() |
The same freshness check as an instance method, on an object you already have in hand. |
Suppresses MFA_REQUIRED only. It does not open @mfa_required views — see
{doc}enforcement for why the two are deliberately independent.
Resolved settings with defaults applied:
from django_mfa.conf import settings as mfa_settings
mfa_settings.MFA_VERIFY_RATE_LIMIT # "5/5m" unless overridden
Asking for a name that isn't a django-mfa setting raises AttributeError, so typos
surface immediately. See {doc}settings for the full list.
| Function | Purpose |
|---|---|
strings_equal(a, b) |
Timing-safe comparison. Normalizes to NFKC, then hmac.compare_digest. Use for any secret comparison in a custom factor. |
build_uri(secret, name, initial_count=None, issuer_name=None) |
Build an otpauth:// provisioning URI. |
| Function | Purpose |
|---|---|
encrypt(value) |
Encrypt with the first MFA_SECRET_ENCRYPTION_KEYS entry. Returns value unchanged when the setting is unset. |
decrypt(value) |
Decrypt, trying every configured key. Passes through values stored before encryption was enabled. Raises BadSignature if no key works. |
Both are no-ops until a host project opts in, so a custom factor can use them unconditionally and gain encryption the day someone sets the keys.
WebAuthn user handles — opaque, stored, SECRET_KEY-independent.
| Function | Purpose |
|---|---|
user_handle_for(user) |
The user's stable handle, creating it on first call. Idempotent. |
user_from_handle(handle) |
Resolve a handle back to a user, or None. Never raises — input is untrusted. |
For a login page that offers a passkey to a returning visitor. Requires
MFA_QUICKLOGIN = True.
| Name | Purpose |
|---|---|
COOKIE_NAME |
"mfa_quicklogin". |
user_from_hint(value) |
Resolve the cookie to a user, or None. Never raises. |
set_hint(response, user, secure) |
Attach the cookie. No-op when the setting is off. |
clear_hint(response) |
Remove it. Unconditional, so a stale cookie is cleaned up even after the feature is switched off. |
The cookie is a UX hint, never a credential — it identifies whose passkey prompt
to show and authenticates nobody. See {doc}recipes.
Required in AUTHENTICATION_BACKENDS for passwordless login. It performs no
cryptography: the view validates the assertion first, and the backend only returns
the already-resolved user through Django's standard contract.
authenticate() deliberately ignores username/password and returns a user only
when handed one explicitly as mfa_user, so another backend's call passing
credentials through the chain can never authenticate anyone here.
Omitting this backend breaks passkey login silently, one request later —
django_mfa.E003 exists to catch that at startup.
Redirects authenticated-but-unverified requests to the picker. Its exempt set is
derived from the registry (the picker, plus each factor's verify page) unioned with
MFA_EXEMPT_PATHS. Subclass and override process_request to carve out a path
prefix — see {doc}recipes.
Applied for you by the verify view; documented because a custom factor's tests may need to reset it.
| Function | Purpose |
|---|---|
check(user, factor_type) |
True if another attempt is allowed on the per-user budget. |
record_failure(user, factor_type) |
Count a failure against it. |
clear(user, factor_type) |
Reset it, as a success does. |
check_client(request, factor_type) |
The same question for the per-IP budget (MFA_VERIFY_IP_RATE_LIMIT). |
record_client(request, factor_type) |
Count a failure against the client's address. |
client_ip(request) |
The address that budget bills to, or None. |
prune() |
Delete expired counters; what manage.py mfa_prune calls. |
There is deliberately no clear_client(). A successful verification clears the
user's counter but must never clear the shared per-IP one — an attacker needs only
one account they can log into for that to be a reset button. If a test needs the IP
counter gone, delete the rows (or clear the cache) directly.
django-mfa also receives user_logged_in (to stamp the session pending) and
user_logged_out (to clear the quicklogin hint) — you don't connect anything for
those, they're internal.
It sends six signals of its own, defined in django_mfa.events and re-exported
from django_mfa.signals (either import path works):
| Signal | kwargs | Fires when |
|---|---|---|
factor_added |
user, authenticator, request |
An adapter's complete_enroll() succeeds — every ordinary enrollment, plus the first time a user generates recovery codes. authenticator is the created Authenticator row. |
factor_removed |
user, factor_type, name, request |
mfa:manage deletes a row. factor_type and name are passed by value, not as an instance — the row is already gone by the time this fires. |
mfa_verified |
user, method, request |
A second-factor challenge succeeds — from verify_factor's success branch, and from passkey_complete when the passkey assertion carries User Verification (a UP-only passkey login logs the user in but has not satisfied a second factor, so it does not fire this). |
mfa_verification_failed |
user, method, request |
A challenge fails: a wrong code, a caught adapter exception (ValueError/TypeError/KeyError), or an attempt refused outright by the rate limiter — no adapter call happens in that last case, so a receiver watching for brute force needs to see refused attempts too, not only evaluated ones. |
recovery_code_used |
user, remaining, request |
A recovery code is spent. Sent from the adapter itself, not the view — only the adapter knows how many codes are left. |
mfa_exemption_changed |
user, reason, expires_at, revoked, request |
The mfa_disable management command grants or revokes an MfaExemption. reason and expires_at are None on a revoke. sender is the MfaExemption model class, not an Adapter subclass — there is no adapter behind this one. |
request is None when the event did not originate in a request — the
mfa_reset and mfa_disable management commands emit these signals too, so
that operator actions are auditable. Receivers must handle both, as the
example below does.
Read the user off the user kwarg, never off request.user. On the passkey path
mfa_verified fires between session.mark_verified() and auth.login() — the
order is forced, since the login signal's own receiver checks whether the session is
already verified — so request.user is still AnonymousUser there, while the user
kwarg is correct on every path.
sender is the Adapter class for the factor involved (e.g. TOTPAdapter), so
a receiver can narrow with sender=TOTPAdapter — except on factor_removed, whose
sender is None when the removed row's type is no longer registered (MFA_FACTORS
was narrowed since it was enrolled, or a third-party adapter was unregistered): match
on the factor_type kwarg instead of sender if you need to handle that case. On
mfa_exemption_changed, sender is always the MfaExemption model class — there is
no adapter behind an exemption at all.
A receiver:
import logging
from django.dispatch import receiver
from django_mfa.signals import factor_removed
logger = logging.getLogger("myapp.security")
@receiver(factor_removed)
def audit_factor_removal(sender, user, factor_type, name, request, **kwargs):
# request is None when mfa_reset/mfa_disable removed the row instead
# of a request to django_mfa:manage -- log a source that makes sense
# either way rather than assuming request is never None.
source = request.META.get("REMOTE_ADDR") if request else "console"
logger.info("factor removed: user=%s type=%s name=%r from=%s",
user.pk, factor_type, name, source)
All six are sent with send_robust(), not send(): a raising receiver cannot break
the security action it's observing — enrolling, verifying, or removing a factor
succeeds or fails independently of what your receiver does with the event. The other
side of that trade is that send_robust() catches and discards the exception rather
than letting it propagate, so a receiver that fails does so silently unless it
logs its own failure.
To react with something other than a signal receiver, post_save on Authenticator
still works too — these signals are additional, not a replacement for the model
layer.