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.
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. |
| โ๏ธ Emailed codes | Opt-in ("email" in MFA_FACTORS): a one-time code sent to the address on file, for a user who's lost everything else. Not in the default factor list โ an existing install has to opt in. |
| ๐ฅ๏ธ 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. |
| ๐ Six languages | German, Spanish, French, Brazilian Portuguese, Japanese and Simplified Chinese ship translated. Switch on USE_I18N and they work. |
| ๐ A JSON API | Opt-in. Every flow above as JSON, for an SPA or mobile client that renders its own screens. No DRF dependency, and a revocable session token for clients that hold no cookie. |
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, unless you ask for it. Someone
with no factor enrolled logs in exactly as before, so you can roll MFA out gradually
instead of on a flag day. Want to require it instead โ for everyone, for staff, for
one group โ set MFA_REQUIRED; a required user with no factor is walled to the
security page until they enroll one. See
Enforcing MFA.
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) and per client address across every account (MFA_VERIFY_IP_RATE_LIMIT, default 50 per 5 minutes) โ the second catches one guess sprayed at ten thousand accounts, which the first cannot see. A locked-out attempt returns the same response as a wrong code, so the lockout itself leaks nothing. Counters are rows by default, so a cache restart can't quietly hand an attacker a fresh budget, and the limiter fails open if its store is unreachable โ 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
Four 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 |
django_mfa.E004 |
MFA_REQUIRED is a dotted path that fails to import, or resolves to something that isn't callable |
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
four built-ins (totp, webauthn, recovery_codes, email) 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 ยท 6.1 |
| Database | Anything Django supports (state is a JSONField) |
| Dependencies | fido2, qrcode. TOTP is implemented in-package, not pulled in. |
Every combination runs the full suite in CI โ except Django 6.1 on Python 3.10 or 3.11, which Django itself doesn't support (6.x requires Python 3.12+). Alongside it, a job builds the wheel, installs it into a clean environment, and starts Django against it from outside the source tree.
Documentation
- Getting started โ install and wire it up in five minutes
- Settings reference โ every setting, its default, and what it does
- JSON API โ endpoints, error codes, and what it needs from your client
- Translations โ the six shipped languages, and how to fix or add one
- Customizing the UI โ templates, context, and the WebAuthn JS contract
- Enforcing MFA โ requiring it for some or all users, and per-view enforcement
- Integration recipes โ allauth, passkey buttons, APIs, testing, troubleshooting
- Writing a custom factor โ the Adapter API, with a worked example
- Security model โ controls, non-goals, and a production checklist
- Flow and URLs โ what happens on enroll, on login, and on passwordless login
- Upgrade notes โ read before upgrading a project with real users
- Contributing
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.