Passkey Login
Passwordless and password sign-in in one form, backed by the WebAuthn passkey handlers.
Live preview
LoginPage.svelte
// 1. src/lib/server/auth-setup.ts — auth deps + shared WebAuthn ceremony config
import { createAuthDeps, createPasskeyHandlers } from '@urbicon-ui/auth/server';
import type { WebAuthnConfig } from '@urbicon-ui/auth/server';
import { createPrismaRepos } from '@urbicon-ui/auth/server/adapters/prisma';
import { env } from '$env/dynamic/private';
import { prisma } from './prisma';
// No email transport: nothing mounted here sends mail. Add one when you also
// mount register / forgot-password / change-email — those three throw at wiring
// time without it.
export const authDeps = createAuthDeps({
config: { jwt: { secret: env.JWT_SECRET }, appUrl: env.PUBLIC_APP_URL },
repos: createPrismaRepos(prisma)
});
const webauthn: WebAuthnConfig = {
rpId: 'example.com', // your registrable domain (no scheme/port)
rpName: 'My App',
origin: env.PUBLIC_APP_URL // e.g. https://app.example.com
// challengeStore defaults to in-memory; pass a ChallengeStore for >1 instance
};
// One factory returns all six passkey handlers (both ceremonies + list/delete).
export const passkey = createPasskeyHandlers(authDeps, webauthn);
// 2. src/routes/api/auth/passkey/authentication-options/+server.ts
import { passkey } from '$lib/server/auth-setup';
export const POST = passkey.authenticationOptions.POST;
// 3. src/routes/api/auth/passkey/authentication-verify/+server.ts
import { passkey } from '$lib/server/auth-setup';
export const POST = passkey.authenticationVerify.POST;
// passkey.registrationOptions / passkey.registrationVerify (and passkey.list /
// passkey.item for the PasskeyManager) wire up identically on sibling routes.
// 4. src/routes/auth/login/+page.svelte
<script lang="ts">
import { LoginPage } from '@urbicon-ui/auth';
import { en } from '@urbicon-ui/auth/i18n/en';
import { goto } from '$app/navigation';
</script>
<LoginPage
t={en}
mode="both"
passkeyApiPath="/api/auth/passkey"
onSuccess={() => goto('/')}
/>Two decisions and a boundary
The challenge store is a deployment decision
A ceremony is two requests. authenticationOptions mints a challenge, keeps it in the challengeStore, and pins a single-use handle to the
browser in an HttpOnly cookie; authenticationVerify reads the handle back to find
and consume that challenge. The in-memory default store only bridges the two requests on a
single process: behind a load balancer or on serverless, the verify call can land where
the challenge never was. Pass a persistent ChallengeStore in the webauthn config there.
The passkey button works with the email empty
Left blank, the options request names nobody and the browser offers every credential saved
for this rpId (discoverable login). A filled email
only narrows the prompt to that account's passkeys; whether the address exists is never
answered. That is what lets mode="both" be one form
instead of an identify-first step, and mode="passkey" drop the password fields entirely.
Adding a passkey is a signed-in action
LoginPage runs only the authentication ceremony, so
it signs in with credentials that already exist. The first one is created by the
registration ceremony, which requires a session: mount passkey.registrationOptions / passkey.registrationVerify on sibling routes and
give account settings a PasskeyManager, whose list and delete run over passkey.list / passkey.item.