Passwordless Authentication in Django: Magic Links & OTP

Blog / Django · July 13, 2019 · Updated June 10, 2026 · 10 min read
Passwordless Authentication in Django: Magic Links & OTP

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() (a TimestampSigner under the hood) and verify it with signing.loads(..., max_age=...), so tokens expire automatically.
  • A custom auth backend's authenticate() returns the user with no password check — gate it behind a verified=True flag 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.email

Step 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 None

Register 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 None

Step 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 data

Then 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 4

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

Share this article