Drop-in two-factor authentication for Django, presented through Bootstrap modals (via django-nested-modals). It replaces the default admin login and adds passkeys, TOTP, trusted devices, brute-force lockout, email invites, and optional "Sign in with Microsoft".
- Passkeys (WebAuthn / FIDO2) – Windows Hello, Face ID / Touch ID, YubiKeys; a passkey alone satisfies the second factor — no authenticator app required. Users can name, rename and remove their passkeys.
- TOTP – authenticator apps (Google Authenticator, Authy, etc.) with an on-screen QR code and the key for typing in when the code can't be scanned
- Trusted devices – an optional cookie to skip 2FA on a named device (the key rotates on each use, so a copied cookie stops working)
- Lockout – throttle repeated failures by username and by IP (proxy-aware); password reset requests count too
- Security notifications – the account owner is emailed when a passkey, authenticator app or trusted device is added or removed, 2FA is removed or reset, or the password changes
- Admin reset – an administrator can strip a locked-out user's factors so they re-enrol
- Microsoft / Entra sign-in – optional single-tenant SSO, off unless configured
- User onboarding – email invitations to set a password, plus a forgotten-password reset flow
- Customisable – restyle every modal and override behaviour from one class
- Python ≥ 3.8
- Django ≥ 4.2
- Installed automatically:
django-nested-modals,django-otp,qrcode,webauthn
pip install django-modal-2fa
For the optional Microsoft sign-in, install the extra (pulls in msal):
pip install "django-modal-2fa[microsoft]"
from modal_2fa.settings_helper import modal_2fa_apps_admin
INSTALLED_APPS += [
*modal_2fa_apps_admin, # adds django_otp + plugins, modal_2fa, and the 2FA admin
]
OTP_TOTP_ISSUER = 'My App' # label shown in authenticator apps
AUTHENTICATION_BACKENDS = ['modal_2fa.auth.CookieBackend']
LOGIN_URL = '/auth/login/'
LOGOUT_REDIRECT_URL = '/auth/login/'
WEBAUTHN_RP_ID = 'example.com' # your domain; use 'localhost' for development
WEBAUTHN_RP_NAME = 'My App' # optional; shown by the authenticator
Remove
'django.contrib.admin'fromINSTALLED_APPS.modal_2fa_apps_adminswaps in a 2FA-protected admin site in its place. (If you don't use the Django admin, usemodal_2fa_appsinstead, which omits the admin replacement.)
WebAuthn requires a secure context, so register/authenticate over HTTPS in
production (localhost is exempt for development).
from modal_2fa.utils import get_custom_auth
urlpatterns += [
path('', include(get_custom_auth().paths(include_admin=True))),
]
All routes mount under auth/ with the auth namespace — reverse them as
'auth:login', 'auth:auth_2fa', 'auth:user_devices', etc. Pass
include_admin=False to leave out the bundled user-admin and security-admin
modals.
- The user submits credentials to
ModalLoginView, verified byCookieBackend. - If a valid trusted-device cookie is present → log in directly.
- Else if the user has no second factor (neither passkey nor TOTP) and 2FA is optional for them → log in directly.
- Otherwise the username is parked in the session and the 2FA modal opens.
Modal2FAverifies a passkey or a TOTP code, then completes login. A user with no factor yet gets a setup choice screen — set up a passkey (recommended, one click into the browser/OS dialog) or an authenticator app (QR code); either alone completes the login and satisfies 2FA from then on.- The user may optionally name and trust the device to skip 2FA next time.
A passkey registered on a phone still works on another machine: browsers offer cross-device sign-in (a QR code confirmed on the phone), and stored transport hints steer them to the right prompt. Passkey-only users can also add an authenticator app later ("Set up authenticator app" in the user menu) as a device-independent backup.
Managing second factors (passkeys & trusted devices, changing the authenticator
app, removing 2FA) needs a session that proved one: a user signed in by a
trusted-device cookie is asked for their passkey or code first. The accepted
sessions are set by factor_management_methods on CustomiseAuth (default
('2fa', 'microsoft')).
Repeated failures are throttled per username and per IP. Defaults shown:
| Setting | Default | Meaning |
|---|---|---|
AUTHENTICATION_USER_FAILED_ATTEMPTS |
10 |
Failures before a username is locked |
AUTHENTICATION_IP_FAILED_ATTEMPTS |
20 |
Failures before an IP is locked |
AUTHENTICATION_LOCKOUT_SECONDS |
30 |
Lockout duration once the threshold is hit |
Password reset requests count toward both counters (against the IP, and against the account the email names), so the reset form can't be used to flood a mailbox or probe for accounts; a throttled request gets a generic "too many attempts" message whether or not the email matched. Note the login lockout is enforced server-side -- a POST carrying the correct password is refused while locked, not just hidden.
Clear expired rows periodically with the bundled command:
python manage.py clear_failed_logins
IP-based lockout needs the real client IP. Behind a proxy, Django's REMOTE_ADDR
is the proxy's address, so set the number of trusted proxy hops and the client IP
is read (spoof-safely) from X-Forwarded-For:
AUTHENTICATION_TRUSTED_PROXY_COUNT = 1 # single edge proxy, e.g. Traefik / nginx
# AUTHENTICATION_TRUSTED_PROXY_COUNT = 2 # a CDN/LB in front of the proxy, e.g. Cloudflare → Traefik
Default is 0 (no proxy — REMOTE_ADDR is used and X-Forwarded-For is ignored,
since it would otherwise be client-spoofable). BEHIND_REVERSE_PROXY = True is
accepted as a legacy alias for a count of 1.
With include_admin=True, a Security Admin modal is added at
auth:security_admin_modal and linked from the user dropdown for superusers only.
It gives an in-app alternative to the clear_failed_logins command and the Django
admin for two operational tasks:
- Failed login attempts — lists every
FailedLoginAttempt(by username and by IP) with its attempt count and lock status; currently-locked rows are highlighted. Clear removes a row, which both lifts the lockout and resets the counter — use it to free a legitimate user who has locked themselves out. - Active sessions — lists signed-in users decoded from their sessions, with the authentication method and expiry. Sign out deletes the session, ending it immediately — useful for revoking a session you believe is compromised.
Access is gated on the can_manage_security hook, which defaults to
is_superuser — override it if superuser status in your project comes from group
or role membership rather than the flag (see Customisation below). The
active-sessions list reads server-side session rows, so it requires the database
session backend (Django's default); with a cache-only SESSION_ENGINE the list
will be empty.
Add the routes and login button automatically by configuring an Entra (Azure AD)
app registration. The feature stays dormant until all three values are present,
and msal is only imported when used.
MS_CLIENT_ID = '...' # Application (client) ID
MS_TENANT_ID = '...' # Directory (tenant) ID — single-tenant gate
MS_CLIENT_SECRET = '...' # a client secret value — keep this out of source control
# MS_REDIRECT_URI = 'https://example.com/auth/microsoft/redirect' # optional; built from the request if omitted
Sign-in is restricted to the configured tenant; by default only tenant members
are admitted (B2B guests are turned away). A Microsoft sign-in counts as the second
factor only when the ID token proves recent MFA (amr + fresh auth_time);
otherwise the user still completes the normal TOTP/WebAuthn step. All of this is
overridable — see MicrosoftCustomiseMixin (microsoft_allowed, microsoft_user,
microsoft_satisfies_2fa, …).
To make certain users Entra-only (no password login at all), override
password_login_allowed(user) to return False for them. Their password is
rejected at the backend and the login modal shows a message plus the Microsoft
button. The rule is yours to define — e.g. by group:
class MyCustomise(CustomiseAuth):
@staticmethod
def password_login_allowed(user):
return not user.groups.filter(name='sso-only').exists()
Wagtail ships its own login, logout and password-reset views under its admin
prefix, and has no equivalent of the Django admin's 2FA gate. Its login form does
reach this package's backend, but it skips the lockout accounting and the 2FA
step. Setting wagtail_admin_prefix makes paths() emit patterns that shadow
those URLs, and enables a middleware that requires a second factor across the
whole CMS. Wagtail itself is never imported — nothing here runs until the prefix
is set.
# myapp/auth.py
class MyCustomise(CustomiseAuth):
wagtail_admin_prefix = 'cms/' # the prefix wagtailadmin_urls is mounted at
# settings.py
MIDDLEWARE += ['modal_2fa.wagtail_auth.Wagtail2FAMiddleware'] # after AuthenticationMiddleware
WAGTAILADMIN_LOGIN_URL = 'auth:login' # where Wagtail sends anonymous users
WAGTAIL_PASSWORD_RESET_ENABLED = False # hide the now-dead links in Wagtail's own UI
WAGTAIL_PASSWORD_MANAGEMENT_ENABLED = False
Django matches the first pattern that fits, so the include must come above Wagtail's:
urlpatterns = [
path('', include(MyCustomise.paths(include_admin=True))),
path('cms/', include(wagtailadmin_urls)),
]
Misconfiguration fails loudly rather than silently weakening the gate: the
middleware raises ImproperlyConfigured at startup if Wagtail's own login would
win the URL resolution (includes in the wrong order, or paths() not mounted),
and on the first gated request if it is installed before
AuthenticationMiddleware (no request.user).
wagtail_2fa_methods controls what the middleware accepts, defaulting to
('2fa', 'microsoft'). A trusted-device cookie is deliberately not enough for
the CMS, matching how the Django admin is gated; a user with no second factor is
sent to the 2FA modal and enrols there via the setup choice screen (passkey or
authenticator app).
Point AUTHENTICATION_CUSTOMISATION at a subclass of CustomiseAuth:
# settings.py
AUTHENTICATION_CUSTOMISATION = 'myapp.auth.MyCustomise'
# myapp/auth.py
from modal_2fa.customise import CustomiseAuth
class MyCustomise(CustomiseAuth):
@staticmethod
def user_2fa_optional(user):
return not user.is_staff # force 2FA for staff
@staticmethod
def allowed_remember(user):
return True # offer "remember this device"?
@staticmethod
def max_cookies(user):
return 2 # trusted devices per user
@staticmethod
def customise_view(view):
view.size = 'md' # restyle any auth modal
Common hooks: user_2fa_optional, password_login_allowed, allow_passkeys /
allow_totp (which second factors are offered — disable one and the setup screen
offers only the other; disallowing passkeys also stops a user's existing passkeys
signing them in, while an existing authenticator app keeps working),
passkey_user_verification / passkey_resident_key
(both default 'discouraged' so hardware keys such as YubiKeys stay touch-only —
stronger values make browsers demand or force a PIN on the key; raise them only
for a future passwordless flow), allowed_remember, max_cookies,
can_manage_users, can_manage_security, customise_view, override_views
(swap any URL→view mapping), and the email template attributes for invitations
and password resets.
Every change to a user's sign-in security emails the account owner: a passkey, authenticator app or trusted device added or removed, 2FA removed (by the user) or reset (by an administrator), and a password change. Verifying an existing factor is not a change and sends nothing. The email names the event, the account, the time, the IP and the browser, and says what to do if it wasn't them.
Set security_notifications = False on your CustomiseAuth subclass to turn
them off, change security_notification_txt_email_template /
security_notification_subject_template (and the optional HTML
security_notification_email_template) to restyle them, edit the
security_events dict to reword an event, or override
notify_security_change(request, user, event, name=None) to log, raise a
signal or route them elsewhere. A failure to send is logged under the
modal_2fa logger and never undoes the change it reports.
user_2fa_optional asks whether 2FA is optional for a user. When the auth views
cover only part of a larger project — a control panel on one host, or one schema of
a multi-tenant app — the other question is whether a given request is even in the
area 2FA protects. two_factor_enabled(request) answers that:
class MyCustomise(CustomiseAuth):
@staticmethod
def two_factor_enabled(request):
return request.get_host() == 'admin.example.com'
Returning False makes CookieBackend behave as a plain ModelBackend for that
request — no trusted-device or device lookup, and no part-login parked in the
session for auth views that aren't routed there to consume. Without it such a
project has to leave ModelBackend behind CookieBackend as a fall-through, and
then defend against that fall-through wherever 2FA is required.
Two hooks gate the admin surfaces. Unlike the hooks above they take
(user, request=None) — request is passed whenever the caller has one, for
permission systems that depend on session or site state:
can_manage_users(user, request=None)— user create/edit/view, the user admin table, email invites, and Reset 2FA (strips a user's authenticator app, passkeys and trusted devices and clears their lockout, so they go through first-time 2FA setup at their next sign-in; the user is emailed). Defaults to thechangepermission on your own user model, derived fromget_user_model()— so it is correct under a swappedAUTH_USER_MODEL(e.g.myapp.change_usermodel, notauth.change_user).can_manage_security(user, request=None)— the Security Admin modal. Defaults tois_superuser.
The defaults rely on has_perm(), which resolves group permissions through
Django's built-in user.groups M2M. If your project uses its own membership
model, override both:
class MyCustomise(CustomiseAuth):
@staticmethod
def can_manage_users(user, request=None):
if request is None:
return False # no session context — fail closed
return user_in_group(request, 'user-admins')
@staticmethod
def can_manage_security(user, request=None):
return MyCustomise.can_manage_users(user, request)
Inject the signed-in-user dropdown (2FA management, passkeys & trusted devices, logout) into any view's menu:
from modal_2fa.menus import add_auth_menu
def setup_menu(self):
super().setup_menu()
add_auth_menu(self)
A Dockerised demo project lives in django_examples/:
docker-compose up # Django on :8010 + Redis + Celery
docker-compose exec django_2fa python manage.py migrate
docker-compose exec django_2fa python manage.py test
For HTTPS (needed to exercise WebAuthn locally), uncomment the runserver_plus
line in docker-compose.yaml and supply cert.pem / key.pem.
MIT — see LICENSE.
