Passwordless authentication in Django lets users sign in with a one-time email magic link or a short-lived one-time passcode (OTP) instead of a stored password — you verify a signed, expiring token and then call Django's login() to start the session, so no password is ever set, transmitted, or breached.
This guide rebuilds the classic "custom passwordless backend" pattern for Django 5.x (5.2 LTS, 2026) using django.core.signing, a custom authentication backend, and send_mail, then shows where libraries like django-sesame and passkeys/WebAuthn fit.
Key takeaways
- Passwordless authentication = prove control of an email or phone, then log the user in — there is no password to leak, reuse, or phish.
- Generate the link with
signing.dumps()(aTimestampSignerunder the hood) and verify it withsigning.loads(..., max_age=...), so tokens expire automatically. - A custom auth backend's
authenticate()returns the user with no password check — gate it behind averified=Trueflag so it can never become a silent password bypass. - Three non-negotiable security controls: short expiry (10-15 min), single-use tokens (a nonce in the cache), and rate limiting on the request endpoint.
- For production, prefer battle-tested options: django-sesame for magic links, django-otp for OTP/TOTP, and passkeys/WebAuthn as the 2026 direction.
What is passwordless authentication in Django?
Passwordless authentication removes the password from the login flow entirely. Instead of username + password, the user proves they control a channel you can reach — usually their email inbox or phone — and your app trusts that proof to create an authenticated session.
In Django terms, the session is still created the normal way with django.contrib.auth.login(). What changes is how you decide the user is who they claim to be: rather than check_password(), you validate a signed, time-limited token (the magic link) or a short numeric code (OTP). Because nothing secret is stored on your side, there is no password hash to steal and no "reused password" blast radius.
This is the same mechanism you need for social login: with Google, Facebook, or Auth0 you never receive a password, yet you still must create a Django session. A custom authentication backend is the bridge. If you are adding hosted identity, see our walkthrough on single sign-on with Auth0 in a Django application and how to extend it to SSO across multiple applications.
Which passwordless method should you choose?
Each method trades convenience against security and build effort. Here is how the common options compare.
| Method | User experience | Security | Setup effort | Best for |
|---|---|---|---|---|
| Email magic link | Click a link in the inbox | Good — tied to email control; phishable if forwarded | Low (this guide) | Web apps, low-friction sign-up |
| Email / SMS OTP | Type a 6-digit code | Good; SMS is weaker (SIM-swap) | Low-medium | Mobile apps, step-up auth |
| Passkeys / WebAuthn | Face, fingerprint, or device PIN | Strongest — phishing-resistant, no shared secret | High (browser + device APIs) | New apps that want 2026-grade security |
| Social / SSO (OAuth) | "Continue with Google" | Delegated to the provider | Medium (provider setup) | Consumer apps, fast onboarding |
Magic links and OTP are the quickest to ship and the focus of this article. Passkeys are the strongest long-term option, and social SSO offloads identity to a provider entirely.
Step 1 — Use an email-first custom user model
Passwordless accounts are identified by email, not a chosen username, so start from a custom user model. Always do this at the start of a project — swapping AUTH_USER_MODEL later is painful, and our guide to creating a custom user model in Django covers the migration traps.
# accounts/models.py
from django.contrib.auth.models import AbstractUser
from django.db import models
class User(AbstractUser):
"""Email-first user.
AbstractUser still has a password field, but a passwordless account simply
never gets a usable one (Django stores an unusable hash by default).
"""
email = models.EmailField("email address", unique=True)
USERNAME_FIELD = "email"
REQUIRED_FIELDS = [] # "email" is already required as USERNAME_FIELD
def __str__(self):
return self.emailStep 2 — Write a passwordless authentication backend
Django's authenticate() walks every backend in AUTHENTICATION_BACKENDS until one returns a user. A passwordless backend simply skips the password check — but that makes it dangerous if called carelessly, so we require an explicit verified=True argument that only our token-verifying view is allowed to pass.
# accounts/backends.py
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import BaseBackend
User = get_user_model()
class MagicLinkBackend(BaseBackend):
"""Authenticate a user from a *verified* email, with no password check.
Only call this AFTER a signed magic-link token has been validated, so the
`email` passed in is proven to belong to the requester.
"""
def authenticate(self, request, email=None, verified=False, **kwargs):
# The verified flag makes accidental password-free login impossible.
if not email or not verified:
return None
try:
return User.objects.get(email__iexact=email, is_active=True)
except User.DoesNotExist:
return None
def get_user(self, user_id):
try:
return User.objects.get(pk=user_id)
except User.DoesNotExist:
return NoneRegister it alongside the default ModelBackend — keep that so the Django admin and any password accounts still work:
# settings.py
AUTH_USER_MODEL = "accounts.User"
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend", # keep admin / password login
"accounts.backends.MagicLinkBackend", # passwordless email login
]
# Email backend: prints to console in dev; use SES/SMTP in production.
DEFAULT_FROM_EMAIL = "no-reply@example.com"
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"Step 3 — Generate a signed, expiring magic-link token
Never put a raw user id in the URL — anyone could edit it. Use django.core.signing, which signs the payload with your SECRET_KEY and timestamps it. signing.dumps() uses a TimestampSigner internally, so signing.loads(..., max_age=...) rejects expired tokens for free. (SignatureExpired subclasses BadSignature, so one except covers both forged and stale tokens.)
# accounts/tokens.py
from django.core import signing
SALT = "accounts.magic-link" # namespaces the token to this flow only
MAX_AGE = 60 * 15 # link is valid for 15 minutes
def make_login_token(user):
"""Return a signed, timestamped token that carries only the user's pk."""
return signing.dumps({"uid": user.pk}, salt=SALT)
def read_login_token(token):
"""Return the decoded payload, or None if the token is forged or expired."""
try:
return signing.loads(token, salt=SALT, max_age=MAX_AGE)
except signing.BadSignature: # also catches SignatureExpired
return NoneStep 4 — Email the magic link
Collect the email, find or create the user, build an absolute URL that carries the token, and send it. Return the same response whether or not the account exists, so the form can't be used to enumerate which emails are registered.
# accounts/views.py
from django.contrib.auth import get_user_model
from django.core.mail import send_mail
from django.shortcuts import render
from django.urls import reverse
from django.views.decorators.http import require_http_methods
from .tokens import make_login_token
User = get_user_model()
@require_http_methods(["GET", "POST"])
def request_magic_link(request):
if request.method == "POST":
email = request.POST.get("email", "").strip().lower()
# Create the account on first sign-in, or fetch the existing one.
user, _ = User.objects.get_or_create(
email=email, defaults={"username": email}
)
token = make_login_token(user)
link = request.build_absolute_uri(
reverse("magic_login") + f"?token={token}"
)
send_mail(
subject="Your sign-in link",
message=f"Click to sign in (valid for 15 minutes):\n\n{link}",
from_email=None, # falls back to DEFAULT_FROM_EMAIL
recipient_list=[email],
)
# Same screen every time, so attackers can't probe for valid accounts.
return render(request, "accounts/link_sent.html")
return render(request, "accounts/request_link.html")Step 5 — Verify the token and log the user in
The verify view decodes the token, loads the matching user, runs them back through authenticate() (so the backend is recorded on the session), and calls login() to create the session.
# accounts/views.py (continued)
from django.contrib.auth import authenticate, login
from django.shortcuts import redirect
from .tokens import read_login_token
def magic_login(request):
data = read_login_token(request.GET.get("token", ""))
if not data:
return render(request, "accounts/link_invalid.html", status=400)
try:
user = User.objects.get(pk=data["uid"], is_active=True)
except User.DoesNotExist:
return render(request, "accounts/link_invalid.html", status=400)
# Re-run through the backend so request.user and @login_required work.
user = authenticate(request, email=user.email, verified=True)
if user is None:
return render(request, "accounts/link_invalid.html", status=400)
login(request, user) # creates the authenticated session
return redirect("dashboard")Wire up the two URLs:
# accounts/urls.py
from django.urls import path
from . import views
urlpatterns = [
path("login/", views.request_magic_link, name="request_magic_link"),
path("login/verify/", views.magic_login, name="magic_login"),
]How do you make magic links secure?
A signed token is tamper-proof, but on its own it is still replayable until it expires. Production-grade passwordless login needs three more controls:
- Short expiry — 10-15 minutes is plenty; we used
MAX_AGE = 60 * 15. - Single-use — bind a random nonce (
jti) to each link and delete it on first use, so a forwarded or logged link can't be reused. - Rate limiting — cap requests per email and per IP to stop inbox flooding and brute-force probing.
Here is the single-use upgrade to tokens.py, backed by Django's cache:
# accounts/tokens.py (single-use hardening)
import secrets
from django.core import signing
from django.core.cache import cache
SALT = "accounts.magic-link"
MAX_AGE = 60 * 15
def make_login_token(user):
jti = secrets.token_urlsafe(16)
cache.set(f"magic:{jti}", user.pk, timeout=MAX_AGE) # one-time record
return signing.dumps({"uid": user.pk, "jti": jti}, salt=SALT)
def read_login_token(token):
try:
data = signing.loads(token, salt=SALT, max_age=MAX_AGE)
except signing.BadSignature:
return None
# Pop the nonce: a second click finds nothing, so the link works only once.
if cache.get(f"magic:{data['jti']}") != data["uid"]:
return None
cache.delete(f"magic:{data['jti']}")
return dataThen rate-limit the request view with django-ratelimit, keyed on both the submitted email and the client IP:
# accounts/views.py
from django_ratelimit.decorators import ratelimit
@ratelimit(key="post:email", rate="5/h", method="POST", block=True)
@ratelimit(key="ip", rate="20/h", method="POST", block=True)
@require_http_methods(["GET", "POST"])
def request_magic_link(request):
... # body unchanged from Step 4Should you build it or use a library?
Hand-rolling is great for learning and full control, but for production you usually shouldn't reinvent token handling. Two mature, well-audited libraries cover most needs:
- django-sesame — passwordless magic links with one backend and one helper, including single-use and scoped tokens.
- django-otp — pluggable OTP/TOTP devices, ideal for 2FA and email or SMS codes.
| Approach | Control | Maintenance | Single-use built in | Best for |
|---|---|---|---|---|
| Hand-rolled (this guide) | Full | You own it | No (DIY nonce) | Learning, bespoke flows |
| django-sesame | High | Maintained | Yes | Most magic-link apps |
| django-otp | High | Maintained | Yes (per device) | OTP / 2FA, step-up auth |
django-sesame reduces the whole flow to a few lines:
# pip install django-sesame
# settings.py
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"sesame.backends.ModelBackend",
]
# views.py
from django.core.mail import send_mail
from sesame.utils import get_query_string
def send_sesame_link(request, user):
link = request.build_absolute_uri("/login/" + get_query_string(user))
send_mail("Your sign-in link", link, None, [user.email])What about passkeys, APIs, and authorization?
Passkeys / WebAuthn are where authentication is heading in 2026: a phishing-resistant key pair stored on the user's device and unlocked by biometrics. They remove the "click the email in time" friction of magic links entirely. Django doesn't ship WebAuthn, but libraries such as django-passkeys and the webauthn package make it practical — plan for it as a first-class option, not an afterthought.
APIs and mobile apps can't follow a browser redirect, so issue a token after verification instead of a session cookie. See our guide to token-based authentication in Django REST Framework.
Authorization is the next step after login: once a user is authenticated, control what they can do with our pattern for custom decorators that check user roles and permissions in Django.
Build secure passwordless login with MicroPyramid
Passwordless and passkey authentication spans security, email deliverability, and UX — easy to get subtly wrong. MicroPyramid has shipped Django authentication, SSO, and security work for startups and enterprises for 12+ years (since 2014), across 50+ delivered projects. If you want magic links, OTP, passkeys, or a full identity overhaul done right, explore our Django development services and tell us about your project.
Frequently Asked Questions
Is passwordless authentication secure in Django?
Yes — done correctly it is usually more secure than passwords, because there is no password hash to steal and no reused-password risk. The security depends on the token, not the user: sign it with Django's SECRET_KEY via django.core.signing, give it a short 10-15 minute expiry, make it single-use with a cached nonce, and rate-limit the request endpoint. Skip those controls and a leaked link becomes a replayable backdoor.
How long should a magic link be valid?
Keep magic links short-lived — 10 to 15 minutes is the common sweet spot. Long enough that a user can switch from your site to their inbox and back, short enough that an intercepted or forwarded link is unlikely to still work. In Django you enforce this by passing max_age (in seconds) to signing.loads(); an older token raises SignatureExpired and is rejected automatically.
Can I make a Django magic link single-use?
Yes. A plain signed token is replayable until it expires, so add a one-time nonce: store a random jti in the cache when you create the link, embed it in the signed payload, and delete it on first verification. A second click then finds nothing in the cache and is rejected. Libraries like django-sesame offer single-use and one-time tokens out of the box.
Do I still need a password field on my user model?
You don't need to use one, but Django's AbstractUser/AbstractBaseUser includes a password field regardless. For a passwordless account simply never set a usable password — Django stores an unusable hash by default, so check_password() always fails and only your magic-link backend can log the user in. Keeping the field also lets you offer optional password or admin login later.
Should I use django-sesame or build my own magic links?
Build your own to learn the mechanics or when you need a bespoke flow. For production, django-sesame is the safer default: it handles signed, single-use, and scoped tokens with one authentication backend and a small helper, so you avoid subtle token-handling bugs. Use django-otp instead when you need numeric OTP codes or two-factor authentication rather than links.
How is passwordless different from two-factor authentication (2FA)?
Passwordless replaces the password as the single login factor — you sign in with just a magic link or OTP. 2FA adds a second factor on top of a first one (often a password plus an OTP). The building blocks overlap (OTP, TOTP, passkeys), but the goal differs: passwordless removes the password, while 2FA strengthens whatever the first factor is. Passkeys/WebAuthn can serve as a strong single factor and a second factor at once.