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 18 min read 10 views

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.

Why passwords keep failing

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.

The security model

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.

Why it is phishing-resistant

The property that makes WebAuthn special is that every credential is bound to your origin — the exact domain, like djangozen.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 djangozenn.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.

Passkeys versus device-bound credentials

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.

Setup

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.PositiveIntegerField(default=0)
    name = models.CharField(max_length=100, blank=True)   # "My iPhone"
    created_at = models.DateTimeField(auto_now_add=True)

The registration ceremony

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

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(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

def register_complete(request):
    v = verify_registration_response(
        credential=json.loads(request.body),
        expected_challenge=request.session.pop("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,
    )
    return JsonResponse({"status": "registered"})

The authentication ceremony

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.

The signature counter

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.

Allow multiple credentials per user

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.

Account recovery — the hard part

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.

Rolling out without stranding users

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.

User experience details that matter

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.

Browser and platform support in 2026

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.

Attestation: knowing what authenticator was used

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.

Combining passkeys with enterprise SSO

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.

Migrating an existing password userbase

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.

The FIDO2 standards behind it

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.

Summary

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.