Multi-Factor Authentication in Django with django-otp and TOTP

Blog / MongoDB · December 29, 2022 · Updated June 10, 2026 · 7 min read
Multi-Factor Authentication in Django with django-otp and TOTP

Multi-factor authentication (MFA) adds a second proof of identity on top of a password, so a stolen or guessed password is no longer enough to take over an account. In Django the practical way to add MFA in 2026 is the actively maintained django-otp framework (the low-level building block) together with django-two-factor-auth (a complete, ready-made 2FA flow built on top of it).

Heads-up on the original django-mfa package: the standalone django-mfa app this post first demonstrated is effectively unmaintained — it has had no meaningful releases in years and targets old Django/Python. Do not use it for new projects. This guide keeps the same goal (time-based one-time-password MFA scanned from an authenticator app) but uses the maintained, standard tooling and current Django 5.x / Python 3.10+ patterns.

What MFA is and why it matters

A password is a single factor: something you know. MFA combines it with a second factor — usually something you have (a phone running an authenticator app, or a hardware security key). Even if an attacker dumps your password database or phishes a single credential, they still cannot authenticate without that second factor.

The most common second factor for web apps is a TOTP (Time-based One-Time Password, RFC 6238): a 6-digit code your authenticator app derives from a shared secret plus the current time. It works offline and is supported by every major authenticator app.

Which Django MFA library should you use?

Package What it is Maintained in 2026? Reach for it when
django-mfa The standalone app this post originally used No — effectively abandoned Never, for new work
django-otp The de-facto low-level framework: TOTP/HOTP/static-token device models, OTPMiddleware, @otp_required Yes — Jazzband, active You want full control of the UI and flow
django-two-factor-auth Batteries-included views, templates and admin integration built on django-otp Yes — Jazzband, active You want a complete 2FA flow fast
django-mfa2 A separate, maintained package (not the same project as django-mfa): TOTP plus FIDO2/WebAuthn, email and trusted devices Yes — active You want WebAuthn/passkeys with less wiring

For most teams django-two-factor-auth is the right default — it ships enrolment, verification, backup tokens and optional phone/WebAuthn out of the box, while still letting you drop down to django-otp when you need custom behaviour.

TOTP vs SMS vs WebAuthn/passkeys

Factor How it works Phishing-resistant? Notes
TOTP authenticator app 6-digit code from a shared secret + time (RFC 6238) No — codes can be phished or relayed Best default; offline; works with Google Authenticator, Authy, 1Password, Microsoft Authenticator
SMS / phone OTP One-time code by text or call No — vulnerable to SIM-swap and interception Avoid as a primary factor; NIST discourages SMS-only
WebAuthn / passkeys (FIDO2) Public-key challenge bound to the site origin, via a security key or device biometrics Yes Strongest, phishing-resistant; needs a compatible authenticator
Backup / recovery codes Pre-generated one-time codes N/A Recovery only — never a primary factor

TOTP is the pragmatic baseline. Where you need phishing resistance (admins, finance, healthcare), layer WebAuthn/passkeys on top.

Option A — the fast path with django-two-factor-auth

This gives you a complete login plus enrolment flow (authenticator app, backup tokens, optional phone/WebAuthn) with very little code. Install the package, adding only the extras you need:

pip install django-two-factor-auth
pip install "django-two-factor-auth[phonenumbers]"   # optional: SMS / phone call
pip install "django-two-factor-auth[webauthn]"        # optional: WebAuthn / passkeys

Wire up django-otp, two_factor and OTPMiddleware in settings.py:

# settings.py
INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "django.contrib.sites",          # required by two_factor

    # django-otp core
    "django_otp",
    "django_otp.plugins.otp_static",  # backup / recovery codes
    "django_otp.plugins.otp_totp",    # TOTP authenticator apps

    # two-factor flow (built on django-otp)
    "two_factor",
    # optional plugins:
    # "two_factor.plugins.phonenumber",  # SMS / phone call
    # "two_factor.plugins.webauthn",     # WebAuthn / passkeys
]

MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django_otp.middleware.OTPMiddleware",   # MUST come after AuthenticationMiddleware
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

SITE_ID = 1
LOGIN_URL = "two_factor:login"
LOGIN_REDIRECT_URL = "two_factor:profile"

Point your project URLs at the bundled views:

# urls.py
from django.contrib import admin
from django.urls import include, path
from two_factor.urls import urlpatterns as tf_urls

urlpatterns = [
    path("", include(tf_urls)),   # login, setup, profile, backup tokens...
    path("admin/", admin.site.urls),
    # ... your own routes ...
]

Run the migrations, start the server, and open the setup view (/account/two_factor/setup/ by default) to enrol an authenticator app:

python manage.py migrate
python manage.py runserver
# then visit /account/two_factor/setup/ to scan the QR code

Option B — understanding TOTP under the hood with django-otp

If you need a custom enrolment screen (the part the original post built by hand), drop down to django-otp directly. The flow is: create an unconfirmed TOTPDevice, show its secret as a QR code, then confirm the device once the user types a valid code.

# A TOTPDevice stores the shared secret for one user.
from django_otp.plugins.otp_totp.models import TOTPDevice

# Start enrolment: create an UNCONFIRMED device for the signed-in user.
device = TOTPDevice.objects.create(user=request.user, name="default", confirmed=False)

# config_url is a standard otpauth:// provisioning URI, e.g.
#   otpauth://totp/MyApp:alice?secret=BASE32SECRET&issuer=MyApp&digits=6&period=30
provisioning_uri = device.config_url
# Render the provisioning URI as a QR code the user scans with their
# authenticator app (Google Authenticator, Authy, 1Password, ...).
# pip install "qrcode[pil]"
import qrcode
import qrcode.image.svg
from io import BytesIO


def totp_qr_svg(device: TOTPDevice) -> str:
    """Return an inline SVG QR code for a device's otpauth:// URI."""
    img = qrcode.make(device.config_url, image_factory=qrcode.image.svg.SvgImage)
    buffer = BytesIO()
    img.save(buffer)
    return buffer.getvalue().decode()
from django.http import JsonResponse


def confirm_totp(request):
    """Confirm enrolment by verifying the first 6-digit code."""
    device = TOTPDevice.objects.get(user=request.user, confirmed=False)
    token = request.POST.get("token", "").strip()

    # verify_token() validates the code AND throttles repeated failures,
    # so brute-forcing the 6-digit code is rate-limited automatically.
    if device.verify_token(token):
        device.confirmed = True
        device.save()
        return JsonResponse({"status": "MFA enabled"})
    return JsonResponse({"status": "invalid or expired code"}, status=400)

With OTPMiddleware installed you can require a verified device per view or globally:

  • request.user.is_verified() is True only after a valid second factor has been supplied this session.
  • @otp_required (from django_otp.decorators) gates a single view behind MFA.
  • LOGIN_URL = "two_factor:login" routes unauthenticated users through the 2FA-aware login.

Backup and recovery codes (do not skip these)

Phones get lost, wiped and replaced. Without recovery codes a lost device means a locked-out user and a support ticket. Issue a set of one-time static tokens at enrolment, show them once, and tell the user to store them somewhere safe. django-two-factor-auth does this for you; with raw django-otp you create them via a StaticDevice:

from django_otp.plugins.otp_static.models import StaticDevice, StaticToken


def generate_backup_codes(user, count: int = 10) -> list[str]:
    """Create one-time recovery codes for when the user loses their phone."""
    device, _ = StaticDevice.objects.get_or_create(user=user, name="backup")
    device.token_set.all().delete()          # invalidate any previous codes

    codes = []
    for _ in range(count):
        token = StaticToken.random_token()    # short, single-use code
        device.token_set.create(token=token)
        codes.append(token)
    return codes   # display ONCE; do not store these in plaintext yourself

Going phishing-resistant: WebAuthn / passkeys

TOTP codes can still be phished — an attacker fronting a fake login page can relay the 6-digit code in real time. WebAuthn/passkeys (FIDO2) close that gap because the credential is cryptographically bound to your site's origin and never leaves the authenticator.

  • With django-two-factor-auth, install the webauthn extra, add two_factor.plugins.webauthn to INSTALLED_APPS, and set TWO_FACTOR_WEBAUTHN_RP_NAME.
  • django-mfa2 is another maintained option with first-class FIDO2/WebAuthn support.
  • For lower-level work, the webauthn (py_webauthn) library handles registration and assertion.

Also rate-limit the MFA step. django-otp's verify_token() throttles repeated failures per device, but you should still throttle the login endpoint itself with something like django-axes or django-ratelimit to blunt credential stuffing before the second factor is ever reached.

How MicroPyramid approaches Django auth security

We have built and secured Django applications for 12+ years across 50+ delivered projects, covering login hardening, MFA roll-outs, SSO and audit-ready access controls. When we add MFA we default to maintained tooling (django-otp / django-two-factor-auth), ship recovery codes and rate limiting from day one, and add WebAuthn for high-risk roles.

If you are planning authentication work, our Django development services and broader Python development services cover secure authentication design, while our software testing services include security and regression testing for login and MFA flows.

Frequently Asked Questions

Should I use the django-mfa package in 2026?

No. The standalone django-mfa package is effectively unmaintained and targets old Django/Python. Use django-otp for low-level control or django-two-factor-auth for a complete, ready-made 2FA flow — both are actively maintained under Jazzband.

Is TOTP tied to Google Authenticator?

No. TOTP is an open standard (RFC 6238). The secret you provision works with any compliant app — Google Authenticator, Authy, 1Password, Microsoft Authenticator and others. Your Django app emits a standard otpauth:// URI; which app the user scans it with is up to them.

What happens if a user loses their phone?

They sign in with a backup/recovery code, then re-enrol a new device. That is why you must generate and show one-time recovery codes (via django-otp's static-token device) at enrolment. As a fallback, an admin can delete the user's TOTP device to reset MFA.

Is TOTP secure enough, or should I add passkeys?

TOTP is a solid baseline and far better than passwords alone, but it is not phishing-resistant — codes can be relayed by a fake login page. For admins and high-risk accounts, add WebAuthn/passkeys (FIDO2), which bind the credential to your site's origin and cannot be phished.

How do I enforce MFA across my Django site?

Install OTPMiddleware after AuthenticationMiddleware, then gate views with the @otp_required decorator or check request.user.is_verified(). Set LOGIN_URL = "two_factor:login" so unauthenticated users are routed through the 2FA-aware login flow.

Can I require MFA for the Django admin?

Yes. django-two-factor-auth can patch the admin so staff must pass a second factor — swap in its AdminSiteOTPRequired admin site (or use its patch helper) so the admin login enforces MFA for every staff user.

Share this article