Skip to content

Security: MicroPyramid/django-mfa

Security

docs/security.md

Security model

What django-mfa defends against, how, and — just as important — what it doesn't.

What it assumes

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.

Controls

No account enumeration

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.

Rate limiting

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, and X-Forwarded-For is ignored unless MFA_CLIENT_IP_RESOLVER says 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, or REMOTE_ADDR is 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 stray cache.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 = False reverses 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(). With LocMemCache and four worker processes each keeps its own counter, so the effective limit is four times what you configured. FileBasedCache implements neither add() nor incr() 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 single UPDATE ... count = count + 1 is 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.

Authenticator app (TOTP)

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.

Cloned-authenticator detection

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.

Recovery codes

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.

Emailed codes

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.

Notifications

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 ERROR and 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.events signals it's built on (see {doc}api) fire unconditionally, whether or not MFA_NOTIFY_ON_CHANGE is on — connect your own receiver to a logging pipeline or a queue if you need something you can actually alert on.

Secrets at rest

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.

Django admin

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.

WebAuthn user handles

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.

Open redirects

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.

Startup checks

Three misconfigurations that would otherwise fail silently in production are Errors at startup rather than warnings — see System checks.

What it does not defend against

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 Authenticator rows is the recovery path, and verifying identity before doing so is on you.

Production checklist

Before turning this on for real users:

Configuration

  • MFA_EXEMPT_PATHS includes your logout URL, and you have tested logging out from a half-verified session.
  • MFA_FIDO2_RP_ID is your registrable domain, set once, and recorded somewhere as never-to-be-changed.
  • manage.py check is clean, with nothing added to SILENCED_SYSTEM_CHECKS that you can't justify.
  • MFA_ISSUER_NAME is 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 Authenticator row removes the user's second factor entirely.
  • If you plan to narrow MFA_FACTORS later, you know it silently de-protects users enrolled in the removed factor — see the warning in {doc}settings.

Reporting a vulnerability

Please report security issues privately rather than in a public issue tracker: open a security advisory on the repository.

There aren't any published security advisories