Notable changes to django-mfa, newest first.
This file starts at 4.1.0. For the 2.x/3.x → 4.0 rewrite — which was a ground-up rebuild with breaking changes to models, URLs, session keys and settings — see docs/upgrading.md; it is far more than a changelog entry could carry. Releases before 4.1.0 are on the GitHub releases page.
Versions follow PEP 440. The version in
pyproject.toml is the only place it is written; the git tag and the GitHub
Release are derived from it (see docs/contributing.md).
- A rollout ramp for
MFA_REQUIRED, for a project turning it on against users who already exist rather than a fresh install.MFA_REQUIRED_FROM(adate/datetimebefore which nobody is walled byMFA_REQUIRED, however it answers) andMFA_GRACE_PERIOD(anintnumber of days, or atimedelta, each user gets from their own anchor) combine so that a user is required from whichever ofMFA_REQUIRED_FROMandanchor + MFA_GRACE_PERIODis later — an existing account is governed by the announced cutover, while someone who signs up after it still gets their full window.MFA_GRACE_ANCHORsupplies a per-user anchor other thanuser.date_joined(a dotted path or callable,(user) -> datetime | None) for a clockdate_joinedcan't express, such as a migration cohort.policy.required_at(user)andpolicy.grace_state(user)(aGraceState(required_at, days_remaining), display-only) are the public entry points. Grace suppressesMFA_REQUIREDonly — it does not open@mfa_required/MfaRequiredMixinviews, exactly like anMfaExemption, and it does not open theMFA_PROTECT_ADMINgate either: that gate is enforced throughdecorators.enforcement_state(), which never consultsdjango_mfa.policyat all. See docs/enforcement.md. - Grace surfaced everywhere the enrollment wall already is: a
gracekey insecurity_settings's context, the opt-indjango_mfa.context_processors.mfatemplate context processor (mfa_grace), the JSON API'sstateendpoint (gracefield), andmfa_status's new "In grace until" line.mfa_reportnow also lists users currently in grace — see the Changed entry below for its CSV output. MFA_PROTECT_ADMIN(defaultFalse). WhenTrue, no page on the default admin site (django.contrib.admin.site) is reachable without a verified session — enforced bydjango_mfa.admin_site.protect_admin_site()wrapping that site's ownhas_permission()/login()in place, so it holds even on a project that never installedMfaMiddleware. A project mounting its ownAdminSiteinstance gets no protection from the setting alone;django_mfa.admin_site.MfaAdminMixincovers that case. It also unionsis_staffintopolicy.resolve()'s predicate, so staff become subject toMFA_REQUIREDwithout a second setting — without rewritingMFA_REQUIREDitself.MFA_ADMIN_STEPUP(defaultFalse) additionally requires a challenge withinMFA_STEPUP_MAX_AGE, not merely a verified session, onceMFA_PROTECT_ADMINis on. See docs/enforcement.md.- System checks
django_mfa.E010(grace is configured but cannot apply —MFA_REQUIRED_FROMisn't a date,MFA_GRACE_PERIODis negative or not a number, or it's set with no usable anchor) anddjango_mfa.E011(MFA_ADMIN_STEPUPset withoutMFA_PROTECT_ADMIN, so nothing reads it and the admin stays unprotected). decorators.enforcement_redirect(request, require_primary_factor=True, next_url=None)is now public — it renders anenforcement_state()rung as a redirect, anddjango_mfa.admin_sitecalls it directly to reuse the same two destinationsMfaMiddlewareand@mfa_requiredalready redirect to. The previous private name,_enforce, remains as a back-compat alias.
mfa_report --format csvgains agrace_untilcolumn, appended after the existing ones (so indexing by position still works for those). Users inside a grace window are now listed bymfa_report; previously — before grace existed — there was no such state.
No new migration — this release adds no models.
-
MFA session tokens, for clients that cannot hold a cookie.
POSTto the newsession/endpoint returns a token; present it in theX-MFA-Sessionheader and a verification completed on one request is still in force on the next. This is what 4.4.0's JSON API was missing — its own docs said a cookie-less client "can call every endpoint and will still never get anywhere".The token is a Django session key, deliberately: it is revocable (
DELETE session/), expires withSESSION_COOKIE_AGE, is cleaned up byclearsessions, and leavesdjango_mfa/session.pyas the only module that touches MFA state. It is bound to the account it was issued to, so a token presented alongside a different identity is refused rather than inherited. A request carrying no cookie at all is CSRF-exempt — nothing ambient for a cross-site page to forge — while any request with a cookie keeps full enforcement.SESSION_ENGINE = "...signed_cookies"cannot issue one and says so with a 501. See docs/rest_api.md. -
A per-IP verification budget,
MFA_VERIFY_IP_RATE_LIMIT, defaulting to"50/5m".MFA_VERIFY_RATE_LIMITbudgets guesses per account, which an attacker holding a list of stolen passwords routes around entirely: one guess against each of ten thousand accounts leaves every counter at 1 and none of them ever binding. A successful verification clears the user's counter and deliberately not the IP's. -
MFA_CLIENT_IP_RESOLVER(defaultNone→REMOTE_ADDR).X-Forwarded-Foris not read unless you point this at a resolver that does, because a client-set header breaks the budget in both directions — an attacker who varies it is never throttled, one who forges your office's address locks your staff out. Set this if you run behind a proxy. -
MFA_RATE_LIMIT_BACKEND(default"database") andMFA_RATE_LIMIT_FAIL_OPEN(defaultTrue). See below. -
manage.py mfa_prune, deleting expired rate-limit counters. Schedule it besidedjango-admin clearsessions. -
System checks
django_mfa.E007–E009, rejecting a malformed rate-limit spec, an unknown rate-limit backend, and an unimportable or non-callableMFA_CLIENT_IP_RESOLVER. Without them each of those surfaces from inside the first verification attempt after a deploy — a 500 on the challenge page for whichever user logs in first.
- Rate-limit counters now live in a database table by default
(
RateLimitCounter, migration0010), not the cache. A cache-only counter is erased by a Redis restart, an eviction under memory pressure, or acache.clear()in an unrelated deploy step, and every erasure silently hands an attacker mid-run a fresh budget with no trace. The cost is one row read per verification attempt and one write per failed one. SetMFA_RATE_LIMIT_BACKEND = "cache"for the previous behaviour; it needs no migration and no pruning. - A session that has never been challenged no longer counts as verified.
decorators.enforcement_state()treats "holds a primary factor, and this session carries no MFA stamp at all" asPENDING. Previouslysession.is_pending()— which means stamped, not yet passed — was False for such a session and the request fell through as though it had passed. See docs/upgrading.md for who this affects; the ordinary login path is unchanged, becauseuser_logged_instamps every session it creates.
- Two rate-limit tests were passing spuriously: they patched
ratelimit._db_get/_cache_get, which the backend table binds at import, so the "broken store" they simulated was in fact a healthy one. They now break the store at the ORM and cache boundary.
-
A JSON API, opt-in via a separate URL include:
path("api/mfa/", include("django_mfa.api.urls"))Every flow the HTML views offer — state, enroll, verify, recovery codes, factor removal, passwordless sign-in — as JSON, for an SPA or mobile client that renders its own screens. No new dependency: plain Django views, so it works inside a DRF, django-ninja or plain-Django project alike. Session authentication by default;
MFA_API_AUTHENTICATION(new setting, defaultNone) supplies a hook for token or JWT clients. Note that MFA state remains session-backed, so a client must persist the session cookie — see docs/rest_api.md, which is explicit about what that rules out. -
System check
django_mfa.E006, rejecting an unimportable or non-callableMFA_API_AUTHENTICATION, the wayE004already does forMFA_REQUIRED. -
Passkey autofill (WebAuthn conditional mediation), opt-in per form with
data-conditional="true"plusautocomplete="username webauthn"on your username input. Offers a returning user their passkey from the browser's own dropdown instead of behind a button. Off by default because it movesmfa:passkey_beginto once per login-page view for every anonymous visitor, and that endpoint writes a session — see docs/recipes.md. -
tools/compile_catalogs.py, which refreshes catalog source references and compiles every.mo. It ismakemessages+msgfmtin pure Python, because gettext's binaries are not a dependency this project imposes — including on its own CI. -
The sandbox login page now demonstrates passkey sign-in, including autofill. It previously demonstrated neither.
- The six translations are now live.
de,es,fr,pt_BR,jaandzh_Hansshipped in 4.3.0 with every entry markedfuzzy, which meant users still saw English. Every entry is now translated and unfuzzed, and compiled.mofiles ship — Django reads only those, so without them the catalogs did nothing. They remain machine-drafted and maintainer-reviewed rather than reviewed by a native speaker; corrections are welcome. See docs/translations.md. - The order of operations for an enrollment or verification attempt moved
to
django_mfa.flows, and the enforcement rungs todecorators.enforcement_state/recent_enforcement_state. Both are shared verbatim by the HTML views and the API, so the two cannot come to apply different rules. No behaviour change — this is why the HTML views are shorter in this release.
"Remove"was rendering indjango.contrib.admin's words, not ours, in every language admin translates. gettext keys on the string itself and Django merges all installed apps' catalogs, with the app listed first inINSTALLED_APPSwinning a shared key — and admin is listed first in nearly every project. The button now carries acontext "second-factor method", which makes the key ours alone, and a test fails on any bare msgid a bundled Django app also translates.- The "managed by your organization" message shown when
MFA_OWNED_BY_ENTERPRISEblocks a removal was the one user-facing string never wrapped for translation.
-
Translation catalogs.
django_mfa/locale/now shipsdjango.pot(75 entries) and machine-drafted.pofiles forde,es,fr,pt_BR,jaandzh_Hans. Every entry is markedfuzzy, so gettext ignores it and users still see English: no language is live yet, and a draft only starts appearing after a human reviews it and removes the flags. See docs/translations.md. No compiled.mofiles ship, because a fully fuzzy catalog compiles to an empty one. -
The Python side is now translatable, matching the templates (which already were): the verification error, the passkey sign-in error, each adapter's
verbose_name, andAuthenticator.Type's labels. Wrapping theTypelabels needs no migration — agettext_lazyproxy compares equal to the string it wraps, so the autodetector sees no change tochoices(verified on Django 4.2, 5.2 and 6.1). -
Django 6.1 support, now claimed in the classifiers and exercised in CI on Python 3.12 and 3.13. Django 6.x requires Python 3.12+, so the matrix excludes it on 3.10/3.11; those interpreters keep Django 4.2 and 5.2, both still LTS. No source change was needed — the suite already passed on 6.x. (Django 6.0 passes too, but is not claimed or tested.)
- The PyPI classifier is now
Development Status :: 5 - Production/Stable, up from4 - Beta. publish.yml's pre-release smoke matrix now tests the newest supported corner as Python 3.13 + Django 6.1, up from 3.13 + 5.2. The oldest corner (3.10 + 4.2) is unchanged.
.gitignore's blanket*.pot/*.morules excluded the package's own catalogs. Because hatchling honours.gitignoreat build time, an ignored catalog is also an unshipped one — Django would find no locale directory in the installed package and silently fall back to English. Negations now keepdjango_mfa/locale/tracked, andtest_packaging.pyasserts the catalogs are in the built wheel.
- Step-up re-authentication.
mfa_recent_required/MfaRecentRequiredMixin(django_mfa.decorators) andMFA_STEPUP_MAX_AGE(default300seconds) require a recent challenge, not merely a verified session, before a factor can be added, removed or regenerated. SetMFA_STEPUP_MAX_AGE = Noneto switch it off for django-mfa's own three built-in views (they never pass their ownmax_age, so they fall back to the setting) and restore 4.1.0 behaviour there. It does not override a host view's own explicit@mfa_recent_required(max_age=60)— an explicit per-viewmax_agealways wins over the global setting, by design. See docs/enforcement.md. - Four management commands for day-to-day operation:
mfa_status(read-only — one user's enrolled factors and MFA status),mfa_reset(remove every factor from a locked-out user so they can re-enroll),mfa_report(rollout coverage, and whoMFA_REQUIREDapplies to but who hasn't enrolled — text or CSV), andmfa_disable(grant or--revokeanMfaExemptionfromMFA_REQUIREDfor one user; does not touch that user's enrolled factors). See docs/operations.md. - Two importers for migrating factors in from another package:
mfa_import_django_otp(also covers django-two-factor-auth, which stores its TOTP and static tokens as django-otp rows) andmfa_import_django_mfa2. Both support--dry-run,--users, and--overwrite, are idempotent, and never destroy a working factor unless--overwriteis passed. See docs/operations.md for what each does and does not migrate — several factor shapes (a clock-drifted django-otp TOTP device, django-mfa2's wider acceptance window,RECOVERYrows, and any factor type absent fromMFA_FACTORS) are reported rather than imported, and are worth reading before a cutover. MfaExemptionmodel and manager (MfaExemption.objects.active_for()), and themfa_exemption_changedsignal (user,reason,expires_at,revoked,request) it fires. Written only bymfa_disable— there is no web UI for granting yourself an exemption from a security requirement.- System check
django_mfa.E005, rejecting anMFA_STEPUP_MAX_AGEthat isn't a positive integer orNone, the same wayE004already does forMFA_REQUIRED.
- Behaviour change. Adding, removing or regenerating a factor now
requires a session that completed a challenge within the last
MFA_STEPUP_MAX_AGEseconds (default 300), not merely a verified one. SetMFA_STEPUP_MAX_AGE = Noneto restore 4.1.0 behaviour for django-mfa's own views (see the Added entry above for the one case this doesn't cover). This is the one place this release does not upgrade to byte-identical behaviour by default — see docs/upgrading.md. MFA_REMEMBER_MY_BROWSERnow interacts with step-up. A trusted browser still skips the challenge at login exactly as before — the RMB cookie check marks the session verified immediately — but that session is only fresh the moment it's created.MFA_STEPUP_MAX_AGEis enforced on every factor change regardless of how the session became verified, so a trusted browser that adds, removes or regenerates a factor more thanMFA_STEPUP_MAX_AGEseconds after logging in is now challenged for that action — the RMB cookie is consulted only at login, not re-checked by the step-up gate. This is a visible change for installs that enabled RMB specifically to avoid challenges. See docs/settings.md.- Signals may now carry
request=None.mfa_resetandmfa_disableemitfactor_removed/mfa_exemption_changedfrom outside any request, so that an operator action is exactly as auditable as the equivalent user-initiated one. A receiver that reaches forrequest.METAunconditionally must be updated to tolerateNonefirst — see docs/api.md's Signals section. - The verification picker now honours
?next=, so a single-factor user is returned to the page they requested after logging in rather than toLOGIN_REDIRECT_URL.
Run manage.py migrate django_mfa. Migration 0009_mfa_exemption adds the
MfaExemption table; it is reversible.
Nothing else is required to keep 4.1.0 behaviour, with one exception: factor
changes are gated on MFA_STEPUP_MAX_AGE by default (see above). Set it to
None if you need the previous, unconditional behaviour immediately after
upgrading.
Three additions, all opt-in. An install that sets none of the new settings behaves identically to 4.0.1 — the only required step is running the new migration.
MFA_REQUIRED— require a second factor. Until now enrollment was entirely voluntary: the middleware only challenged users who had already enrolled, so anyone who never opted in was never prompted, and there was no way to require MFA of staff. AcceptsFalse(default),True, a callable taking a user, or a dotted path to one;django_mfa.policysuppliesis_staffandin_groups(*names). A required user holding no primary factor is walled to the security page — only the enroll pages, recovery codes andMFA_EXEMPT_PATHSstay reachable — until they enroll. See docs/enforcement.md.@mfa_requiredandMfaRequiredMixin(django_mfa.decorators) for per-view enforcement regardless ofMFA_REQUIRED. The setting picks users, the decorator picks views, and neither can express the other.- System check
django_mfa.E004, rejecting an unimportable or non-callableMFA_REQUIREDatmanage.py checkrather than from inside middleware on a user's first live request. Unlike E001–E003 it is not gated on WebAuthn being active. - An emailed one-time-code factor (
"email") — the only built-in that does not assume the user still holds a device they enrolled earlier, which makes it the lost-phone path. Not in theMFA_FACTORSdefault: add it explicitly, so that upgrading cannot silently acquire a factor that sends mail through a backend this package does not control. New settingsMFA_EMAIL_CODE_LENGTH(6),MFA_EMAIL_CODE_VALIDITY(300s),MFA_EMAIL_SEND_RATE_LIMIT("3/5m"),MFA_EMAIL_SUBJECT, andMFA_FROM_EMAIL. - Five signals —
factor_added,factor_removed,mfa_verified,mfa_verification_failed,recovery_code_used— importable fromdjango_mfa.signals, always on. All sent withsend_robust(), so a raising receiver cannot break a security action such as removing a compromised key.mfa_verification_failedalso fires for attempts the rate limiter refuses: a brute-force detector needs the refused attempts, not only the evaluated ones. See docs/api.md. MFA_NOTIFY_ON_CHANGE(defaultFalse) — emails the user when a factor is added or removed, when a recovery code is spent, and when their last factor goes. Sending is synchronous and best-effort: a failure is logged, never raised, because a mail outage must not turn "remove this key I think is compromised" into a 500. For async delivery or non-email routing, connect your own receiver to the signals above and leave this off — that is why the signals ship independently of the emails.Registry.has_primary_factor(user)— the boolean form ofprimary_enabled_for()in one query instead of one per registered adapter. Both derive fromAdapter.counts_as_primary_factor, so they cannot disagree.- New documentation page, Enforcement.
Adapter.complete_enroll()must return the createdAuthenticator. This was always true of the built-ins, but it is now a documented contract: thefactor_addedsignal carries the return value, so a custom adapter returningNonesilently degrades every host project's audit trail for that factor type.ratelimit.parse/check/record_failuregained asetting=keyword argument so a second budget (emailed-code sends) can be counted against its own setting. Existing positional calls are unaffected;record_failureis now an alias of the more generalrecord.docs/custom_factors.md's worked example is now a printed-backup-token factor. Its previous example was an emailed-code factor, which now ships as a built-in, so the page had begun documenting how to reimplement something the package provides.
Run manage.py migrate django_mfa. Migration 0008_email_factor adds the
email factor type and extends the mfa_one_singleton_authenticator_per_user
constraint to cover it — a singleton factor missing from that condition would
not actually be constrained. Unlike 0007, it is reversible.
Nothing else is required. MFA_REQUIRED, MFA_NOTIFY_ON_CHANGE and the
absence of "email" from MFA_FACTORS's default are what keep existing
behaviour unchanged.