Skip to main content

DRF-TOTP

TOTP (Time-based One-Time Password, RFC 6238) second-factor authentication for Django REST Framework: enrollment with any authenticator app, code validation with replay protection and rate limiting, backup codes, optional encryption of secrets at rest, and permission classes to enforce the second factor on your own views.

CI PyPI

Features

  • Generate a secret and otpauth:// provisioning URI (render it as a QR code on the client)
  • Confirm enrollment with the first code from the app
  • Validate codes with drift tolerance and replay protection (a code is accepted once)
  • Per-user throttling on every token endpoint, with an optional hard lockout
  • Hashed, single-use backup codes for account recovery
  • Disable requires a valid code (optionally the password too)
  • IsTOTPVerified / IsTOTPEnrolled permissions to gate your views
  • Optional Fernet encryption of secrets at rest, with key rotation
  • Signals, logging and a read-only admin that never shows the secret
  • Django 4.2 – 6.1, Python 3.9 – 3.13, DRF 3.14+

Installation

pip install drf-totp
# with encryption of secrets at rest:
pip install "drf-totp[encryption]"
# settings.py
INSTALLED_APPS = [
    ...
    "rest_framework",
    "drf_totp",
]

TOTP_ISSUER_NAME = "Your App Name"   # shown in the authenticator app
# urls.py
urlpatterns = [
    ...
    path("auth/", include("drf_totp.urls")),
]
python manage.py migrate

All endpoints require an authenticated user (IsAuthenticated), using whatever authentication classes your DRF settings define.

How it works

Enrollment

Client                                  Server
  │  POST /auth/otp/generate/             │
  │──────────────────────────────────────▶│  create secret (unverified)
  │  { secret, otpauth_url }              │
  │◀──────────────────────────────────────│
  │  show QR code, user scans it          │
  │  POST /auth/otp/verify/ {token}       │
  │──────────────────────────────────────▶│  code ok → otp_verified = True
  │  { detail: "TOTP verified…" }         │
  │◀──────────────────────────────────────│
  │  POST /auth/otp/backup-codes/ {token} │
  │──────────────────────────────────────▶│  issue 10 single-use codes
  │  { backup_codes: [...] }              │  (show once, user stores them)
  │◀──────────────────────────────────────│

The secret is returned only by /generate/. /status/ never includes it.

Login with a second factor

1. Password login (your existing flow)         → session / token
2. GET  /auth/otp/status/                       → otp_verified: true?
3. POST /auth/otp/validate/ {token}             → 200 "Token is valid"
4. Your protected views use IsTOTPVerified      → allowed

Step 4 is yours to wire up: drf-totp validates codes, it does not intercept requests. See Enforcing the second factor.

Disable

POST /auth/otp/disable/ {token}   # a TOTP code or a backup code

API

All responses are JSON. Errors use DRF's {"detail": "..."} shape (or a per-field dict for validation errors) with status 400, 429 when throttled or locked out, and 401/403 when unauthenticated.

Method Path Body Success
POST /auth/otp/generate/ – {"secret": "...", "otpauth_url": "otpauth://totp/..."}
POST /auth/otp/verify/ {"token": "123456"} {"detail": "TOTP verified successfully"}
GET /auth/otp/status/ – see below
POST /auth/otp/validate/ {"token": "123456"} or a backup code {"detail": "Token is valid", "method": "totp"}
POST /auth/otp/backup-codes/ {"token": "123456"} (TOTP only) {"backup_codes": ["ABCDE-FGHJK", ...]}
POST /auth/otp/disable/ {"token": "..."}, optional "password" {"detail": "TOTP disabled successfully"}

GET /auth/otp/status/:

{
  "otp_enabled": true,
  "otp_verified": true,
  "last_used_at": "2026-01-01T12:00:00Z",
  "backup_codes_remaining": 9,
  "created_at": "2026-01-01T11:58:00Z",
  "updated_at": "2026-01-01T12:00:00Z"
}

Notes:

  • /generate/ fails with 400 once TOTP is verified. Disable first to re-enroll.
  • /verify/ accepts only a TOTP code. /validate/ and /disable/ also accept a backup code (case and dashes are ignored). /backup-codes/ accepts only a TOTP code, so a backup code cannot mint more backup codes.
  • Each code is accepted once. Submitting the same code twice returns Invalid token; such replays do not count towards the lockout, since they prove possession of the secret.
  • Before enrollment is complete, /disable/ needs no token (there is no second factor to prove yet).
  • otp_enabled always equals otp_verified; it is deprecated and will be removed in 0.3.

Enforcing the second factor

drf-totp does not gate your views by itself. Two building blocks are provided.

Session authentication

On a successful /verify/ or /validate/, the current session is stamped. IsTOTPVerified allows users who have no TOTP at all, and enrolled users whose session carries a fresh stamp:

from rest_framework.permissions import IsAuthenticated
from drf_totp.permissions import IsTOTPVerified

class SensitiveView(APIView):
    permission_classes = [IsAuthenticated, IsTOTPVerified]

The stamp records which user earned it and a fingerprint of the enrollment, so it is ignored for any other user and stops working after a reset or re-enrollment. Set TOTP_SESSION_MAX_AGE (seconds) to require re-validation periodically. Disabling TOTP clears the stamp. Use IsTOTPEnrolled to require that a user has enabled TOTP at all.

Token or JWT authentication

There is no session to stamp, so issue a limited credential after the password step and a full one after /validate/. Typical shape:

  1. Password login returns a short-lived "pre-auth" token whose only permitted endpoint is /auth/otp/validate/.
  2. Connect to the totp_validated signal (or subclass ValidateOTP) and issue the real access token there.
  3. Point TOTP_VERIFIED_CHECK at a callable that inspects the credential:
# settings.py
TOTP_VERIFIED_CHECK = "myapp.auth.is_second_factor_verified"

# myapp/auth.py
def is_second_factor_verified(request) -> bool:
    return bool(getattr(request.auth, "payload", {}).get("mfa"))

IsTOTPVerified then uses your callable instead of the session stamp.

Settings

Setting Default Description
TOTP_ISSUER_NAME "drftotp" Issuer in the provisioning URI
TOTP_ACCOUNT_LABEL None Account label: None = email, then username; or a user attribute or method name (e.g. "get_full_name"); or a callable / dotted path taking the user
TOTP_DIGITS 6 Code length (6–8)
TOTP_PERIOD 30 Time step in seconds
TOTP_VALID_WINDOW 1 Steps accepted either side of now (clock drift)
TOTP_THROTTLE_RATE "5/min" Per-user rate for verify/validate/disable/backup-codes; None disables. REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["drf_totp"] takes precedence
TOTP_MAX_FAILED_ATTEMPTS 0 Consecutive failures before a hard lockout; 0 disables
TOTP_LOCKOUT_SECONDS 300 Lockout duration
TOTP_DISABLE_REQUIRES_PASSWORD False Also require password on /disable/
TOTP_SESSION_MAX_AGE 0 Seconds a session stamp stays valid; 0 = for the session's lifetime
TOTP_VERIFIED_CHECK None Callable or dotted path (request) -> bool replacing the session check
TOTP_ENCRYPTION_KEY None Fernet key, or list of keys (newest first), encrypting secrets at rest
TOTP_BACKUP_CODES_ENABLED True Accept and issue backup codes
TOTP_BACKUP_CODE_COUNT 10 Codes issued per request

Settings are read at request time, so they can be changed in tests with override_settings.

Backup codes are stored as HMAC-SHA256 digests keyed with SECRET_KEY. When you rotate SECRET_KEY, keep the old value in SECRET_KEY_FALLBACKS until users have regenerated their codes, or the old codes stop working.

The drf-totp views opt out of ATOMIC_REQUESTS. A rejected code ends the request with an error, and under ATOMIC_REQUESTS that would roll back the failed-attempt counter and lockout.

Throttling uses Django's cache framework. The default local-memory cache works for a single process; use a shared cache (Redis, Memcached) behind multiple workers so the limit is enforced globally.

Encryption at rest

pip install "drf-totp[encryption]"
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
TOTP_ENCRYPTION_KEY = env("TOTP_ENCRYPTION_KEY")

New secrets are stored as fernet$.... Existing plaintext rows stay readable; re-encrypt them once:

python manage.py totp_reencrypt

To rotate, set TOTP_ENCRYPTION_KEY = [new_key, old_key], run totp_reencrypt, then drop the old key. totp_reencrypt --decrypt writes plaintext back if you need to turn encryption off. manage.py check reports a missing library or malformed key.

Signals

All are sent with sender=TOTPAuth, user= and request= (may be None).

Signal Extra kwargs When
drf_totp.signals.totp_enabled enrollment confirmed
drf_totp.signals.totp_disabled TOTP disabled (API or admin action)
drf_totp.signals.totp_validated method ("totp" / "backup_code") a code was accepted
drf_totp.signals.totp_validation_failed method a code was rejected
drf_totp.signals.backup_codes_generated count new backup codes issued

Failed attempts and lockouts are also logged at WARNING level on the drf_totp logger; secrets and submitted codes are never logged.

Admin

TOTPAuth is registered read-only: rows cannot be added, edited or deleted, and the secret is never displayed. Two actions are available: Reset TOTP (disables and deletes the secret and backup codes, with the usual log line and totp_disabled signal) and Clear lockout.

Client example

const api = axios.create({ baseURL: "/auth/otp/" });

export const generateTotp = () => api.post("generate/").then(r => r.data);      // { secret, otpauth_url }
export const verifyTotp   = token => api.post("verify/", { token }).then(r => r.data);
export const getStatus    = () => api.get("status/").then(r => r.data);
export const validateTotp = token => api.post("validate/", { token }).then(r => r.data);
export const backupCodes  = token => api.post("backup-codes/", { token }).then(r => r.data.backup_codes);
export const disableTotp  = (token, password) => api.post("disable/", { token, password }).then(r => r.data);

Render otpauth_url as a QR code with any QR library; secret can be shown for manual entry.

Upgrading to 0.2

Run python manage.py migrate. Then review these behavior changes:

  • /status/ no longer returns otp_auth_url. Take the URI from /generate/.
  • /disable/ requires {"token": ...} for enrolled users.
  • A code cannot be reused within its window.
  • Token endpoints are rate limited (5/min per user by default).
  • One step of clock drift is now tolerated.
  • The otp_auth_url column is dropped by migration 0002_security_hardening.
  • /verify/ returns 400 for a user who is already verified.
  • TOTPAuthSerializer and VerifyTOTPSerializer still import but warn; switch to TOTPStatusSerializer and TOTPTokenSerializer.

See CHANGELOG.md for the complete list.

Development

pip install -e ".[dev]"
ruff check src tests && ruff format --check src tests
pytest --cov                              # SQLite; every test runs with encryption off and on
pytest --ds=tests.settings_postgres       # PostgreSQL: adds concurrency, HTTP e2e and load tests
pytest --ds=tests.settings_tokyo          # USE_TZ=False in a non-UTC zone
pytest -m "not load"                      # skip the load test

The suite has four layers: unit tests (tests/test_*.py), integration tests (tests/integration/: full middleware stack with CSRF, audit trail, encryption roll-out, migrations from 0.1, and row-lock concurrency on PostgreSQL), end-to-end tests over real HTTP (tests/e2e/, pytest-django's live server), and performance guards (tests/perf/: per-endpoint query budgets, behaviour with thousands of rows, and a threaded load test). CI runs them on Python 3.9–3.13 and Django 4.2–6.1, on SQLite and PostgreSQL.

See CONTRIBUTING.md and SECURITY.md.

License

MIT License – see LICENSE.

Metadata

Release files for drf-totp 0.2.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for drf-totp 0.2.0
File Size Uploaded Provenance Actions
drf_totp-0.2.0.tar.gz 42.4 kB Without provenance Details for drf_totp-0.2.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for drf-totp 0.2.0
File Interpreter ABI Platform Actions
drf_totp-0.2.0-py3-none-any.whl Python 3 none any Details for drf_totp-0.2.0-py3-none-any.whl

Total release size: 69.6 kB

Release files / drf_totp-0.2.0.tar.gz

Download URL drf_totp-0.2.0.tar.gz
Size 42.4 kB
Tags Source
SHA-256 checksum
How to use checksums
11c65f038d57f640f2cfb85f47ea8ed81e904275532ad9563b16fc5a76aa2cd9
BLAKE2b-256 checksum
How to use checksums
593accd3a04476fae509ca6ce23dad453819df4583bf2176a1e05c4f9200416a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Release files / drf_totp-0.2.0-py3-none-any.whl

Download URL drf_totp-0.2.0-py3-none-any.whl
Size 27.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d5397d7860051f93ef734efc1c5d32c1677ab553683b2355b2a0d34259efb468
BLAKE2b-256 checksum
How to use checksums
9efeb9f11366bf24ce2648ef336b1a73b7030d1d84c5cf61efaa6d0c5cfd5eaa
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.14

Security for drf-totp 0.2.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page