Production-ready TOTP (Time-based One-Time Password) support for Django and Django REST Framework.
django-totp adds two-factor authentication (2FA) to a Django project with encrypted secret storage, QR-code enrollment, one-time backup codes, email-based account recovery, and JWT-aware login endpoints - all exposed as a small, composable set of DRF views.
This README is the single source of documentation for installation, configuration, integration, and operations.
django-totp stores each user’s TOTP secret in encrypted form and exposes API actions to:
It’s designed to be used as:
cryptography.FernetInstalled dependencies used by this package: cryptography, pyotp, qrcode.
Install from PyPI:
pip install django-totp
# settings.py
INSTALLED_APPS = [
# Django apps...
"rest_framework",
"django_totp",
]
Generate a Fernet key once:
python -c "from django_totp.encryption import generate_fernet_key; print(generate_fernet_key())"
Load it from the environment rather than hardcoding it:
# settings.py
import os
TOTP_ENCRYPTION_KEY = os.environ["TOTP_ENCRYPTION_KEY"]
Generate this key once per environment and never rotate it casually - rotating it makes every previously encrypted TOTP secret and backup code unreadable. See Security and Production Checklist for the full reasoning.
django-totp ships three independent URL modules so you can include only what you need:
# urls.py
from django.urls import include, path
urlpatterns = [
# your routes...
path("api/", include("django_totp.urls")), # enroll / confirm / disable / rotate backup codes
path("api/", include("django_totp.urls.jwt")), # JWT login + 2FA verification
path("api/", include("django_totp.urls.recovery")), # email-based account recovery
]
All three are optional independently of each other: a project that doesn’t use JWT can omit django_totp.urls.jwt, and a project that doesn’t want self-service recovery can omit django_totp.urls.recovery.
python manage.py migrate
TOTP management endpoints (authenticated):
POST /api/totp/create/POST /api/totp/confirm/POST /api/totp/disable/POST /api/totp/rotate_backup_codes/TOTP recovery endpoints (unauthenticated):
POST /api/totp/recovery/POST /api/totp/recovery_confirm/JWT authentication endpoints:
POST /api/jwt/create/POST /api/jwt/totp/verify/POST /api/jwt/refresh/POST /api/jwt/verify/All settings are optional unless marked otherwise, and are read once at import time via getattr(settings, ...), with sensible defaults.
| Setting | Required | Default | Purpose |
|---|---|---|---|
TOTP_ENCRYPTION_KEY |
Yes | - | Fernet key used to encrypt TOTP secrets and backup codes at rest. Raises ImproperlyConfigured if missing or invalid. |
| Setting | Required | Default | Purpose |
|---|---|---|---|
TOTP_ISSUER |
No | "MyApp" |
Issuer label shown inside authenticator apps. |
TOTP_MAX_BACKUP_CODES |
No | 10 |
Number of backup codes generated and stored per user. |
| Setting | Required | Default | Purpose |
|---|---|---|---|
TOTP_THROTTLE_RATE |
No | "10/minute" |
Rate limit applied to every django-totp endpoint, for both authenticated (TotpUserThrottle) and anonymous (TotpAnonThrottle) callers. |
TotpThrottlestill exists as an alias ofTotpUserThrottlefor backward compatibility, but is deprecated and will be removed in a future release. New code should depend onTotpUserThrottledirectly.
These govern the short-lived token issued by /api/jwt/create/ while a user is mid-login and has not yet supplied their TOTP or backup code.
| Setting | Required | Default | Purpose |
|---|---|---|---|
TOTP_TOKEN_SALT |
Recommended in production | "django-totp-token-salt" |
Salt used when signing the challenge token. Changing it invalidates every challenge token already in flight. |
TOTP_TOKEN_MAX_AGE |
No | 120 (seconds) |
How long the challenge token remains valid after /api/jwt/create/ is called. |
| Setting | Required | Default | Purpose |
|---|---|---|---|
TOTP_RECOVERY_CONFIRM_URL |
No | "/totp-recovery/{uid}/{token}" |
Path template embedded in the recovery email. {uid} and {token} are substituted automatically; point this at your frontend’s recovery page, not at the API itself. |
TOTP_RECOVERY_EMAIL_TEMPLATE |
No | "email/totp_recovery.html" |
Template used for the initial recovery email. |
TOTP_DISABLED_EMAIL_TEMPLATE |
No | "email/totp_disabled.html" |
Template used for the confirmation email sent once TOTP has actually been disabled. |
DOMAIN |
Recommended in production | "localhost:3000" |
Host used to build the recovery link. Point this at your frontend, not your API, if they’re on different hosts. |
PROTOCOL |
Recommended in production | "http" |
Scheme used to build the recovery link. Use "https" in production. |
SITE_NAME |
Recommended in production | "localhost" |
Display name interpolated into the email subject and body. |
DEFAULT_FROM_EMAIL |
Recommended in production | Django’s own default ("webmaster@localhost") |
Sender address for both recovery emails. This is Django’s built-in setting, not one defined by django-totp. |
Recovery links are signed with Django’s own default_token_generator (the same mechanism Django uses for password resets), not with TOTP_TOKEN_SALT / TOTP_TOKEN_MAX_AGE - those two only govern the separate, much shorter-lived 2FA login challenge token described above. As a result, recovery link expiry is controlled by Django’s native PASSWORD_RESET_TIMEOUT setting (3 days by default), and a recovery link is automatically invalidated the moment the user’s password changes.
Required only if you’re using the JWT endpoints:
# settings.py
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"rest_framework_simplejwt.authentication.JWTAuthentication",
),
}
from datetime import timedelta
SIMPLE_JWT = {
"AUTH_HEADER_TYPES": ("Bearer",),
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=20),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"ROTATE_REFRESH_TOKENS": True,
"BLACKLIST_AFTER_ROTATION": True,
# other settings as needed...
}
See the djangorestframework-simplejwt settings reference for the full list of available options.
All endpoints return error payloads as JSON with a detail field, unless otherwise noted.
A user enables TOTP by calling create to receive a QR code, then confirm with a valid OTP to finalize enrollment and receive backup codes. All four endpoints require an authenticated user.
POST /api/totp/create/Starts TOTP enrollment, creating an encrypted secret and returning a QR-code SVG.
Request body: empty.
Success response (201):
{ "svg": "<svg ...>...</svg>" }
Error examples (400): TOTP already exists for this user.
POST /api/totp/confirm/Confirms enrollment using a valid code from an authenticator app and returns backup codes.
Request body:
{ "input_code": "123456" }
Success response (200):
{ "backup_codes": ["code1", "code2", "..."] }
Error examples (400): user has no associated TOTP secret; invalid TOTP code.
POST /api/totp/disable/Disables TOTP and deletes associated backup codes.
Request body: empty.
Success response: 204 No Content.
Error examples (400): user has no associated TOTP secret.
POST /api/totp/rotate_backup_codes/Replaces all existing backup codes with a new set.
Request body: empty.
Success response (200):
{ "backup_codes": ["new1", "new2", "..."] }
These two endpoints exist for users who have lost their TOTP device and therefore can’t satisfy a normal authenticated request. Both are unauthenticated by design, and both are throttled for anonymous as well as authenticated callers.
POST /api/totp/recovery/Sends a recovery email if, and only if, an account with the given email exists and has TOTP enabled - but the response is identical either way, to avoid leaking which emails are registered.
Request body:
{ "email": "user@example.com" }
Success response (200), always returned regardless of whether the account exists:
{ "details": "If an account with that email exists and has TOTP enabled, a recovery email has been sent." }
POST /api/totp/recovery_confirm/Validates the signed uid/token pair from the recovery email together with the account’s current password, then disables TOTP on the account and sends a confirmation email.
Request body:
{ "uid": "...", "token": "...", "password": "current_account_password" }
Success response: 204 No Content.
Error examples (400):
{ "message": ["The recovery link is invalid or has expired."] }
{ "message": ["The provided credentials is invalid."] }
Requiring the account’s current password here, in addition to the signed link, means a leaked or intercepted recovery email alone is not enough to disable a user’s 2FA.
Use these when django-totp is integrated with JWT authentication, for 2FA-aware token issuance.
POST /api/jwt/create/Initiates login with username and password. Returns JWT tokens directly if the user has no TOTP enabled, or a challenge token if 2FA is required.
Request body:
{ "username": "user@example.com", "password": "secure_password" }
Success response (200) - no TOTP enabled:
{ "is_totp_enabled": false, "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...", "access": "eyJ0eXAiOiJKV1QiLCJhbGc..." }
Success response (200) - TOTP enabled:
{ "is_totp_enabled": true, "totp_challenge_token": "eyJ0eXAiOiJKV1QiLCJhbGc..." }
Error examples (401): invalid username/password combination.
POST /api/jwt/totp/verify/Verifies a TOTP code or backup code and returns JWT tokens. Must be called after /api/jwt/create/ when TOTP is enabled.
Request body (TOTP code):
{ "totp_challenge_token": "...", "otp_code": "123456" }
Request body (backup code):
{ "totp_challenge_token": "...", "backup_code": "BACKUP-CODE-1" }
Success response (200):
{ "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc...", "access": "eyJ0eXAiOiJKV1QiLCJhbGc..." }
Error examples (400): invalid or expired challenge token; invalid TOTP code; invalid backup code, and both or neither otp_code and backup_code is provided in the same request.
POST /api/jwt/refresh/Refreshes an expired access token using a valid refresh token.
Request body:
{ "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..." }
Success response (200):
{ "access": "eyJ0eXAiOiJKV1QiLCJhbGc...", "refresh": "eyJ0eXAiOiJKV1QiLCJhbGc..." }
POST /api/jwt/verify/Verifies the validity of an access or refresh token.
Request body:
{ "token": "eyJ0eXAiOiJKV1QiLCJhbGc..." }
Success response (200): an empty body indicates a valid token.
Both recovery emails are rendered from a single Django template per email, split into named blocks rather than separate subject/body files:
| Block | Used for |
|---|---|
| {% block subject %} | Email subject line |
| {% block text_body %} | Plain-text body (always attached) |
| {% block html_body %} | HTML alternative (attached if both bodies render to non-empty content) |
Override either template by placing a file at the same relative path earlier in your template loader’s search order, or by pointing TOTP_RECOVERY_EMAIL_TEMPLATE / TOTP_DISABLED_EMAIL_TEMPLATE at a different path entirely. Available context variables:
totp_recovery.html: site_name, protocol, domain, url, usertotp_disabled.html: site_name, userEMAIL_BACKEND is read from your own Django settings as usual - django-totp doesn’t configure one for you, so use the console or file-based backend in development and a real transactional backend in production.
django-totp sends the following signals via send_robust, so a failing receiver never breaks the request itself:
totp_created: sent after a new TOTP enrollment is confirmed.totp_disabled: sent after TOTP is disabled, whether by the user directly or via account recovery.backup_codes_rotated: sent after backup codes are rotated.totp_login_succeeded: sent after a successful 2FA verification (TOTP code or backup code) during login.non_totp_login_succeeded: sent after a successful login for a user who doesn’t have TOTP enabled.totp_recovery_succeeded: sent after a successful account recovery, immediately before totp_disabled fires for the same request.# example signal handler
from django.dispatch import receiver
from django_totp.signals import totp_created
@receiver(totp_created)
def handle_totp_created(sender, request, user, **kwargs):
# Custom logic after TOTP enrollment creation
print(f"TOTP created for user: {user.username}")
Registering django_totp gives you an admin view for the Totp model out of the box, with no extra wiring. It lists each user’s email, username, and a live used/total backup-code count, and shows backup codes inline. Both the TOTP secret and every backup code are masked in the admin (only the first four encrypted characters are shown) - the underlying encrypted values are never exposed, and there’s no way to retrieve a plaintext secret or code through the admin.
Typical two-step login flow:
/api/jwt/create/./api/jwt/totp/verify/.Typical recovery flow for a user who has lost their TOTP device:
/api/totp/recovery/. The response is identical whether or not the account exists or has TOTP enabled, so the frontend should show the same message either way.TOTP_RECOVERY_CONFIRM_URL, pointed at your frontend.uid and token from the URL and prompts for the account’s current password.uid, token, and password to /api/totp/recovery_confirm/.TOTP_ENCRYPTION_KEY encrypts every stored TOTP secret and backup code.
Serve every authentication-related endpoint over HTTPS - TOTP setup, recovery, and JWT endpoints alike. Never expose OTP codes, challenge tokens, recovery links, or JWTs over plain HTTP. Set PROTOCOL = "https" so recovery links reflect this too.
Keep TOTP_THROTTLE_RATE strict for both login and recovery surfaces, since both are realistic targets for brute-force and enumeration attempts:
TOTP_THROTTLE_RATE = "5/minute"
Backup codes are recovery credentials and should be treated like passwords: show them only once, at generation or rotation time, ask users to store them securely, and never log or expose them in plaintext anywhere - debug responses, monitoring, or error traces included.
Never log a submitted OTP code, never persist it, and keep it out of debugging tools and error traces.
TOTP_TOKEN_MAX_AGE controls how long the 2FA login challenge token stays valid (default 120 seconds). Keep this short; there’s little reason to extend it significantly in production.
TOTP_TOKEN_SALT signs the 2FA login challenge token. Change the default value before going live, then keep it stable - changing it later invalidates every challenge token currently in flight (which only matters for the few seconds a user is mid-login, so this is low-risk to rotate if needed).
Point DOMAIN and PROTOCOL at your actual frontend, not at the Django backend, so the link inside the recovery email lands on a page that can read uid/token and submit them. Recovery requires the user’s current password in addition to the link, but it’s still worth treating recovery emails with the same care as password-reset emails.
Apply security updates promptly for Django, cryptography, pyotp, djangorestframework, and djangorestframework-simplejwt.
ImproperlyConfigured: TOTP_ENCRYPTION_KEY must be setCause: missing or invalid Fernet key.
Fix: generate a valid Fernet key, set TOTP_ENCRYPTION_KEY in the environment, and restart the application.
Possible causes: device clock drift, the wrong issuer/account was scanned, or the code was submitted after it expired.
Fixes: make sure the server’s clock is synchronized via NTP; re-run enrollment and rescan the QR code; submit the currently active code from the authenticator app.
Cause: the code was already used (each backup code is one-time-use), or there’s a copy/paste whitespace mismatch.
Fix: rotate backup codes and redistribute them securely.
Possible causes: the link is older than Django’s PASSWORD_RESET_TIMEOUT (3 days by default), the account’s password changed after the link was issued, or the link has already been used once.
Fix: request a new recovery email via /api/totp/recovery/.
django_totp defines two models:
Totp
user - one-to-one with AUTH_USER_MODELsecret_key - encryptedcreated_atBackupCode
totp - foreign key to Totpcode - encryptedis_usedcreated_atHelpers you can import directly, for building custom flows on top of the same primitives the bundled views use:
django_totp.auth
is_totp_enabled(user)generate_challenge_token(user)verify_challenge_token(token)get_user_from_challenge_token(token)django_totp.totp
generate_totp_secret()verify_totp_code(user, input_code)create_totp_setup(user)confirm_totp_setup(user, input_code)disable_totp(user)django_totp.backup_code_utils
store_backup_codes(user, codes)verify_backup_code(user, input_code)rotate_backup_codes(user)django_totp.email
TotpRecoveryEmail(request, context)TotpDisabledEmail(request, context)django_totp.email_utils
encode_uid(pk)decode_uid(uid)django_totp.encryption
generate_fernet_key()resolve_fernet_key(default=None)encrypt(value)decrypt(value)Utilities for development, debugging, and response inspection:
create endpoint.Available at: https://django-totp-helper.pages.dev/
Contributions are welcome. Please open an issue for bugs or feature requests, and submit pull requests for improvements.
MIT License. See LICENSE for details.