django-totp

PyPI Version Python Versions Django License Downloads PyPI Status Django Packages GitHub Stars GitHub Issues Last Commit Code Style

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.

Table of Contents

Overview

django-totp stores each user’s TOTP secret in encrypted form and exposes API actions to:

  1. Create an enrollment and return a provisioning QR code
  2. Confirm enrollment with a valid OTP and receive backup codes
  3. Disable TOTP, or rotate backup codes, for an already-enrolled user
  4. Recover an account by email when a user has lost their TOTP device, without ever requiring the device itself

It’s designed to be used as:

Features

Requirements

Installed dependencies used by this package: cryptography, pyotp, qrcode.

Installation

Install from PyPI:

pip install django-totp

Quick Start

1. Add the app

# settings.py
INSTALLED_APPS = [
    # Django apps...
    "rest_framework",
    "django_totp",
]

2. Set the encryption key (required)

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.

3. Include the URLs

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.

4. Run migrations

python manage.py migrate

5. Call the endpoints

TOTP management endpoints (authenticated):

TOTP recovery endpoints (unauthenticated):

JWT authentication endpoints:

Configuration Reference

All settings are optional unless marked otherwise, and are read once at import time via getattr(settings, ...), with sensible defaults.

Encryption

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.

Enrollment and Backup Codes

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.

Throttling

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.

TotpThrottle still exists as an alias of TotpUserThrottle for backward compatibility, but is deprecated and will be removed in a future release. New code should depend on TotpUserThrottle directly.

2FA Login Challenge Tokens

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.

Account Recovery and Email

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.

DRF and JWT Integration

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.

API Endpoints

All endpoints return error payloads as JSON with a detail field, unless otherwise noted.

TOTP Management Endpoints

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", "..."] }

TOTP Recovery Endpoints

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.

JWT Authentication Endpoints

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.

Email Templates

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:

EMAIL_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.

Signals

django-totp sends the following signals via send_robust, so a failing receiver never breaks the request itself:

# 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}")

Django Admin

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.

Integrating 2FA Into Login Flow

Typical two-step login flow:

  1. Validate username/password via /api/jwt/create/.
  2. If the user has TOTP enabled, the response carries a short-lived signed challenge token instead of tokens.
  3. Prompt the user for a TOTP code or backup code.
  4. Submit it, along with the challenge token, to /api/jwt/totp/verify/.
  5. Issue final JWTs only after that verification succeeds.

Integrating Account Recovery

Typical recovery flow for a user who has lost their TOTP device:

  1. The user submits their email to /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.
  2. If eligible, the user receives an email containing a link built from TOTP_RECOVERY_CONFIRM_URL, pointed at your frontend.
  3. Your frontend’s recovery page extracts uid and token from the URL and prompts for the account’s current password.
  4. The frontend submits uid, token, and password to /api/totp/recovery_confirm/.
  5. On success, TOTP is disabled on the account, a confirmation email is sent, and the user can log in normally (without 2FA) and re-enroll a new device.

Security and Production Checklist

Configure the Encryption Key Correctly

TOTP_ENCRYPTION_KEY encrypts every stored TOTP secret and backup code.

Use HTTPS in Production

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.

Configure Throttling

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"

Handle Backup Codes Securely

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.

Handle OTP Codes Securely

Never log a submitted OTP code, never persist it, and keep it out of debugging tools and error traces.

Configure Challenge Token Expiry Carefully

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.

Configure the Token Salt Before 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).

Configure Recovery Email Settings Correctly

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.

Keep Dependencies Updated

Apply security updates promptly for Django, cryptography, pyotp, djangorestframework, and djangorestframework-simplejwt.

Troubleshooting

ImproperlyConfigured: TOTP_ENCRYPTION_KEY must be set

Cause: missing or invalid Fernet key.

Fix: generate a valid Fernet key, set TOTP_ENCRYPTION_KEY in the environment, and restart the application.

Confirm endpoint always returns “Invalid TOTP code”

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.

Backup code rejected

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/.

Data Model

django_totp defines two models:

Public Python API

Helpers you can import directly, for building custom flows on top of the same primitives the bundled views use:

Interactive Helper Tools

Utilities for development, debugging, and response inspection:

Available at: https://django-totp-helper.pages.dev/

Contributing

Contributions are welcome. Please open an issue for bugs or feature requests, and submit pull requests for improvements.

Maintainers

License

MIT License. See LICENSE for details.