Passkeys and WebAuthn: A Rollout Guide for Apps
Passwords are the worst part of almost every app. Users reuse them, forget them, and get phished out of them, and you, the operator, carry the liability of storing them. Passkeys are the industry's answer, and by 2026 they are mainstream: every major browser and mobile platform supports them, the big consumer apps offer them by default, and password managers sync them. What most articles skip is the part that decides whether a rollout succeeds: the relying party decisions you make once and live with, account recovery, and how to run passkeys beside passwords without doubling your attack surface. That is what this piece is about.
What a passkey actually is
A passkey replaces the password with a pair of cryptographic keys. When a user enrolls, their device generates a key pair: a private key that never leaves the device (guarded by the phone's secure hardware and unlocked with Face ID, a fingerprint or the device PIN), and a public key that you store against the user. To sign in, your server sends a random challenge, the device signs it with the private key, and your server verifies the signature with the public key it holds. The crucial part is that there is no shared secret. You only ever store a public key, which is useless to a thief. There is nothing to leak in a breach, nothing to reuse across sites, and nothing for a fake login page to capture.
The phishing resistance is structural, not behavioral. A passkey is bound to your domain at creation: the browser records which site it belongs to and will only offer it on that site. A user who lands on a look-alike domain is not asked to be careful; their device simply has no matching credential to present. Phishing, the single most common way accounts get taken over, stops working regardless of how convincing the page is.
A passkey cannot be phished, reused, or stolen from your database, because the only secret never leaves the user's device.
The WebAuthn flow, step by step
The underlying standard is WebAuthn, the web half of FIDO2, implemented natively by browsers and mobile operating systems. You do not implement the cryptography yourself; a maintained server library handles challenges and signature verification, and the platform handles the device side. There are two short ceremonies, and each one is a round trip.
Registration. The server generates options: a random challenge, your relying party id, the user's id and display name, and your preferences for the credential. The browser hands those to the platform authenticator, the user approves with a biometric, and a new key pair is created. The public key, the credential id and a signature counter come back to the server, which verifies the origin and challenge and stores the record.
// Registration, server side (pseudocode around a WebAuthn library)
const options = await webauthn.generateRegistrationOptions({
rpID: "example.com", // your relying party id
rpName: "Example",
userID: user.id, userName: user.email,
authenticatorSelection: {
residentKey: "required", // discoverable: enables username-less sign-in
userVerification: "preferred",
},
excludeCredentials: user.passkeys.map((p) => ({ id: p.credentialId })),
});
session.challenge = options.challenge; // keep it server side, single use, short lived
// ...browser: const cred = await navigator.credentials.create({ publicKey: options });
const verified = await webauthn.verifyRegistrationResponse({
response: cred, expectedChallenge: session.challenge,
expectedOrigin: "https://example.com", expectedRPID: "example.com",
});
await db.passkeys.insert({ userId: user.id, credentialId, publicKey, counter, transports });Authentication. The server issues a fresh challenge; the browser asks the authenticator to sign it with a credential for this domain; the server looks up the public key by credential id, verifies the signature, checks the challenge and origin, and opens a session. If the credential was created as a discoverable one, the browser can list the user's passkeys for your site without you asking for a username first, which is the one-tap sign-in users now expect. Both ceremonies take under a second and there is no typing.
Relying party decisions you make once
A handful of choices at setup shape the whole system, and some are awkward to change later.
The relying party id
The RP id is the domain the credential is bound to, and every passkey your users create is tied to it forever. It must be your registrable domain or a subdomain of it; you cannot use a different domain. The usual choice is the apex (example.com) rather than app.example.com, so credentials work across every subdomain you might add. Choose it before the first passkey exists, because a change orphans every credential created under the old id.
Discoverable credentials
Asking for a resident or discoverable key means the authenticator stores the user handle alongside the private key, which enables sign-in with no username field. That is the experience people mean when they say passkeys are faster. The trade-off is that hardware security keys have limited slots for discoverable credentials; for a consumer app it is the right default, and for an enterprise app that expects YubiKeys you may want to make it preferred rather than required.
User verification and attestation
User verification is the biometric or PIN check. “Preferred” is the sane default for most products; “required” is for high-value actions where you want proof a human unlocked the device. Attestation, the authenticator proving which make and model it is, sounds useful and almost never is for consumer apps; request none unless a regulator or an enterprise policy demands specific hardware, because requiring it breaks sync and adds a certificate chain you must maintain.
Synced or device-bound
Modern passkeys sync through the platform's keychain (iCloud Keychain, Google Password Manager, or a third-party manager), so a new phone gets the credential after signing into the platform account. That sync is what turned a clever idea into something shippable to ordinary users without a flood of lockouts. It also means the security of the passkey now includes the security of that cloud account. For nearly every product that is an excellent trade. For a few, a bank's high-value transaction step or an admin console, you may prefer device-bound credentials on hardware keys, and the response tells you which kind you got via the backup-eligible flag so you can apply policy per credential.
Account recovery is the real hard part
Here is the uncomfortable truth: the security of your sign-in is the security of its weakest recovery path. A phishing-proof passkey protected by a “forgot access? click this emailed link” flow is exactly as strong as the emailed link. Passkeys do not make recovery harder than passwords did; they make it impossible to keep ignoring, because the failure mode is no longer “I forgot it” but “the device that held it is gone”.
The layered approach that works in practice:
- Encourage more than one passkey. Sync covers the lost-phone case within one ecosystem; a second passkey on a laptop, a security key, or a partner's phone covers switching ecosystems. Prompt for it after the first enrollment, not during.
- Keep a second strong factor for the recovery path: a verified email plus a TOTP code, or a set of one-time recovery codes generated at enrollment and shown once. A recovery code is a password the user cannot reuse or be phished for, because they only ever type it into your recovery page.
- Slow the recovery path down deliberately. A delay of a day with a notification to every other channel the user has (email, existing device) turns a silent takeover into one the real owner can interrupt. Fast recovery is convenient; it is also what attackers want.
- Never let support “just reset it” without a defined identity check. Social engineering of support staff is how high-profile passwordless accounts have actually been taken over.
Write the recovery flow down before the enrollment flow. If the document is one line long, the design is not finished.
Rolling out beside passwords
You do not flip everything overnight. A sane rollout keeps the existing password or magic-link login working and adds passkeys as the better door, then nudges. The sequence we recommend:
- Add, do not replace. A user who has a passkey can still sign in with the password until they choose otherwise. Store passkeys in their own table, one row per credential, so a user can have several and you can revoke one without touching the rest.
- Enroll after a successful sign-in, when the user is already authenticated and in a good mood, not on the registration screen where every extra step costs conversions. The prompt should be one sentence and skippable.
- Use conditional UI. Browsers can offer a saved passkey inside the ordinary username field via autofill, so users who have one are recognized instantly and users who do not see no change. It is the single largest usability win in the whole standard and it is a few lines of code.
- Measure and then retire. Once a user signs in with a passkey repeatedly, offer to remove the password. Only after that do you stop accepting passwords for that account, and only for that account. A system with two live sign-in methods is only as strong as the weaker one, so the goal is a per-user transition, not a permanent dual state.
The compliance argument writes itself once the system exists: you can prove you never stored anything an attacker could use, and a breach of your user table is a breach of a list of public keys.
Native apps: what changes on iOS and Android
The same credential works in a browser and in your native app, which is the point, but the plumbing differs. On iOS you use the AuthenticationServices framework and, on Android, the Credential Manager API; in a cross-platform stack there are maintained packages for both Flutter and React Native that wrap them. The part teams miss is domain association. A native app must prove it is allowed to use passkeys for your RP id, and it does so through a file you host on your domain: the Apple App Site Association file with a webcredentials entry, and Android's assetlinks.json declaring your app's signing certificate. If those files are wrong, the platform silently refuses to offer the passkey and the bug looks like nothing happening. Ship them before the first build goes to testers, and remember that Android verifies the signing key, so a debug build and a release build are different apps until both fingerprints are listed. Users on a laptop without a synced passkey can still sign in with their phone through a QR code and a Bluetooth proximity check, so “the passkey is on the other device” is a solved problem rather than a support ticket.
Common mistakes we see in reviews
- Generating the challenge on the client, or reusing one. It must be server-issued, random, single-use and short-lived.
- Skipping the origin check, which is the exact check that makes phishing fail.
- Ignoring the signature counter. A counter that goes backwards suggests a cloned credential; log it at minimum.
- Binding to a subdomain, then wanting the credential elsewhere a year later.
- Requiring attestation for a consumer app and wondering why enrollment fails on synced passkeys.
- Building beautiful enrollment and no recovery, which is the entire threat model relocated.
The takeaway
Passkeys are faster for users, immune to phishing, and they remove your most dangerous liability: stored passwords. The tooling is mature, the platforms all support it, and users already know the gesture from unlocking their phones. What decides a rollout is the boring engineering around it: a relying party id chosen once, a recovery path as strong as the front door, and a per-user transition off passwords instead of a permanent dual state. Authentication is part of every web platform and mobile app we build, and if you are considering an audit of what you have, that is exactly what our software audit is for. To talk through passwordless login for your product, get in touch, or see what we have shipped.
Frequently asked questions
What is a passkey and how is it different from a password?
A passkey is a pair of cryptographic keys. The private key stays on the user's device, protected by its secure hardware and unlocked with a biometric or PIN, and your server stores only the public key. To sign in, the server sends a random challenge, the device signs it, and the server verifies the signature. There is no shared secret, so nothing in your database is useful to a thief, nothing can be reused across sites, and because the credential is bound to your domain, a look-alike phishing site is never offered it.
What is the relying party id and why does it matter?
The relying party id is the domain every passkey is bound to, and it cannot be changed without orphaning every credential created under the old one. It must be your registrable domain or a subdomain of it. Most products choose the apex domain, for example example.com rather than app.example.com, so the same credentials work on any subdomain added later. Choose it before the first passkey exists.
What happens if a user loses the device with their passkey?
Synced passkeys survive a lost phone within one ecosystem, because iCloud Keychain, Google Password Manager or a third-party manager restores them on the new device. Beyond that, the recovery path is the real design work: encourage a second passkey on another device, keep a strong second factor such as one-time recovery codes or a verified email plus a TOTP code, slow recovery down deliberately with notifications to the user's other channels, and never let support reset access without a defined identity check. The sign-in is only as strong as its weakest recovery path.
Can I add passkeys without removing passwords?
Yes, and that is the recommended rollout. Keep the existing password or magic-link login, store passkeys in their own table with one row per credential, enroll users after a successful sign-in rather than on the registration screen, and use the browser's conditional UI so users with a passkey are recognized in the ordinary username field. Once a user signs in with a passkey repeatedly, offer to remove the password for that account. The goal is a per-user transition, because a system with two live sign-in methods is only as strong as the weaker one.
Do passkeys work in native iOS and Android apps?
Yes, and the same credential works in the browser and the app. On iOS you use the AuthenticationServices framework and on Android the Credential Manager API, with maintained packages available for Flutter and React Native. The step teams miss is domain association: the app must prove it may use passkeys for your relying party id through an Apple App Site Association file with a webcredentials entry and an Android assetlinks.json listing your app's signing certificate fingerprints. If those files are wrong, the platform silently refuses to offer the passkey.
Should I require attestation or user verification?
User verification, the biometric or PIN check, should usually be preferred rather than required, with required reserved for high-value actions where you want proof that a human unlocked the device. Attestation, which proves the authenticator's make and model, is rarely useful for consumer apps and requiring it breaks synced passkeys and adds a certificate chain to maintain. Request it only when a regulator or an enterprise policy demands specific hardware.
Thinking about building this?
Appluex designs and ships production mobile & web apps. Including AI features. Let's talk.