Skip to main content

django-mfa

Passkeys, security keys, and authenticator apps for Django.
Add one app, one middleware, and one URL include — your users get a second factor,
and you never touch your login view.

PyPI CI Python versions Django versions Docs License


Most Django projects get multi-factor authentication as a to-do item that never quite gets done, because the usual starting point is a low-level framework and a weekend of writing enrollment views, challenge screens, recovery flows, and rate limiting.

django-mfa is the other end of that trade: a finished second-factor feature you mount under a URL prefix. Enrollment pages, challenge pages, recovery codes, the picker for users with more than one method, the middleware that actually enforces it — all included, all overridable.

INSTALLED_APPS += ["django_mfa"]
MIDDLEWARE += ["django_mfa.middleware.MfaMiddleware"]
urlpatterns += [path("mfa/", include("django_mfa.urls"))]

That's a working second factor. Your login view doesn't change — django-mfa listens for Django's own user_logged_in signal.

What your users get

🔑 Passkeys & security keys WebAuthn/FIDO2 — Touch ID, Windows Hello, Face ID, YubiKey. Usable as a second factor or for full passwordless login, with no username typed.
📱 Authenticator apps Standard TOTP (RFC 6238) — Google Authenticator, 1Password, Aegis, anything. QR code rendered server-side as inline SVG; no third-party service ever sees your users' secrets.
🧾 Recovery codes Ten single-use codes, hashed at rest, shown exactly once. The answer to "I lost my phone" that isn't a support ticket.
🖥️ Remember this browser Optional, off by default. Trust a browser for N days after one successful challenge.
Several keys at once A user can register a work laptop's Touch ID and a backup YubiKey, each with its own name.

Install

pip install django-mfa      # or: uv add django-mfa
python manage.py migrate

Upgrading from 2.x or 3.x? Read the upgrade notes first — several changes are breaking, and one migration is deliberately irreversible.

Quick start

1. Add the app and the middleware. The middleware goes after AuthenticationMiddleware — it needs request.user.

INSTALLED_APPS = [
    ...,
    "django_mfa",
]

MIDDLEWARE = [
    ...,
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django_mfa.middleware.MfaMiddleware",
]

2. Mount the URLs anywhere you like. The mfa namespace is baked into the pattern list, so don't pass namespace=:

urlpatterns = [
    ...,
    path("mfa/", include("django_mfa.urls")),
]

3. Exempt your logout URL. django-mfa can't discover it, and a user who can't complete their second factor needs a way out:

MFA_EXEMPT_PATHS = ["/logout/"]

4. For passkeys and security keys, name your relying party and add the backend:

MFA_FIDO2_RP_ID = "example.com"     # set once — changing it invalidates every credential

AUTHENTICATION_BACKENDS = [
    "django_mfa.backends.WebAuthnBackend",
    "django.contrib.auth.backends.ModelBackend",
]

Get either of those wrong and manage.py check says so at startup, by design — see system checks below. Want TOTP only? Set MFA_FACTORS = ["totp", "recovery_codes"] and skip step 4 entirely; the WebAuthn checks switch themselves off.

Then send users to /mfa/security/. That page lists what they have, what they can add, and how many recovery codes are left.

How it fits into your project

Your login view stays exactly as it is. Whether you use django.contrib.auth's built-in view, allauth, or your own SSO handler, all django-mfa needs is that login() gets called. A user_logged_in receiver marks the session pending, and the middleware takes it from there.

Users without a second factor are never blocked. Someone with no factor enrolled logs in exactly as before. Enforcement applies only to users who actually have one, so you can roll MFA out gradually instead of on a flag day.

The screens are yours. Every page extends MFA_BASE_TEMPLATE, so pointing that at your own base template is usually all the theming you need. Want more? Shadow any template under django_mfa/ in your own app.

URL What it is
mfa:security_settings Overview — methods enabled, methods available, recovery codes remaining
mfa:enroll_factor Enroll a method (TOTP QR code, or a WebAuthn registration ceremony)
mfa:verify The picker, shown at login when a user holds more than one method
mfa:verify_factor The challenge screen for one method
mfa:recovery_codes Generate and display recovery codes
mfa:passkey_begin / mfa:passkey_complete Passwordless login endpoints

A user with exactly one method never sees the picker — they're redirected straight to their challenge.

Security, in detail

The parts that are easy to get subtly wrong, done deliberately:

  • No account enumeration. Every failure on the passwordless path — unknown user handle, unknown credential, bad signature, expired ceremony, tampered payload — returns one identical generic response.
  • Rate limiting that isn't an oracle. Failed attempts are capped per user per factor (MFA_VERIFY_RATE_LIMIT, default 5 per 5 minutes). A locked-out attempt returns the same response as a wrong code, so the lockout itself leaks nothing. The counter lives in the cache with no database fallback, so it fails open rather than locking everyone out — a secondary control shouldn't be able to take your site down.
  • Cloned-authenticator detection. WebAuthn signature counters are checked on every assertion, with an explicit carve-out for authenticators that legitimately never implement one (iCloud passkeys always report 0).
  • Recovery codes are hashed with Django's password hasher, marked used individually, and displayed exactly once.
  • Encryption at rest for TOTP secrets, opt-in via MFA_SECRET_ENCRYPTION_KEYS — a list, because the first key encrypts and every key is tried on decrypt. That's what makes key rotation a redeploy instead of a migration.
  • Recovery codes can never be someone's only factor. They're exhaustible, so they don't count toward "is this user protected" — one source of truth in the registry, not a rule re-implemented in three places.
  • Timing-safe comparison everywhere a submitted code meets a stored one.

It tells you when you've misconfigured it

Three system checks run on manage.py check (and therefore on migrate and runserver), because each one guards a failure that is otherwise silent in production:

Check Fires when
django_mfa.E001 MFA_FIDO2_RP_ID is unset
django_mfa.E002 MFA_FIDO2_RP_ID doesn't match any ALLOWED_HOSTS entry
django_mfa.E003 WebAuthnBackend is missing from AUTHENTICATION_BACKENDS

E003 is the instructive one. Passwordless login calls login() with an explicit backend=, which succeeds no matter what AUTHENTICATION_BACKENDS says. One request later, Django re-checks that backend, doesn't find it, and quietly resolves request.user to AnonymousUser — no exception, no log line, just a user who was logged in a moment ago and isn't anymore. Catching that at startup costs nothing; catching it in production costs a support ticket.

Adding your own factor

Factors are pluggable. Each one is an Adapter subclass registered into a single registry — the views and URLs are generic and dispatch to whatever's registered, so a new factor means no new views and no new URLs:

class Adapter:
    def begin_enroll(self, request): ...            # → template context
    def complete_enroll(self, request, data): ...   # ← the POSTed payload
    def begin_verify(self, request, user): ...
    def complete_verify(self, request, user, data): ...

Add enroll_<type>.html and verify_<type>.html, register the adapter, and it appears in the security page, the picker, and the middleware's exempt set automatically. The three built-ins (totp, webauthn, recovery_codes) are written against this same API — there's no privileged path.

Compatibility

Python 3.10 · 3.11 · 3.12 · 3.13
Django 4.2 LTS · 5.2 LTS
Database Anything Django supports (state is a JSONField)
Dependencies fido2, qrcode. TOTP is implemented in-package, not pulled in.

Every combination in that grid runs the full suite in CI, along with a job that builds the wheel, installs it into a clean environment, and starts Django against it from outside the source tree.

Documentation

A runnable demo project lives in sandbox/.

Contributing

Issues and pull requests are welcome — open a ticket for bugs or feature ideas.

git clone https://github.com/MicroPyramid/django-mfa
cd django-mfa
uv run python test_runner.py     # the whole suite
uv run ruff check .

License

MIT. Built and maintained by MicroPyramid.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_mfa-4.0.1.tar.gz (146.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_mfa-4.0.1-py3-none-any.whl (146.3 kB view details)

Uploaded Python 3

File details

Details for the file django_mfa-4.0.1.tar.gz.

File metadata

  • Download URL: django_mfa-4.0.1.tar.gz
  • Upload date:
  • Size: 146.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_mfa-4.0.1.tar.gz
Algorithm Hash digest
SHA256 e78aef6404e2ca015f1e97a1ccf1d2914f609be28723887b070026d643389165
MD5 125aa916c9d66f84c7d58965a77ef563
BLAKE2b-256 72709daba7d2389d1e2f0b7cf8bcf2ab5f49a0a417b066e7c3ee0377a10f2227

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_mfa-4.0.1.tar.gz:

Publisher: publish.yml on MicroPyramid/django-mfa

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_mfa-4.0.1-py3-none-any.whl.

File metadata

  • Download URL: django_mfa-4.0.1-py3-none-any.whl
  • Upload date:
  • Size: 146.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for django_mfa-4.0.1-py3-none-any.whl
Algorithm Hash digest
SHA256 70e9f42c17081b1ed13f5757598343b0b4d2ac08e7e64e010ba981e11b512bd0
MD5 9d696eda617b20540dccbfac18d91c0e
BLAKE2b-256 b7b758abb5f41999fdfa323f05dc26e824f5a9186ae73ca258a16d9756fe446b

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_mfa-4.0.1-py3-none-any.whl:

Publisher: publish.yml on MicroPyramid/django-mfa

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

4.6.0

2 files

4.5.0

2 files

4.4.0

2 files

4.3.0

2 files

4.2.0

2 files

4.1.0

2 files

This release

4.0.1 This release

2 files

4.0.0

2 files

3.2

2 files

2.2

2 files

2.1

2 files

2.0

1 file

1.2

1 file

1.1

1 file

1.0

1 file

0.0.10

2 files

0.0.9

2 files

0.0.8

2 files

0.0.7

2 files

0.0.6

2 files

0.0.5

1 file

0.0.4

1 file

0.0.3

1 file

0.0.2

1 file

0.0.1

1 file

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page