Configuring Multi-Factor Authentication¶
Validibot ships with opt-in multi-factor authentication (MFA) powered by django-allauth's MFA module. Authenticator apps (TOTP) and recovery codes are enabled out of the box; any user can turn MFA on from their Security settings page and turn it off again at any time. MFA is never forced — policies that require it belong in the Pro/Enterprise tiers.
Required environment variable: DJANGO_MFA_ENCRYPTION_KEY¶
MFA secret material — TOTP shared secrets and recovery-code seed values —
is stored encrypted in the database via a Fernet cipher (AES-128-CBC +
HMAC-SHA256) provided by our custom
ValidibotMFAAdapter. The
application refuses to start in any environment without a valid
encryption key.
Why a dedicated key (not SECRET_KEY)¶
Django's SECRET_KEY rotates on a different schedule. Rotating it
invalidates sessions and signed cookies, which is an acceptable cost for
session hygiene — but it must NOT invalidate every user's long-lived
second factor. A separate DJANGO_MFA_ENCRYPTION_KEY lets the two
rotate independently.
Generate a key¶
The output is a 44-character URL-safe base64 string ending in =
(example: qYy0eDvn7TRiLVXGJk1XeXgvr1SYathyVc9X-7HIV5E=). Fernet rejects
anything else — hand-typed strings, random UUIDs, raw bytes — so you
must use the generator above.
Where to set it¶
| Environment | File | Notes |
|---|---|---|
| Local dev | .envs/.local/.django |
Generate once and reuse; never commit |
| GCP production | .envs/.production/.google-cloud/.django |
Uploaded to Secret Manager via just gcp secrets prod |
| Docker Compose production | .envs/.production/.docker-compose/.django |
Deployed to the target host; keep out of git |
| AWS production | .envs/.production/.aws/.django |
Store in AWS Secrets Manager or Parameter Store |
| Tests | config/settings/test.py (hardcoded) |
Fixed test key; never reuse outside tests |
Never reuse a key across environments. If dev leaks, prod should be
unaffected. Matching templates in .envs.example/ document the expected
placement.
Rotate the key¶
If the key is compromised (or on a regular schedule), switch to a
cryptography.fernet.MultiFernet in
validibot/users/mfa_adapter.py
with the new key first and the old key second. Fernet will decrypt with
whichever key works and re-encrypt under the new one on next write. The
current adapter uses a single-key Fernet; wire up MultiFernet when
rotation is actually needed.
Recover from a lost or single-key rotation¶
If the old key is genuinely gone (not just superseded by a MultiFernet
with both keys), every user enrolled under it is locked out of the
normal login flow. The stored TOTP secret can't be decrypted, so
ValidibotMFAAdapter.decrypt() raises InvalidToken and
/accounts/2fa/authenticate/ returns 500.
The fix is to delete the affected Authenticator rows so allauth skips
the MFA stage on next login and the user can re-enroll under the new
key. That's what the clear_mfa management command is for:
# Single user (normal case — one admin's key rotated, most users unaffected)
just gcp management-cmd prod "clear_mfa --email daniel@example.com"
# Every user (mass rotation — run once, then notify affected users to re-enroll)
just gcp management-cmd prod "clear_mfa --all-users"
# Preview first, especially for --all-users
just gcp management-cmd prod "clear_mfa --all-users --dry-run"
The command refuses to run without a selector (--email,
--user-id, or --all-users) so "accidentally delete every row" is not
a possible failure mode. It also fails loudly on a missing email rather
than silently deleting zero rows — silent success during an incident is
worse than a loud error.
Why this lives in a management command rather than the Django admin: the admin panel is itself authenticated through Django's built-in login (which does not route through allauth MFA), so a locked-out staff user can still reach admin — but any other recovery channel that depends on allauth login would share the same failure. A management command runnable as a Cloud Run Job is the one recovery path that works even if every admin account is locked out and the admin panel is later network-restricted.
Other settings¶
Three additional settings in config/settings/base.py drive the feature:
MFA_ADAPTER = "validibot.users.mfa_adapter.ValidibotMFAAdapter"
MFA_SUPPORTED_TYPES = ["totp", "recovery_codes"]
MFA_TOTP_ISSUER = "Validibot"
MFA_RECOVERY_CODE_COUNT = 10
MFA_ADAPTERpoints allauth at our Fernet-backed adapter. Leaving it unset reverts to allauth's default, which stores secrets in cleartext — don't.MFA_SUPPORTED_TYPESis the list of second factors allauth will accept. TOTP means six-digit codes from apps like Aegis, 1Password, Bitwarden, or Google Authenticator. Recovery codes are single-use backup codes a user prints or stores in a password manager for the day they lose their phone.MFA_TOTP_ISSUERis the label authenticator apps show next to the account email. Without it, users with multiple TOTP entries see bare email addresses and have to guess which one is Validibot.MFA_RECOVERY_CODE_COUNTis allauth's default (10). We state it explicitly so future maintainers don't have to chase an upstream default if we ever audit it.
Required infrastructure: a shared cache¶
Allauth's rate limiting and short-window "same TOTP code can't be reused"
checks store state in Django's cache. That cache MUST be shared across
Gunicorn workers and scaled instances — a per-process LocMemCache would
silently weaken those protections. config/settings/production.py
enforces this by picking between two supported shared backends and
explicitly refusing to fall back to per-process storage.
Default: DatabaseCache on the existing Postgres database¶
Reuses the Cloud SQL / Postgres instance you already have, so there's no new infrastructure and no incremental cost. Fine for the low-volume rate-limit workload (a few hundred cache ops/day) that this app sees at pre-release scale.
One one-time setup step per environment:
This provisions the django_cache table. Skip it on subsequent deploys
— the table persists across code changes.
Upgrade path: Redis via Memorystore¶
When traffic grows enough that DB-backed cache latency starts showing up in auth-path monitoring, or when you want separation between cache and primary data, switch to Redis:
- Provision Memorystore for Redis (smallest BASIC-tier instance on GCP ≈ $35/month as of writing).
- Attach Cloud Run to the VPC connector that can reach Memorystore.
- Set
REDIS_URL=redis://host:port/dbin the production env file andjust gcp secrets prod. - Redeploy. Nothing else changes —
config/settings/production.pyauto-switches toRedisCachewhenREDIS_URLis set.
The DB-backed cache keeps working if you need to roll back: remove
REDIS_URL and redeploy.
Other deployment targets¶
- Docker Compose: bundled
redisservice covers it;REDIS_URLis already wired in the compose file. - AWS: use ElastiCache; same
REDIS_URLsetting.
Adding WebAuthn / passkeys later¶
Enabling WebAuthn is two pieces of work:
- Append
"webauthn"toMFA_SUPPORTED_TYPES. No migration is required, because allauth stores authenticators in a single polymorphic table keyed bytype.
- Write per-page template overrides for each allauth WebAuthn management
page. At time of writing those are
mfa/webauthn/authenticator_list.html,mfa/webauthn/add_form.html,mfa/webauthn/edit_form.html,mfa/webauthn/remove_form.html, andmfa/webauthn/reauthenticate.html.
Each override extends app_base.html directly (the same pattern as the
TOTP and recovery-code overrides in validibot/templates/mfa/) and uses
the mfa_breadcrumbs template tag to emit the top-bar breadcrumb trail.
We do NOT extend allauth's mfa/base_manage.html, because Django block
inheritance doesn't compose cleanly through it — allauth's leaf templates
redefine {% block content %}, which erases any wrapper chrome we add at
the base layer.
Also plan to add a WebAuthn card to users/security.html mirroring the
TOTP card before shipping, so users can discover and manage keys from the
Security page.
Development bypass¶
If you're testing the login flow locally and don't want to keep a TOTP app
handy, you can set MFA_TOTP_INSECURE_BYPASS_CODE in a dev .env file to a
fixed six-digit string (e.g. "000000"). Allauth will accept that literal
code in place of a real TOTP. Never set this in staging or production —
anyone who knows the bypass code can complete MFA without the second factor.
How the pages fit into Validibot chrome¶
Each allauth MFA leaf template has a Validibot-branded override in
validibot/templates/mfa/:
mfa/totp/activate_form.htmlmfa/totp/deactivate_form.htmlmfa/recovery_codes/index.htmlmfa/recovery_codes/generate.html
All of them extend app_base.html directly (the same pattern as
users/security.html). None of them extend allauth's mfa/base_manage.html:
we tried that first, but Django block inheritance doesn't compose through it
— allauth's leaf templates redefine {% block content %}, which wipes out
any wrapper chrome added at the base layer.
Each override uses the mfa_breadcrumbs template tag
(validibot/core/templatetags/core_tags.py) to emit a
User Settings › Security › {leaf} trail into the top-bar breadcrumb slot.
The tag is there because allauth views don't run through our
BreadcrumbMixin, so the default breadcrumb partial would render nothing.
The user_settings_nav_state template tag (same file) keeps the
Security tab highlighted throughout the multi-step allauth flows by
matching any URL name prefixed with mfa_.
The mfa_index redirect¶
Allauth ships its own MFA landing page at /accounts/2fa/ (URL name
mfa_index) that duplicates the Security page with worse styling, and its
post-action flows hard-code reverse("mfa_index") as the redirect target
(e.g. after deactivating TOTP). config/urls_web.py preempts the
mfa_index URL name with a RedirectView pointing at users:security,
so every such redirect lands on our Security page instead. The override
is registered before the accounts/ allauth URL include so Django's
first-match resolver picks ours.
The Security landing page¶
UserSecurityView (validibot/users/views.py) renders
templates/users/security.html and is accessible at /users/security/. The
view computes a handful of context flags from the user's Authenticator
rows — totp_enabled, is_mfa_enabled, recovery_codes — so the template
can branch between "set up" and "deactivate" states without running its own
queries.
The view does not implement activation or deactivation itself. Those are
already handled by allauth's own URL names (mfa_activate_totp,
mfa_deactivate_totp, mfa_generate_recovery_codes, and so on), which the
Security page links to. Keeping the split this way means we inherit
allauth's hardening (rate-limiting, CSRF, session rotation) instead of
rewriting it.
Testing¶
validibot/users/tests/test_security.py covers the Validibot-specific
wiring: access control on the landing page, context-flag correctness, that
both nav partials link to users:security, that the settings-nav tag
recognises the allauth mfa_* URL names, that each MFA leaf template
override extends app_base.html and emits the expected breadcrumb trail,
that the mfa_breadcrumbs tag returns the right shape, and that the
mfa_index URL redirects to the Security page without rendering
allauth's unbranded index template.
We deliberately don't re-test allauth's TOTP cryptography or state machine —
those live upstream and already have good coverage. If you add a new
authenticator type, the right place for tests is whatever renders the new
card on security.html, not the allauth plumbing underneath.