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 across sites, phished, stuffed from leaked databases, and dumped 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 security key. Passkeys are WebAuthn credentials that sync across a user's devices. Crucially, they are phishing-resistant by design, not by user vigilance. This tutorial implements them end to end on Django and explains the security model that makes them the strongest authentication you can offer.
Every password-based system shares the same fatal property: there is a shared secret that both the user and the server know, and that secret can be stolen. Users reuse passwords, so one breached site compromises many; attackers phish them with convincing fake login pages; leaked password databases feed credential-stuffing bots that try billions of combinations; and even hashed passwords are crackable at scale. Two-factor authentication helps but does not fix the root cause — SMS codes are interceptable and even app-based codes can be phished in real time by a proxy that relays them. The only durable fix is to stop having a shared secret at all.
That is exactly what public-key authentication does. There is no secret on the server to steal, nothing to phish, and nothing to reuse across sites. WebAuthn is the web standard that brings this to browsers, and it is supported everywhere that matters in 2026.
On registration, the user's device — the authenticator — generates a public/private key pair specific to your site. The private key never leaves the device's secure hardware (the secure enclave or TPM); your server receives and stores only the public key. To authenticate later, the server sends a random challenge, the device signs it with the private key (after a local biometric or PIN check), and the server verifies the signature using the stored public key. The server never holds anything an attacker could steal and replay.
This inverts the trust model. With passwords, the server holds the secret and is the juicy target; with WebAuthn, the secret stays on the user's device and the server holds only public keys, which are useless to an attacker. A breach of your database leaks no usable credentials at all.
The property that makes WebAuthn special is that every credential is bound to your origin — the exact domain, like example.com. The browser enforces this: it will only use a credential on the origin that created it. So when a user lands on a look-alike phishing site at exarnple.com, the browser simply refuses to produce a signature, because no credential exists for that origin. There is nothing for the user to get wrong — no code to read out, no password to mistype into the wrong box. The phishing attack fails structurally, at the protocol level, regardless of how convincing the fake page looks. This is something no password or one-time-code system can offer.
A passkey is a WebAuthn credential that the platform syncs across the user's devices through their account (Apple, Google, or a password manager). The benefit is convenience and recovery: lose your phone, and your passkey is still available on your laptop and your new phone. A device-bound credential, by contrast, lives on one piece of hardware — typically a security key like a YubiKey — and never leaves it, which is more secure but means losing it means losing that credential. For consumer apps, syncable passkeys are usually the right default because they solve the recovery problem; for high-security environments, device-bound hardware keys may be required. Your server code is nearly identical for both.
Use the py_webauthn library for the server-side cryptographic verification — do not hand-roll the COSE and CBOR parsing, which is intricate and easy to get subtly wrong:
pip install webauthn # py_webauthn
You will store credentials in a model linked to the user, holding the credential ID, the public key, and a signature counter:
class Credential(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE,
related_name="credentials")
credential_id = models.BinaryField(unique=True)
public_key = models.BinaryField()
sign_count = models.PositiveBigIntegerField(default=0) # counter is unsigned 32-bit
name = models.CharField(max_length=100, blank=True) # "My iPhone"
created_at = models.DateTimeField(auto_now_add=True)
The minimal model above is enough to verify signatures, but running the feature well needs a little more metadata. The verification result tells you whether the credential is a single-device key or a multi-device (synced) passkey and whether it is currently backed up; the browser reports which transports the authenticator supports; and a last-used timestamp lets users spot and revoke credentials they no longer recognise. The counter is an unsigned 32-bit value in the specification, so a big-integer column avoids any overflow edge case on databases where a positive integer tops out at 2,147,483,647:
class Credential(models.Model):
# ... fields shown above ...
sign_count = models.PositiveBigIntegerField(default=0)
transports = models.JSONField(default=list, blank=True) # ["internal", "hybrid"]
device_type = models.CharField(max_length=20, blank=True) # single_device / multi_device
backed_up = models.BooleanField(default=False)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
indexes = [models.Index(fields=["user", "-last_used_at"])]
Keep your relying party settings in one place rather than repeating literals in every view. A mismatch between the RP ID used at registration and at authentication is the single most common cause of "it worked yesterday" bugs:
# settings.py
WEBAUTHN_RP_ID = "example.com"
WEBAUTHN_RP_NAME = "ExampleShop"
WEBAUTHN_ORIGIN = "https://example.com"
WEBAUTHN_CHALLENGE_TTL = 300 # seconds
The RP ID must be the page's host or a registrable suffix of it. Choosing example.com means credentials work on both example.com and app.example.com; choosing app.example.com ties them to that subdomain. Credentials cannot be moved to a new RP ID later, so decide this before the first user enrols.
Registration has two steps. First, the server generates options containing a random challenge and your site's identifying information, and stashes the challenge in the session to verify against later:
from webauthn import generate_registration_options, options_to_json
from webauthn.helpers import bytes_to_base64url
def register_begin(request):
opts = generate_registration_options(
rp_id="example.com",
rp_name="ExampleShop",
user_name=request.user.email,
user_id=str(request.user.id).encode(),
)
# bytes are not JSON-serialisable; store the challenge as base64url text
request.session["reg_challenge"] = bytes_to_base64url(opts.challenge)
return JsonResponse(json.loads(options_to_json(opts)))
The browser passes those options to navigator.credentials.create(), which prompts the user for their biometric or PIN, generates the key pair, and returns a credential. The server verifies it against the stored challenge and persists the public key:
from webauthn import verify_registration_response
from webauthn.helpers import base64url_to_bytes
def register_complete(request):
v = verify_registration_response(
credential=json.loads(request.body),
expected_challenge=base64url_to_bytes(request.session.pop("reg_challenge")),
expected_rp_id="example.com",
expected_origin="https://example.com",
)
Credential.objects.create(
user=request.user,
credential_id=v.credential_id,
public_key=v.credential_public_key,
sign_count=v.sign_count,
)
return JsonResponse({"status": "registered"})
The challenge returned by generate_registration_options() is raw bytes. Django's default session serializer is JSON, which cannot store bytes, so in a real project encode it with base64url before saving and decode it before verification. Store an issue time alongside it so a stale ceremony is rejected, and pop it so it can be used exactly once. It is also worth telling the authenticator what you want: a discoverable credential (so the user can sign in without typing a username), user verification (so possession alone is not enough), and an exclusion list so the same device is not registered twice:
import time
from django.conf import settings
from webauthn.helpers import bytes_to_base64url, base64url_to_bytes
from webauthn.helpers.structs import (
AuthenticatorSelectionCriteria,
PublicKeyCredentialDescriptor,
ResidentKeyRequirement,
UserVerificationRequirement,
)
def stash_challenge(request, key, challenge):
request.session[key] = {"c": bytes_to_base64url(challenge), "t": time.time()}
def take_challenge(request, key):
data = request.session.pop(key, None)
if not data or time.time() - data["t"] > settings.WEBAUTHN_CHALLENGE_TTL:
return None
return base64url_to_bytes(data["c"])
@login_required
@require_POST
def register_begin(request):
existing = [
PublicKeyCredentialDescriptor(id=bytes(c.credential_id))
for c in request.user.credentials.all()
]
opts = generate_registration_options(
rp_id=settings.WEBAUTHN_RP_ID,
rp_name=settings.WEBAUTHN_RP_NAME,
user_name=request.user.email,
user_id=str(request.user.pk).encode(),
exclude_credentials=existing,
authenticator_selection=AuthenticatorSelectionCriteria(
resident_key=ResidentKeyRequirement.REQUIRED,
user_verification=UserVerificationRequirement.REQUIRED,
),
)
stash_challenge(request, "reg_challenge", opts.challenge)
return JsonResponse(json.loads(options_to_json(opts)))
Two details deserve attention. First, bytes(c.credential_id) matters because on PostgreSQL BinaryField values come back as memoryview objects. Second, the user_id becomes the credential's user handle, which the authenticator stores and returns at sign-in. Using the primary key is simple, but a random opaque value stored on the user (for example 32 random bytes) avoids exposing sequential IDs to the authenticator and survives primary-key migrations. In register_complete, verify with require_user_verification=True and save v.credential_device_type, v.credential_backed_up and the transports list from the browser's response into the extra fields.
Authentication mirrors registration. The server issues a fresh challenge with generate_authentication_options(); the browser calls navigator.credentials.get(), the user approves with their biometric, and the device signs the challenge; the server verifies the signature against the stored public key with verify_authentication_response(). If verification passes, you log the user in with Django's session machinery exactly as you would after a password check. The difference is that what you verified is an unforgeable cryptographic signature bound to your origin, not a guessable secret.
With discoverable credentials, the begin step needs no username at all: send an empty allow_credentials list and the authenticator offers whatever passkeys it holds for your RP ID. The complete step then looks up the credential by the ID the browser returns, verifies the signature, updates the counter under a row lock, and logs the user in:
from django.contrib.auth import login
from django.db import transaction
from django.utils import timezone
from webauthn import generate_authentication_options, verify_authentication_response
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
@require_POST
def login_begin(request):
opts = generate_authentication_options(
rp_id=settings.WEBAUTHN_RP_ID,
allow_credentials=[],
user_verification=UserVerificationRequirement.REQUIRED,
)
stash_challenge(request, "auth_challenge", opts.challenge)
return JsonResponse(json.loads(options_to_json(opts)))
@require_POST
def login_complete(request):
challenge = take_challenge(request, "auth_challenge")
if challenge is None:
return JsonResponse({"error": "expired"}, status=400)
body = json.loads(request.body)
try:
raw_id = base64url_to_bytes(body["rawId"])
except (KeyError, ValueError):
return JsonResponse({"error": "malformed"}, status=400)
with transaction.atomic():
cred = (Credential.objects.select_for_update()
.select_related("user").filter(credential_id=raw_id).first())
if cred is None or not cred.user.is_active:
return JsonResponse({"error": "sign-in failed"}, status=400)
try:
v = verify_authentication_response(
credential=body,
expected_challenge=challenge,
expected_rp_id=settings.WEBAUTHN_RP_ID,
expected_origin=settings.WEBAUTHN_ORIGIN,
credential_public_key=bytes(cred.public_key),
credential_current_sign_count=cred.sign_count,
require_user_verification=True,
)
except InvalidAuthenticationResponse:
return JsonResponse({"error": "sign-in failed"}, status=400)
cred.sign_count = v.new_sign_count
cred.last_used_at = timezone.now()
cred.save(update_fields=["sign_count", "last_used_at"])
login(request, cred.user, backend="django.contrib.auth.backends.ModelBackend")
return JsonResponse({"status": "ok"})
Return the same generic error for "unknown credential" and "verification failed" to the browser, and record the specific reason in your logs. Because login() rotates the session key, a session planted before sign-in cannot be used to fixate the authenticated one. If you use a user handle distinct from the primary key, also compare the userHandle in the response with the credential owner's stored handle as an extra consistency check.
The WebAuthn browser API works with ArrayBuffers, while your server speaks base64url JSON. Current browsers ship PublicKeyCredential.parseCreationOptionsFromJSON(), parseRequestOptionsFromJSON() and credential.toJSON(), which remove the conversion boilerplate; feature-detect them and fall back to a small base64url helper (or a maintained client library) for older browsers. The POSTs go to CSRF-protected Django views, so send the token header:
async function postJSON(url, data) {
const res = await fetch(url, {
method: "POST",
headers: {"Content-Type": "application/json",
"X-CSRFToken": getCookie("csrftoken")},
body: JSON.stringify(data ?? {}),
});
if (!res.ok) throw new Error((await res.json()).error || res.statusText);
return res.json();
}
async function registerPasskey() {
const json = await postJSON("/webauthn/register/begin/");
const publicKey = PublicKeyCredential.parseCreationOptionsFromJSON(json);
const cred = await navigator.credentials.create({publicKey});
return postJSON("/webauthn/register/complete/", cred.toJSON());
}
Conditional UI — passkeys offered in the username field's autofill dropdown — needs two things: the input marked with autocomplete="username webauthn", and a get() call started on page load with mediation: "conditional". The promise stays pending until the user picks a passkey, so start it once and abort it with an AbortController if the user chooses a different sign-in method:
<input name="username" autocomplete="username webauthn">
<script>
window.addEventListener("load", async () => {
if (!window.PublicKeyCredential ||
!(await PublicKeyCredential.isConditionalMediationAvailable?.())) return;
const json = await postJSON("/webauthn/login/begin/");
const publicKey = PublicKeyCredential.parseRequestOptionsFromJSON(json);
try {
const cred = await navigator.credentials.get({publicKey, mediation: "conditional"});
await postJSON("/webauthn/login/complete/", cred.toJSON());
window.location.assign("/account/");
} catch (err) {
if (err.name !== "NotAllowedError" && err.name !== "AbortError") showError(err);
}
});
</script>
Keep this script in a static file rather than inline if you run a strict Content Security Policy. A NotAllowedError covers both "user cancelled" and "timed out" — deliberately indistinguishable for privacy — so treat it as a quiet non-event, not an error banner.
Each credential maintains a counter that increments every time it signs. On every authentication, verify that the new counter is greater than the value you stored, then update your stored value. The purpose is clone detection: if an attacker somehow extracted and cloned a credential, the two copies would eventually produce a counter that goes backward or repeats, and you reject it. It is a subtle but important backstop — skipping the counter check removes a layer of defense — so always compare and persist it. Note that some platform authenticators always report zero; handle that case by treating a non-incrementing counter as acceptable only when the authenticator declares it does not support counters.
py_webauthn already enforces the comparison: if either the stored or the new counter is non-zero and the new value is not strictly greater, verify_authentication_response() raises InvalidAuthenticationResponse. Your job is to always pass the stored value and always persist new_sign_count — under the row lock shown earlier, so two near-simultaneous sign-ins cannot race and trip a false regression. Synced passkeys from the major platforms commonly report zero forever, which passes that check. A genuine regression on a hardware key is rare enough to justify a security alert: log it with the credential ID, notify the user, and consider disabling that one credential pending review rather than locking the entire account.
Real users have several devices, and they will register a passkey on their phone, their laptop, and perhaps a hardware key as backup. Model credentials as a collection per user, not a single field, and let users add and name new ones from their account settings ("Work laptop," "Personal phone"). This is not just convenience — it is resilience. A user with three registered credentials who loses one is not locked out, because the other two still work. Encourage users to register at least a second credential during onboarding, precisely so that losing a device is an inconvenience rather than a lockout.
The genuine challenge of passwordless is recovery: if a user loses access to all their credentials, how do they get back in without reopening the phishing hole you just closed? There is no perfect answer, only trade-offs. Syncable passkeys help enormously because the credential survives on other devices and in the platform account. Beyond that, offer one or more deliberate recovery paths: one-time recovery codes generated at registration and stored by the user, a verified-email magic link, or for high-value accounts an identity-verification step. Whatever you choose, the recovery path is now the weakest link in your authentication, so design it as carefully as the primary flow — an SMS-code reset undermines all of WebAuthn's phishing resistance.
Do not flip the entire user base to passwordless overnight. Roll it out gradually: offer passkey registration as an option alongside passwords, encourage it, and let users who have registered a passkey use it while others continue with their existing method. Keep a fallback authentication method available during the transition for users on older devices or browsers that lack support, and watch your support channels for friction. Over time, as adoption grows, you can make passkeys the default and eventually, for new accounts, the only option. A staged rollout behind a feature flag lets you move at the pace your users can absorb.
WebAuthn succeeds or fails on small UX choices. Use clear language — "Sign in with your fingerprint or face," not "Authenticate with WebAuthn" — because users do not know the jargon. Offer conditional UI (autofill-style passkey prompts) so signing in is a single tap. Handle the inevitable errors gracefully: a user who cancels the biometric prompt, a browser that does not support the feature, a credential that no longer exists. Each of these needs a friendly path forward rather than a cryptic failure. The technology is strong; whether users adopt it depends almost entirely on whether the experience feels easier than typing a password — and done well, it genuinely is.
WebAuthn and passkeys are no longer bleeding-edge — by 2026 they are supported across every major browser and operating system, with Apple, Google, and Microsoft all backing syncable passkeys through their platform accounts and password managers. This broad support is what finally makes passwordless practical for mainstream consumer apps rather than just security-conscious enterprises. That said, you will still encounter older devices, locked-down corporate environments, and the occasional unsupported browser, which is why a fallback method during rollout remains necessary. Check support at runtime with the platform's feature-detection APIs and present passkeys prominently where available while keeping a graceful path for the minority who cannot use them yet.
During registration, an authenticator can provide attestation — cryptographic evidence of what kind of device it is, signed by the manufacturer. Most consumer applications should request no attestation and not care, because demanding it adds friction and privacy concerns for little benefit. But in high-security or regulated settings you may need to require specific certified authenticators — for instance, only hardware keys that meet a FIDO certification level — and attestation is how you verify that. Understand the trade-off: attestation gives you assurance about the hardware at the cost of user privacy and convenience, so request it only when a real policy requires it, and default to none.
In business contexts, authentication often runs through an identity provider via SSO, and passkeys fit naturally alongside it. The identity provider itself can use WebAuthn as its authentication method, so users sign into the IdP with a passkey and your app receives a federated assertion as usual — you get phishing-resistant login without implementing WebAuthn in every application. Alternatively, for apps that authenticate users directly, passkeys can coexist with an SSO option, letting individual users choose. The key insight is that passkeys and SSO are not competitors: passkeys are an authentication method, SSO is a federation model, and the strongest enterprise setups use phishing-resistant passkeys as the method behind the SSO front door.
Most teams adopting WebAuthn already have millions of password users, and the migration is gradual by necessity. The proven path is to add passkeys as an option, then actively nudge users to enroll one at a natural moment — after a successful password login, prompt "make your next sign-in faster and safer with a passkey." Each user who enrolls reduces your password attack surface by one, and over months the passwordless share grows organically. Once a user has a passkey, you can encourage them to remove their password entirely, and eventually you can make passkeys mandatory for new accounts. The migration is a campaign, not a switch — measure enrollment rate as the metric of progress.
WebAuthn is one half of a broader standard called FIDO2, developed by the FIDO Alliance; the other half, CTAP, is the protocol by which browsers talk to external authenticators like security keys. You do not need to implement these protocols yourself — that is what libraries like py_webauthn and the browser APIs handle — but understanding that you are building on an open, multi-vendor, widely-certified standard rather than a proprietary scheme is reassuring for security review and procurement. It means your authentication is interoperable, has been scrutinized by the wider security community, and is not tied to any single vendor's roadmap. Standards-based security is durable security.
The cryptography is sound; the surrounding Django views are where mistakes creep in. A checklist for production:
@csrf_exempt to make fetch() work — send the token header instead.expected_origin from request headers such as Host or Origin, which the client controls. Behind a reverse proxy the value is still the public https:// origin the browser saw.login_begin writes to the session store on every call. Throttle it per IP at the proxy or with a cache-backed limiter so it cannot be used to flood your session table.Once login is phishing-resistant, the session cookie becomes the thing worth stealing, so pair passkeys with SESSION_COOKIE_SECURE = True, SESSION_COOKIE_HTTPONLY = True, a sensible SESSION_COOKIE_AGE, and HSTS via SECURE_HSTS_SECONDS. Malware that steals cookies from a device bypasses any login method; short sessions and re-verification for sensitive actions limit the damage.
Real signatures are awkward in unit tests, so split testing into two layers. For your view logic — challenge handling, counter persistence, error paths, login — patch the library's verify function and assert on what your code does with the result:
from types import SimpleNamespace
from unittest import mock
from django.test import TestCase
from webauthn.helpers import bytes_to_base64url
from webauthn.helpers.exceptions import InvalidAuthenticationResponse
class LoginCompleteTests(TestCase):
def setUp(self):
self.user = User.objects.create_user("ada@example.com", "ada@example.com")
self.cred = Credential.objects.create(
user=self.user, credential_id=b"cred-1", public_key=b"pk", sign_count=5)
self.client.post("/webauthn/login/begin/")
self.body = {"rawId": bytes_to_base64url(b"cred-1"), "id": "x",
"type": "public-key", "response": {}}
@mock.patch("accounts.views.verify_authentication_response")
def test_success_updates_counter_and_logs_in(self, verify):
verify.return_value = SimpleNamespace(new_sign_count=6)
r = self.client.post("/webauthn/login/complete/", self.body,
content_type="application/json")
self.assertEqual(r.status_code, 200)
self.cred.refresh_from_db()
self.assertEqual(self.cred.sign_count, 6)
self.assertEqual(int(self.client.session["_auth_user_id"]), self.user.pk)
@mock.patch("accounts.views.verify_authentication_response")
def test_challenge_is_single_use(self, verify):
verify.side_effect = InvalidAuthenticationResponse("bad")
self.client.post("/webauthn/login/complete/", self.body,
content_type="application/json")
r = self.client.post("/webauthn/login/complete/", self.body,
content_type="application/json")
self.assertEqual(r.json()["error"], "expired")
For the full cryptographic path, use a browser's virtual authenticator: Chromium's DevTools has a WebAuthn panel that emulates platform or USB authenticators with configurable resident-key and user-verification support, and the same capability is available to Selenium and Playwright through WebDriver and the Chrome DevTools Protocol. An end-to-end test that registers, signs out, and signs back in against http://localhost (a secure context, so WebAuthn works without TLS locally) catches RP ID and origin misconfiguration that mocked tests never will. Give localhost its own RP ID and origin values in your development settings.
Most WebAuthn bugs surface as a small set of recognisable errors:
| Symptom | Likely cause | Fix |
|---|---|---|
SecurityError in the browser before any prompt | RP ID is not the page's host or a registrable suffix of it, or the page is not served over HTTPS | Align WEBAUTHN_RP_ID with the domain; use HTTPS everywhere except localhost |
NotAllowedError | User cancelled, prompt timed out, or no matching credential on this device | Treat as a quiet cancel; offer "use another device" and fallback options |
InvalidStateError on registration | Authenticator already holds a credential listed in exclude_credentials | Tell the user this device is already registered |
| Server rejects with an unexpected origin | Staging hostname, non-default port, or http vs https mismatch | Set a per-environment origin in settings, including any port visible to the browser |
Session serialization TypeError | Raw challenge bytes stored in a JSON-serialized session | Base64url-encode the challenge before storing it |
| Credential never found at sign-in | Comparing a base64 string or memoryview against stored bytes | Decode rawId with base64url_to_bytes; convert DB values with bytes() |
| Sign-in works on one subdomain but not another | Credentials registered under a narrower RP ID | Use the registrable parent domain as RP ID for new credentials; existing ones must be re-enrolled |
When a report comes in, check the server log for the library's exception message first — py_webauthn names the specific check that failed (challenge, origin, RP ID hash, user verification flag, or counter), which usually points straight at the configuration line to change.
WebAuthn moves the secret off your servers entirely: the device keeps the private key in secure hardware, you store only public keys, and origin binding makes phishing structurally impossible rather than relying on user caution. Implement both the registration and authentication ceremonies with py_webauthn, always verify and persist the signature counter, and let each user hold multiple named credentials so losing one device is not a lockout. Plan recovery deliberately, because it becomes your weakest link, and prefer syncable passkeys for consumers precisely because they solve recovery. Roll out gradually behind a flag with a fallback, and invest in plain-language UX. Get these right and you offer the strongest, most user-friendly authentication available in 2026 — one where a database breach leaks nothing an attacker can use.