Passkeys for SaaS: Migrating From Passwords to WebAuthn Without Breaking Users
The first time a B2B prospect's security questionnaire asks whether your SaaS supports passkeys and lists it as a procurement blocker, the conversation about WebAuthn stops being theoretical. Until late 2025 most of our clients treated passkeys as a nice-to-have, parked behind a "maybe Q3" tag on the roadmap. In 2026 the calculus changed: enterprise buyers are asking for FIDO2 in the same breath as SAML SSO, regulators in financial services are pointing at it as a baseline control, and Apple, Google, and Microsoft have closed enough of the cross-device sync gap that ordinary users no longer get stuck halfway through registration.
The hard part of a passkey implementation in a SaaS application is not the cryptography. The web-auth libraries do the heavy lifting and the spec is finally stable. The hard part is migrating an installed base of password users to a fundamentally different mental model without locking anyone out, supporting both ceremonies on every device combination customers actually own, and getting account recovery right. This post is the playbook we use when a Symfony + Next.js SaaS team comes to us asking for a passkey implementation that production users will actually adopt.
What "Passkey" Really Means in Your Code
A passkey is a discoverable WebAuthn credential, often synced across a vendor's cloud — iCloud Keychain, Google Password Manager, Windows Hello with Microsoft account, 1Password, Bitwarden. From your application's perspective, the credential itself is a public key bound to your relying party ID (your domain), a credential ID, an optional user handle, and a signature counter. You never see the private key. You never see the user's biometric. The browser's WebAuthn API mediates the entire ceremony.
Two ceremonies matter. Registration (navigator.credentials.create) generates a new credential on the authenticator, hands you back the public key plus an attestation, and you store it against the user. Authentication (navigator.credentials.get) asks an existing authenticator to sign a server-issued challenge, and you verify the signature against the stored public key. Both ceremonies are server-led — your backend issues the challenge, the authenticator signs it, your backend verifies. Anything that touches the challenge in client code without a server round-trip is broken by design.
Two terms trip up teams new to the spec. Conditional UI is the autofill-style passkey prompt that appears when a user focuses an empty username field on a sign-in page. Hybrid transport is the QR-code dance that lets a desktop browser borrow a phone's authenticator over Bluetooth and a CTAP2 tunnel. You will need both.
The Symfony Side: Storage, Ceremonies, and the Library Choice
For PHP backends, the web-auth/webauthn-lib package (Spomky-Labs, now FIDO Alliance-aligned) is the only library mature enough to run in production. It speaks the full FIDO2 spec, plays nicely with Symfony 7's Security component, and gets timely fixes when a browser ships a behavior change.
Your domain model needs three small additions next to your existing User entity:
#[ORM\Entity]
class PasskeyCredential
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private int $id;
#[ORM\ManyToOne(targetEntity: User::class)]
private User $user;
#[ORM\Column(length: 512, unique: true)]
private string $credentialId; // base64url
#[ORM\Column(type: 'json')]
private array $publicKey;
#[ORM\Column]
private int $signatureCounter = 0;
#[ORM\Column(length: 64, nullable: true)]
private ?string $aaguid = null; // helps detect authenticator family
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $lastUsedAt = null;
}
Two non-obvious fields earn their keep. The signatureCounter is your only protection against credential cloning for non-synced authenticators — increment it on every successful sign-in, and reject signatures that come in below the stored counter. The aaguid lets you label credentials in the UI as "iCloud Keychain", "Google Password Manager", or "YubiKey 5C" using the FIDO MDS catalog, which dramatically reduces support tickets when users forget which device they registered.
A registration controller looks roughly like this — leaving out boilerplate around CSRF and session storage:
#[Route('/api/passkeys/register/options', methods: ['POST'])]
public function options(#[CurrentUser] User $user): JsonResponse
{
$existing = array_map(
fn(PasskeyCredential $c) => PublicKeyCredentialDescriptor::create(
'public-key', base64_decode($c->getCredentialId())
),
$this->repo->findBy(['user' => $user])
);
$options = $this->ceremonyFactory->createCreationOptions(
rp: $this->relyingParty,
user: PublicKeyCredentialUserEntity::create(
$user->getEmail(), (string) $user->getId(), $user->getDisplayName()
),
challenge: random_bytes(32),
excludeCredentials: $existing,
authenticatorSelection: AuthenticatorSelectionCriteria::create(
residentKey: 'required',
userVerification: 'preferred',
),
);
$this->session->set('webauthn_challenge', $options->challenge);
return new JsonResponse($options);
}
The excludeCredentials parameter prevents a user from accidentally registering the same authenticator twice — important when a customer has both their work laptop and a backup YubiKey, and you do not want them to think the second registration silently succeeded. residentKey: 'required' is what makes this credential a true passkey rather than a server-side credential — it lives on the authenticator and is discoverable without you sending a list of credential IDs. That matters for conditional UI later.
Verification is one library call:
$publicKeyCredential = $this->loader->loadArray($request->toArray());
$this->ceremonyVerifier->check(
$publicKeyCredential->response,
$options,
$request->headers->get('Origin'),
);
Persist the credential, return success, send the user a confirmation email so a stolen-session attacker cannot quietly add a credential without the legitimate owner seeing it.
The Next.js Side: SimpleWebAuthn and Conditional UI
On the client we standardize on @simplewebauthn/browser. It is small, framework-agnostic, and tracks browser quirks fast. In a Next.js 15 App Router app the registration flow is a server action followed by a browser-side ceremony:
'use client';
import { startRegistration } from '@simplewebauthn/browser';
export function AddPasskeyButton() {
async function handleAdd() {
const opts = await fetch('/api/passkeys/register/options', { method: 'POST' })
.then(r => r.json());
const attestation = await startRegistration({ optionsJSON: opts });
const result = await fetch('/api/passkeys/register/verify', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attestation),
});
if (!result.ok) throw new Error('Passkey registration failed');
}
return <button onClick={handleAdd}>Add a passkey</button>;
}
For sign-in, conditional UI is the win. Instead of asking users to click a "Sign in with passkey" button, you wire autocomplete="username webauthn" on the email field and call startAuthentication({ useBrowserAutofill: true }) on mount. The passkey appears in the autofill suggestions; one tap and the user is in. We have measured 30 to 50 percent of returning users completing sign-in this way once it is exposed, and the UX is the single biggest reason adoption sticks.
Critical detail: do not forget to feature-detect. About one in fifteen sessions still hits a browser combination where conditional UI is unsupported, and the autofill call throws. Wrap it, fall back to the password form, log the failure with a user-agent tag.
Migrating an Existing User Base Without Breakage
This is where most passkey rollouts go wrong, and where the engineering decisions matter more than the library choice.
Make passkeys additive, not replacement, for at least one full quarter. Every user keeps their password. After a successful password sign-in, surface a one-screen interstitial: "Skip the password next time — add a passkey from this device." Track who accepts and who dismisses. Do not block sign-in on it. The interstitial is what drives most of your adoption curve; it converts roughly twice as well as a settings-page-only entry point.
Keep the password form discoverable on the sign-in page until passkey adoption is well past 50 percent. A "Use password instead" link below the conditional UI prompt is enough. Users on a brand-new device with no synced passkey will need it, and any UI that hides the fallback creates an unrecoverable wall.
Offer second-factor passkeys before passwordless passkeys. A user who registers a passkey "for faster sign-in" while keeping their password becomes a 2FA user without realizing it. This is a low-anxiety entry point. Once that user has both a passkey and a password and has used the passkey three or four times, prompt them to remove the password — but make removal a deliberate action with a confirmation, never automatic.
Account recovery is the migration's hardest problem, not its last. A user with a passkey on a single non-synced device who loses that device is locked out. You need a recovery path planned before you launch, not after support tickets start arriving. The pattern that holds up: on passkey registration, generate a one-time recovery code, force the user to record it, and store its hash. On recovery, verify the code, then run a high-friction identity verification (email magic link plus a 24-hour cool-off, or a manual operator review for enterprise tenants). Do not use SMS. A naïve recovery flow becomes the new attack surface and undoes the security gain you just won.
The Edge Cases That Quietly Break Production
A short list of things that bit us, in clients and in our own systems, and that you should test for before flipping the rollout flag.
A Safari user on iOS 17 registers a passkey, then upgrades to iOS 18 — the credential survives. That same user wipes the device and restores from a different iCloud account — the credential is gone. Plan for it. Display the passkey list with a "last used" timestamp and a "remove" action so users can clean up orphaned credentials before they run out of registered authenticators.
A Chrome user on Windows registers a Windows Hello credential, then signs in from the same browser on a corporate VDI without Windows Hello available. The credential is silently invisible. The fix is userVerification: 'preferred' instead of 'required', and supporting hybrid transport so the user can complete the ceremony with their phone.
A user with a Bitwarden browser extension and a synced iCloud passkey for the same account gets two prompts, picks the wrong one, and registers a duplicate. Without excludeCredentials the second registration succeeds and you now have two credentials for the same logical authenticator. Always pass the existing list.
The signature counter check is a footgun for synced passkeys, where the counter typically stays at zero across the cluster. The spec allows this. Your verification logic must accept counter=0 from synced authenticators while still enforcing monotonicity for hardware authenticators. The library handles this if you do not override its behavior.
What This Looks Like in a Real Engagement
When we run a passkey rollout for a Symfony and Next.js SaaS, the work breaks down into roughly three weeks: one for the ceremony plumbing and entity model, one for the UI flows including conditional UI and the recovery path, and one for migration scaffolding — the additive interstitials, the support-tooling for credential management, the metrics dashboard that tells the team whether adoption is on track. The library work is small. The product work around it is what determines whether your adoption curve flattens at five percent or climbs through forty.
If your team is staring down an enterprise security questionnaire that just added passkeys to the must-have column, or if you have an audit-driven deadline to remove passwords as a primary factor, this is the kind of engagement where a focused outside team pays for itself quickly. Wolf-Tech runs passkey migrations as part of our code quality consulting and custom software development work, with eighteen years of Symfony and React production experience behind every decision the playbook above describes.
Contact us at hello@wolf-tech.io or visit wolf-tech.io.

