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.
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.
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.
pip install webauthn # py_webauthn — server-side verification
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,
)
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.
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.