What django-mfa defends against, how, and — just as important — what it doesn't.
django-mfa is a second factor, not an authentication system. It assumes your project already authenticates users correctly (password hashing, session security, CSRF, HTTPS) and adds proof of possession on top. It assumes an attacker may know a victim's password.
It does not assume your users are careful, that their devices are clean, or that your login page is the only way into your app.
Every failure on the passwordless login path returns an identical generic response: an unknown user handle, an unknown credential, a bad signature, missing or expired ceremony state, and a tampered payload are indistinguishable to the client. There is no status-code split, no differing message, and no exception path that would turn one case into a 500 while others return 400 — that difference would itself be an oracle.
The same holds for second-factor verification: a wrong code, a malformed payload, a missing field, and a rate-limited attempt all produce the same HTTP 400 and the same message.
Two budgets, and exhausting either one refuses the attempt:
MFA_VERIFY_RATE_LIMIT(default"5/5m") counts failures per user per factor type. A successful verification clears it.MFA_VERIFY_IP_RATE_LIMIT(default"50/5m") counts them per client address, across every account. A successful verification deliberately does not clear it.
The second exists because the first cannot see the attack that matters most at scale. An attacker holding a list of stolen passwords guesses once against each of ten thousand accounts: every per-user counter sits at 1, none of them ever binds, and the per-user budget never fires at all. Not clearing the IP counter on success is the other half — an attacker only needs one account of their own, or one lucky guess, to log into, and a counter reset on success would be reset at will.
A locked-out attempt returns exactly what a wrong code returns, from either budget. The lockout is therefore not observable, and cannot be used to probe whether an account exists or has MFA enabled.
The window is fixed from the first failed attempt, not slid forward by each subsequent one: five failures at 12:00:00 and 12:04:59 both fall in the same window, which reopens at 12:05:00.
Four properties worth understanding before you rely on it:
- The client address comes from
REMOTE_ADDR, andX-Forwarded-Foris ignored unlessMFA_CLIENT_IP_RESOLVERsays otherwise. A client-set header breaks the budget in both directions — an attacker who varies it is never throttled, and one who forges your office's address locks your staff out. Behind a proxy you must set that resolver, orREMOTE_ADDRis the proxy and every client shares one counter. - Counters are durable by default. Since 4.5.0 they are rows
(
MFA_RATE_LIMIT_BACKEND = "database"), because a cache-only counter is erased by a restart, an eviction, or a straycache.clear(), and each erasure silently hands an attacker mid-run a fresh budget. Set the backend to"cache"for the older behaviour, knowing that. - It fails open when the store is unreachable, which is a deliberate
availability-over-control trade: a secondary throttle should not be able to lock
every user out of your site.
MFA_RATE_LIMIT_FAIL_OPEN = Falsereverses that. Note this covers an outage, not an absent counter — "nobody has failed yet" is always allowed, or no first attempt could ever succeed. - On the cache backend, it is only as shared as your cache is, and needs atomic
incr(). WithLocMemCacheand four worker processes each keeps its own counter, so the effective limit is four times what you configured.FileBasedCacheimplements neitheradd()norincr()atomically, so parallel attempts overwrite each other's increments and the limit stops binding at the attacker's chosen concurrency — do not use it for this. Neither caveat applies to the database backend, where a singleUPDATE ... count = count + 1is serialised by the database.
Invalid values are rejected loudly rather than silently misbehaving: a count of 0
would lock out every user permanently, and a window of 0 would expire the counter
instantly and disable the throttle. Both raise ValueError, and manage.py check
refuses them at startup (django_mfa.E007) rather than waiting for the first
verification attempt after a deploy.
Secrets are 160-bit values from secrets.token_bytes (i.e. os.urandom), the length
RFC 4226 recommends. They are never drawn from the random module, whose Mersenne
Twister state is recoverable from observed output.
A code can be redeemed exactly once. django-mfa records the time step each accepted code belonged to and refuses any code at or below it, as RFC 6238 §5.2 requires. This matters because the ±1 window below means a code is otherwise valid for about 90 seconds: without single-use enforcement, a code read over someone's shoulder or captured in transit stays usable for the rest of that window.
Verification accepts the previous and next 30-second code as well as the current one
(TOTP_VALID_WINDOW = 1), matching Google Authenticator, django-otp and allauth. The
tolerance costs three guesses out of a million rather than one, which the rate limit
above covers.
WebAuthn authenticators may maintain a signature counter that increments on every assertion. A counter that fails to advance suggests the credential has been copied. django-mfa checks this on every assertion and refuses the attempt when it regresses.
There's a carve-out: authenticators that never implement a counter always report 0,
which includes Apple/iCloud passkeys. Treating "not greater than stored" as a clone
would reject every iCloud passkey login. The check distinguishes the two cases — do
not simplify it to new > stored.
Ten codes, generated together, each usable once. They are hashed with Django's password hasher, and used codes are marked individually rather than deleted, so a replay is detected rather than silently accepted.
Plaintext exists only for the duration of the response that displays them. It is never written to the session, the database, or a log; the download button builds the file client-side from the codes already on the page, precisely so the server never has to retain them. Revisiting the page shows nothing — there is nothing left to show.
Recovery codes can never be a user's only factor. They're exhaustible, so
counts_as_primary_factor = False: a user holding only recovery codes is offered
them at the picker but is never challenged on their strength alone.
Emailing a one-time code ("email" in MFA_FACTORS, off by default — see
{doc}settings) is the "lost my phone" factor, but it inherits whatever the
mailbox it's sent to is worth as a credential.
It's usually also your password-reset channel. An attacker who already has a victim's password and can reach their inbox — a shared family computer, a mail client left signed in, a compromised email provider — can satisfy both factors through the same channel. Recommend it as a fallback for someone who has lost their authenticator and their recovery codes, not as the factor you steer a high-value account (an administrator, anyone with billing access) toward. TOTP and WebAuthn don't share this property: neither can be satisfied by reading mail.
The enrolled address is fixed. A code is sent to the address captured in
Authenticator.data at enrollment time, not to whatever user.email says right
now. A factor is possession of a specific mailbox; following a mutable profile
field would mean that changing it — by whatever means the host project allows —
silently redirects the factor to a mailbox chosen by whoever changed it. Changing
the enrolled address is therefore a remove-and-re-enroll, not an edit.
MFA_NOTIFY_ON_CHANGE (off by default) emails a user when a factor is added or
removed, when a recovery code is spent, or when their last remaining primary factor
goes. Treat it as a courtesy, not a control:
- Sending is best-effort. A mail-backend failure is logged at
ERRORand dropped, never raised — the security action it's reporting on (removing a key you believe is compromised, say) has already succeeded and must not be rolled back or blocked by a mail outage. A notification not arriving in someone's inbox says nothing about whether the underlying action happened. - Don't build alerting on it. A delivered email is not a durable audit record,
and there is no retry or dead-letter queue behind it. The
django_mfa.eventssignals it's built on (see {doc}api) fire unconditionally, whether or notMFA_NOTIFY_ON_CHANGEis on — connect your own receiver to a logging pipeline or a queue if you need something you can actually alert on.
TOTP secrets are stored in plaintext. WebAuthn stores only a public key, so there is no secret to protect. Recovery codes are hashed, not encrypted, because they never need to be read back.
:::{warning}
MFA_SECRET_ENCRYPTION_KEYS does not encrypt anything, despite the name. It
signs the stored value with django.core.signing, which gives integrity, not
confidentiality — the payload is plain base64 and anyone holding the database can
recover the secret without any key. It is deprecated and kept only so existing values
keep reading. Treat the TOTP secret column as plaintext when deciding who may read
your database.
:::
Any comparison between a submitted value and a stored one goes through
django_mfa.utils.strings_equal, which normalizes and then uses
hmac.compare_digest. There is no == on a secret anywhere in the package.
AuthenticatorAdmin never exposes Authenticator.data — not as a form field, not in
the changelist, not as a search field. That blob holds the TOTP secret, the
recovery-code hashes and the WebAuthn credential, so under a default ModelAdmin any
staff account with view_authenticator could read another user's TOTP secret
(including a superuser's) and generate valid codes for them, and one with
change_authenticator could replace it with a secret of its own choosing.
Adding and editing are disabled outright. Deleting is deliberately still allowed — revoking a lost authenticator for a locked-out user is the one legitimate support operation here, and removing it would push operators into editing the database by hand.
If you register your own ModelAdmin for Authenticator, keep data out of it.
The opaque handle a passkey hands back during passwordless login is a stored random UUID, not a signed derivation of the user's primary key.
That distinction is deliberate and load-bearing. Rotating SECRET_KEY is routine
security hygiene; passkeys are registered once and used for years. A handle derived
from SECRET_KEY would become unresolvable the moment you rotated it — breaking
passwordless login for every user with a passkey, silently, with no sign until they
tried to log in. A stored value survives both key rotation and username changes while
leaking no identity.
The next parameter on verification is validated against the request's host and
scheme before use, falling back to LOGIN_REDIRECT_URL. A verification link cannot
be used to bounce a user to an attacker's site.
Three misconfigurations that would otherwise fail silently in production are Errors
at startup rather than warnings — see System checks.
Stated plainly, so you can decide what else you need:
- Real-time phishing / adversary-in-the-middle for TOTP. A convincing fake login page can collect a password and a TOTP code and replay both immediately. This is inherent to shared-secret OTP, not specific to this package. WebAuthn is the mitigation — credentials are bound to the origin, so a passkey cannot be used on an attacker's domain. If phishing is in your threat model, prefer passkeys and consider not offering TOTP.
- Compromised sessions, for actions django-mfa doesn't know about. Adding,
removing or regenerating a factor already requires a recent challenge, not
merely a verified session — see Step-up re-authentication.
For any other sensitive action of your own, say, changing a password, apply the
same decorator (
mfa_recent_required/MfaRecentRequiredMixin) yourself; a plain verified session is otherwise good for the life of that session. - A compromised server. TOTP secrets are decryptable by your application by definition. Encryption at rest protects against a leaked database dump, not against code execution on your host.
- Malware on the user's device, SIM swapping (there is no SMS factor — that's intentional), or a stolen unlocked phone.
- Enrollment-time identity. django-mfa verifies that whoever is logged in controls the factor; whether that person should have been logged in is your login flow's job.
- Account recovery policy. There is no back door. An administrator deleting a
user's
Authenticatorrows is the recovery path, and verifying identity before doing so is on you.
Before turning this on for real users:
Configuration
-
MFA_EXEMPT_PATHSincludes your logout URL, and you have tested logging out from a half-verified session. -
MFA_FIDO2_RP_IDis your registrable domain, set once, and recorded somewhere as never-to-be-changed. -
manage.py checkis clean, with nothing added toSILENCED_SYSTEM_CHECKSthat you can't justify. -
MFA_ISSUER_NAMEis set, so authenticator apps show your product name.
Infrastructure
- A shared cache backend (Redis, Memcached) — not
LocMemCache— or rate limiting is per-process and your effective limit is multiplied by your worker count. - HTTPS everywhere. WebAuthn requires a secure context, and a session cookie
carrying a verified MFA state deserves
SESSION_COOKIE_SECURE = True. - Server clock synced via NTP. TOTP tolerates one 30-second window either side; drift beyond that rejects correct codes.
Data
- Decide about
MFA_SECRET_ENCRYPTION_KEYS. If you enable it, the keys are in your secret store, and you know that existing plaintext secrets are not retroactively encrypted. - Your database backups are protected at least as well as your password hashes — they now contain second-factor material too.
Process
- A documented recovery procedure for a user with no device and no recovery codes, including how staff verify identity first.
- Your support team knows that deleting an
Authenticatorrow removes the user's second factor entirely. - If you plan to narrow
MFA_FACTORSlater, you know it silently de-protects users enrolled in the removed factor — see the warning in {doc}settings.
Please report security issues privately rather than in a public issue tracker: open a security advisory on the repository.