Skip to main content
Urbicon UI
Back to Recipes

Passkey Login

Passwordless and password sign-in in one form, backed by the WebAuthn passkey handlers.

Built with LoginPage

Live preview

LoginPage.svelte

Try either path; the docs site runs none of the server routes, so both end in the form's error message instead of a session.

Sign in

Forgot password? Don't have an account? Create account
// 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.