Security Advanced

Passwordless Django: WebAuthn and Passkeys for Phishing-Resistant Authentication

Kill the password. Implement WebAuthn/passkeys in Django end to end — registration and authentication ceremonies, public-key credential storage, the security model that makes passkeys phishing-resistant, and a sane fallback strategy.

DjangoZen Team Jun 06, 2026 3 min read 6 views

Passwords are the root cause of most breaches: reused, phished, stuffed, and leaked by the billion. WebAuthn replaces them with public-key cryptography backed by the user's device — Face ID, Touch ID, Windows Hello, or a hardware key. Passkeys are syncable WebAuthn credentials. They are phishing-resistant by design. Here's how to ship them on Django.

The security model

On registration, the user's device (the authenticator) generates a key pair. The private key never leaves the device's secure enclave; the server stores only the public key. To log in, the server sends a random challenge, the device signs it with the private key, and the server verifies the signature with the stored public key.

The phishing resistance comes from one detail: the signature is bound to your origin (djangozen.com). A fake djangozenn.com phishing site can't produce a valid signature, because the browser refuses to use the credential on the wrong origin. There is no secret to steal, phish, or replay.

Setup

pip install webauthn   # py_webauthn — server-side verification

Registration ceremony

from webauthn import generate_registration_options, verify_registration_response
from webauthn.helpers.structs import PublicKeyCredentialDescriptor

def register_begin(request):
    opts = generate_registration_options(
        rp_id="djangozen.com",
        rp_name="DjangoZen",
        user_name=request.user.email,
        user_id=str(request.user.id).encode(),
    )
    request.session["reg_challenge"] = opts.challenge
    return JsonResponse(options_to_json(opts))

The browser calls navigator.credentials.create() with those options; you verify the result and store the credential:

def register_complete(request):
    v = verify_registration_response(
        credential=json.loads(request.body),
        expected_challenge=request.session["reg_challenge"],
        expected_rp_id="djangozen.com",
        expected_origin="https://djangozen.com",
    )
    Credential.objects.create(
        user=request.user,
        credential_id=v.credential_id,
        public_key=v.credential_public_key,
        sign_count=v.sign_count,
    )

Authentication ceremony

Mirror image: generate_authentication_options() issues a challenge, the browser calls navigator.credentials.get(), and verify_authentication_response() checks the signature against the stored public key. Critically, verify the sign counter went up — a counter that goes backward signals a cloned authenticator and should be rejected.

Fallback and recovery

  • Account recovery is the hard part — if the device is lost and the passkey didn't sync, the user is locked out. Offer recovery codes or a verified-email reset.
  • Allow multiple credentials per user (phone + laptop + hardware key).
  • Keep a fallback (magic link / TOTP) during rollout; don't strand users on older devices.

Summary

WebAuthn moves the secret off your servers entirely: you store public keys, the device keeps the private key, and origin binding makes phishing structurally impossible. Implement both ceremonies with py_webauthn, always verify the sign counter, allow multiple credentials, and plan recovery carefully — that's the one place passwordless can lock users out. It's the strongest authentication you can offer in 2026.