Passkeys for SaaS: Migrating From Passwords to WebAuthn Without Breaking Users
A mid-size B2B SaaS we worked with last quarter shipped its first passkey login on a Wednesday afternoon and rolled it back by Friday morning. Conversion on the new login screen had cratered: enterprise users on managed Windows fleets were being told their hardware did not support a security key, customer-success accounts shared between three people could no longer get in at all, and the recovery flow had been redesigned around an SMS code that never reached the half of the user base on European mobile carriers with strict short-code policies. None of that was a problem with passkeys themselves. It was a problem with assuming passkeys were a drop-in replacement for passwords.
Passkey implementation in a real SaaS is not a library swap; it is a multi-month migration that has to coexist with passwords, work across operating systems with very different UX, survive an enterprise IT department, and degrade gracefully when something between the user and the authenticator misbehaves. This post is a practical playbook for that migration in a Symfony and Next.js stack: the data model, the registration and login flows, the recovery story that does not undo all the security gains, the edge cases that tutorials skip, and a phased rollout sequence that does not lose users along the way.
Why passkeys matter in B2B SaaS in 2026
Passkeys have stopped being a consumer curiosity. Microsoft Entra ID, Google Workspace and Okta now ship passkey support out of the box; SOC 2 questionnaires increasingly ask whether the vendor supports phishing-resistant authentication; and the larger European banks have started writing passkey support into their procurement requirements as a hedge against the next round of NIS2 audits. For B2B SaaS specifically, the buyer pressure is no longer hypothetical — by the second half of 2026 it is reasonable to expect at least one enterprise prospect per quarter to ask for passkey support during the security review.
Underneath the marketing, a passkey is a WebAuthn discoverable credential bound to your relying-party identifier (your domain). The user's authenticator — a TPM, a Touch ID sensor, a hardware key, or a synced credential in iCloud Keychain or Google Password Manager — holds a private key that never leaves the device. The server holds the matching public key plus a credential ID. The login round-trip is a signed challenge, not a transmitted secret, which is what makes passkeys phishing-resistant: a credential bound to app.example.com simply will not produce a signature for app.example.com.attacker.tld.
That property is the entire reason this migration is worth doing. Everything else — the cleaner UX, the lower password-reset volume, the reduced credential-stuffing exposure — is a side effect.
The data model and registration flow
The first design decision is multiplicity. A user has many passkeys, not one. They will register a passkey on their work laptop, another on their phone, and a third on a backup hardware key. The schema has to reflect that from day one, or you will rebuild it six weeks in.
#[ORM\Entity]
#[ORM\Table(name: 'webauthn_credentials')]
class WebAuthnCredential
{
#[ORM\Id]
#[ORM\Column(type: 'uuid')]
public string $id;
#[ORM\ManyToOne(targetEntity: User::class, inversedBy: 'webauthnCredentials')]
public User $user;
#[ORM\Column(type: 'string', unique: true)]
public string $credentialId; // base64url
#[ORM\Column(type: 'text')]
public string $publicKey; // CBOR-encoded COSE key
#[ORM\Column(type: 'integer')]
public int $signCount;
#[ORM\Column(type: 'string', length: 64)]
public string $aaguid; // authenticator model identifier
#[ORM\Column(type: 'string', length: 32)]
public string $transport; // internal | hybrid | usb | ble
#[ORM\Column(type: 'datetimetz_immutable')]
public \DateTimeImmutable $createdAt;
#[ORM\Column(type: 'datetimetz_immutable', nullable: true)]
public ?\DateTimeImmutable $lastUsedAt = null;
#[ORM\Column(type: 'string', length: 80)]
public string $label; // user-chosen, e.g. "Work MacBook"
}
Storing the AAGUID and transport hints is what separates a polished implementation from a fragile one. The AAGUID lets you show the user a familiar icon for their YubiKey or iPhone in the credentials list. The transport hint lets the browser surface the right authenticator first during login. Both are visible in the registration response and free to capture; neither is easy to add later.
For registration, generate the challenge server-side, store it in a short-lived signed cookie or in the user session, and return it to the browser along with the relying-party metadata. On the client, call navigator.credentials.create() with userVerification: 'preferred' for the first rollout — 'required' is the right long-term setting but it locks out devices without a biometric or PIN, and you do not want to discover that after launch.
The login flow and the password fallback
The login flow that actually works in production is passkey first, password as fallback — not the inverse. The user lands on a single email-or-username field. When they submit it, the server returns a WebAuthn assertion challenge with the list of credential IDs registered to that account, and the browser surfaces the appropriate authenticator. If the user has no passkey, the same endpoint returns a fallback signal and the UI swaps to a password field.
// Next.js route handler
export async function POST(req: Request) {
const { email } = await req.json();
const user = await users.findByEmail(email);
if (!user) {
return Response.json({ mode: 'password' }); // do not leak existence
}
const credentials = await credentials.findByUser(user.id);
if (credentials.length === 0) {
return Response.json({ mode: 'password' });
}
const challenge = await issueChallenge(user.id);
return Response.json({
mode: 'passkey',
options: serializeAssertionOptions(challenge, credentials),
});
}
Two non-obvious details earn their keep here. Always return the same shape regardless of whether the email exists, otherwise login becomes a free user-enumeration endpoint. And rate-limit by IP plus by email, separately — credential stuffing operates on the email dimension, while denial-of-service operates on the IP dimension, and a single rate limiter cannot serve both.
For the password fallback itself, treat it as a deprecated path under active migration. Show the user a soft prompt to register a passkey after a successful password login. Track the metric — passkey-eligible users on each authentication path — and watch it climb. When it reaches the high nineties for a given customer segment, you can start offering them a "passwords disabled for this account" toggle, which is what enterprise security teams actually want.
Account recovery without an email-link backdoor
Account recovery is where most passkey rollouts quietly defeat their own security model. If a user with a passkey can recover their account by clicking an email link and setting a new password, the system's effective security is the security of the email link — exactly the phishing surface passkeys were meant to remove. A serious recovery design has three properties: it requires more than one factor that the attacker cannot trivially intercept, it is rate-limited and auditable, and it does not silently re-introduce a password where none was before.
The realistic shape: the user must register at least two passkeys, ideally on different device classes (phone plus laptop, or laptop plus hardware key). Recovery is "use one of the other registered passkeys", not "click a link". If the user has lost all their passkeys, the recovery flow is an out-of-band process — a verified support contact, an enterprise admin reset, or an identity-proofing call — that creates a new ephemeral credential under explicit logging. The recovery event is treated as a security incident worth reviewing, not a routine flow.
For consumer-facing tiers where this is too heavy, a tightly-scoped recovery email is acceptable provided it requires a second factor (TOTP, SMS, or an out-of-band confirmation in a still-logged-in session on another device) and the resulting account is forced into "verify on next sensitive action" mode for at least seven days.
Cross-device sync, attestation, and the enterprise question
Synced passkeys — the ones that ride along with iCloud Keychain or Google Password Manager — are what make the consumer experience pleasant. They are also what make some enterprise security teams nervous, because the credential is no longer bound to a single physical device under corporate control. WebAuthn's response to this is attestation: the authenticator can sign a statement about its model, allowing a relying party to require, say, an FIPS-certified hardware key for administrator accounts.
For most B2B SaaS rollouts the right default is attestation: 'none'. Requiring attestation for normal users is a UX disaster — the browser surfaces a privacy prompt that scares people, and you lose a meaningful percentage of registrations. Reserve attestation enforcement for explicitly-marked sensitive accounts (workspace owners, billing admins, security officers), where you offer it as a policy option the customer enables, not a default. That positioning is what turns a friction into a sales differentiator.
For the same reason, build your data model so that an admin can see and manage the credentials registered against their workspace's accounts, revoke individual credentials, and require specific transport types for specific roles. None of that is needed on day one, but the data model decisions you make now determine whether you can ship those features in three months or in three quarters. We see this trade-off play out routinely in code quality consulting engagements where a too-thin schema chosen at MVP time blocks every later feature.
Production edge cases that tutorials skip
A surprising amount of passkey trouble in production has nothing to do with cryptography. The real edge cases:
- The
signCountproblem. WebAuthn lets authenticators report a signature counter to detect cloned credentials. Some authenticators (notably most synced passkeys) always report zero. Treat a zero counter as "skip the check" rather than "reject the login" — otherwise you lock out every iCloud and Google passkey user on their second login. - Browser autofill conflicts. Conditional UI lets the browser surface passkeys directly inside the email field via
mediation: 'conditional'. It is excellent UX when it works and silently breaks if your CSS hides the field, the field is not a realinput[type=text], or you have a competing autofill listener. Test on Safari and Chrome, on macOS and Windows, with and without password managers installed. - The shared-account reality. Many B2B accounts are shared between three or four humans on customer success teams, even when your terms forbid it. Passkeys are inherently per-human; a shared password becomes a registration nightmare. The right answer is to ship multi-user invites and named-seat pricing before you turn off password fallback, not after.
- Roaming-key timeouts. A USB hardware key that takes more than the default WebAuthn timeout (about a minute) to be inserted will cancel the operation silently. Either extend the timeout server-side or surface a clear "tap your key" instruction with a longer client-side wait.
- Domain changes. Passkeys are bound to your relying-party ID, which is your effective top-level domain. Migrating from
app.example.comtoapp.example.ioinvalidates every existing passkey. If you are on a custom-domain track at all, decide on the final domain before mass passkey rollout — or build a re-registration funnel into your migration plan from the start.
These are the kinds of issues that surface only when real users meet a real production environment, which is why a serious web application development project budgets a parallel testing lane on at least four browser/OS combinations rather than treating WebAuthn as a single library to swap.
A migration playbook you can ship in a quarter
For a typical mid-size SaaS, a credible passkey migration is twelve to fourteen weeks of focused engineering with the password path running in parallel throughout:
- Weeks 1–2: data model, schema migration, admin tooling for the credentials table, decision on which WebAuthn library to use (
web-auth/webauthn-libfor Symfony,@simplewebauthn/serverfor Node-side workloads). - Weeks 3–4: server-side registration and assertion endpoints, challenge issuance, signature verification, full test coverage including known WebAuthn test vectors.
- Weeks 5–6: client-side flows in Next.js, conditional UI, error handling, the mode-detection login endpoint.
- Week 7: account recovery design, multi-passkey enforcement for new registrations, support team runbook for out-of-band recovery.
- Weeks 8–9: opt-in beta to internal users and a friendly customer cohort, telemetry on success rates per browser and OS, fixing the inevitable surprises.
- Weeks 10–11: GA rollout with passwords still default, soft-prompt for passkey registration after every login, dashboard for adoption rates.
- Week 12: enterprise-tier policy options (attestation requirements, password-disable toggle, admin-managed credentials), documentation for security questionnaires.
By the end of the quarter the surface area is shipped, the password path is a soft fallback, and the security team has the artefacts they need to answer enterprise procurement questions. A second quarter of careful adoption work — segment-by-segment password disablement, recovery refinement, hardware-key support for admins — converts the rollout into a real reduction in the password-related incident rate.
If you are about to start a passkey rollout — or in the middle of one that has stalled — that is a useful conversation to have before the next enterprise renewal cycle. Contact us at hello@wolf-tech.io or visit wolf-tech.io — eighteen years of European SaaS engineering, including substantial WebAuthn and authentication work, sits behind every recommendation we make.

