Extending with custom fallback MFA methods¶
The fallback OTP system is django-multifactor’s extension point for adding
new “soft” factors — SMS, push notification, Signal, Slack DM, paper code,
whatever your environment supports. This page explains the contract and
walks through a real-world worked example (SMS via Twilio) plus a
deliberately-silly stub (carrier pigeon) so you can see the shape of a
minimal transport.
The contract¶
A fallback transport is a callable with the signature:
def my_transport(user, message: str) -> str | False:
"""Deliver `message` to `user`. Return a truthy label on success,
or False on failure."""
The return value is used in two places:
The package collects all non-False returns to build the “we sent your code via …” message shown to the user.
Any transport returning False is silently skipped (no exception is raised to the user).
Important
Returning a string (e.g. "sms") is conventional and shows up in the
“We sent your code via sms and email” line. Returning True works too, but
the message becomes “We sent your code via True”, which is ugly. Always
return a human-readable label.
In addition, each transport is gated by a predicate:
predicate = lambda user: user.profile.phone # truthy if the user can receive
The predicate runs first. If it returns falsy, the transport is skipped
without calling the sender. This avoids AttributeErrors on users who lack
the relevant contact detail.
How fallback OTPs are dispatched¶
sequenceDiagram
autonumber
participant U as User
participant FAuth as fallback.Auth.get()
participant Predicate as predicate
participant Sender as your transport
participant Msg as messages framework
U->>FAuth: GET /admin/multifactor/fallback/auth/
FAuth->>FAuth: generate random OTP, store in session
FAuth->>FAuth: disabled = DisabledFallback rows for user
loop each (name, (predicate, sender)) in MULTIFACTOR["FALLBACKS"]
FAuth->>Predicate: predicate(user)
alt name in disabled OR predicate falsy
Note over FAuth: skip
else predicate truthy
FAuth->>Sender: sender(user, message)
Sender-->>FAuth: "sms" or False
alt return truthy
FAuth->>FAuth: append label to success list
else return False
Note over FAuth: silently skipped
end
end
end
alt no transport succeeded
FAuth->>Msg: error("No fallback worked. Contact admin.")
FAuth-->>U: redirect home
else at least one
FAuth->>FAuth: render template with labels joined
FAuth-->>U: "We sent your code via sms and email"
end
Source: multifactor/factors/fallback.py:38-62.
Worked example: SMS via Twilio¶
This is the kind of transport most production sites end up writing.
1. The sender¶
# myapp/mfa.py
from django.conf import settings
from twilio.rest import Client
def send_sms(user, message):
"""Deliver an OTP to the user's mobile number."""
phone = getattr(user.profile, "phone", None)
if not phone:
return False
try:
client = Client(settings.TWILIO_ACCOUNT_SID, settings.TWILIO_AUTH_TOKEN)
client.messages.create(
to=phone,
from_=settings.TWILIO_FROM_NUMBER,
body=message,
)
return "sms" # <-- the label shown to the user
except Exception:
import logging
logging.getLogger(__name__).exception("Twilio SMS failed")
return False
2. Wire it into MULTIFACTOR¶
MULTIFACTOR = {
"FALLBACKS": {
"email": (lambda user: user.email, "multifactor.factors.fallback.send_email"),
"sms": (
lambda user: getattr(user.profile, "phone", None),
"myapp.mfa.send_sms",
),
},
}
Now when a user clicks “forgot your device?”, the OTP is sent to both their email and their phone simultaneously.
3. Why fan-out and not selection?¶
If the package let the user pick a transport, an attacker who had compromised the user’s email could quietly request an OTP and the legitimate user would never know. With fan-out, the legitimate user’s phone rings at the same time — they know an authentication attempt is happening and can react.
This is documented in fallback risks.
Worked example: the carrier pigeon¶
Useful as a minimal template — strip the network calls, keep the shape:
# myapp/mfa.py
from .pigeon_dispatch import find_bird
def send_carrier_pigeon(user, message):
address = getattr(user, "address", None)
if not address:
return False
bird = find_bird()
bird.attach(message)
bird.send(address)
return (
"carrier pigeon" # appears in "We sent your code via carrier pigeon and email"
)
MULTIFACTOR = {
"FALLBACKS": {
"email": (lambda u: u.email, "multifactor.factors.fallback.send_email"),
"pigeon": (lambda u: u.address, "myapp.mfa.send_carrier_pigeon"),
},
}
Letting users opt out of a transport¶
The DisabledFallback model records per-user opt-outs. If you want to
expose a “don’t ever text me” toggle in your account UI, create rows by hand:
from multifactor.models import DisabledFallback
DisabledFallback.objects.get_or_create(user=user, fallback="sms")
The dispatch loop checks name in disabled_fallbacks(request) and skips
that transport entirely (factors/fallback.py:36).
There is no built-in admin UI for these opt-outs at present — see the admin integration guide for adding one if needed.
Disabling fallback entirely¶
MULTIFACTOR = {
"FALLBACKS": {},
}
Be aware: a user who loses access to all their primary factors can no longer
authenticate. Reset paths become a manual admin task (UserKey.objects.filter (user=...).delete() followed by user re-enrolment).
Testing your transport¶
A console-printing transport is provided for development:
multifactor.factors.fallback.debug_print_console. Wire it up in your dev
settings:
MULTIFACTOR = {
"FALLBACKS": {
"debug-console": (
lambda u: u,
"multifactor.factors.fallback.debug_print_console",
),
},
}
The OTP is printed to your runserver console — perfect for local testing
without firing real SMS or emails. The bundled testsite/ uses this
pattern.
Hardening checklist¶
Rate-limit
/admin/multifactor/fallback/auth/. Generating fresh OTPs is essentially free for an attacker; sending hundreds of emails is not free for you. Cap per-IP and per-user.Log every dispatch. Both successes and failures. A spike in fallback use across many users is a phishing-campaign signature.
Audit your predicates. A predicate that returns a phone number copied from an attacker-controlled field is a credential-leak vector.
Verify the secret stays in the session. If you’re using a non-default session backend (e.g. signed cookies), confirm the OTP is not exposed in the cookie payload.
See also¶
Fallback risks — why fan-out matters.
multifactor.factors.fallback— the dispatch implementation.