Auth.js has recently added support for Passkeys via WebAuthn specification. However, at the time of this writing, the Auth.js guide does not have a working example of how to implement passkeys in Sveltekit. The current official guide is for Next.js. It uses the Passkey provider, which is not compatible with the Sveltekit implementation. For Sveltekit, you will need to use the WebAuthn provider, which is not documented.
This guide will walk you through the process of setting up passkeys in Sveltekit using Auth.js. In addition, it also includes instructions on supporting multiple passkeys. For this guide, I’ll also be using PostgreSQL and Drizzle as the Database and ORM of choice. There will be differences from the in the Auth.js documentation, as this guide will include steps for allowing users to add multiple passkeys.
1. Install peer dependencies
The @simplewebauthn/browser peer dependency is only required for custom signin pages. If you’re using the Auth.js default pages, you can skip installing that peer dependency.
I am using a slightly modified table definition from the Drizzle adapter to allow for multiple named passkeys for each user. The name column is added to the authenticator table, and a unique constraint is added to ensure that each user can only have one passkey with the same name.
ts
export type Authenticator = InferModel<typeof authenticators>;export type AuthClient = Pick<Authenticator, "credientialID" | "name">;export type InsertAuthenticator = InferInsertModel<typeof authenticators>;export type UpdateAuthenticator = Partial<Authenticator>;export const authenticators = pgTable( "authenticator", { credentialID: text("credentialID").notNull().unique(), userId: text("userId") .notNull() .references(() => users.id, { onDelete: "cascade", onUpdate: "cascade" }), providerAccountId: text("providerAccountId").notNull(), // Add a name column to store the name of the passkey // The default value is an empty string. This will be // important later when we add the rename passkey feature. name: text("name") .notNull() .$defaultFn(() => ""), credentialPublicKey: text("credentialPublicKey").notNull(), counter: integer("counter").notNull(), credentialDeviceType: text("credentialDeviceType").notNull(), credentialBackedUp: boolean("credentialBackedUp").notNull(), transports: text("transports") }, (table) => ({ compositePK: primaryKey({ columns: [table.userId, table.credentialID] }), // At the database level, add onDelete and onUpdate cascades to the foreign key accountFK: foreignKey({ columns: [table.userId, table.providerAccountId], foreignColumns: [accounts.userId, accounts.providerAccountId], name: "public_authenticator_userId_providerAccountId_fkey" }), // Add a unique constraint on the userId and name to avoid duplicate passkey names uniqueName: unique("authenticator_userId_name_key").on(table.userId, table.name) }));
In addition, you should add a unique index on the userId and providerAccountId columns of the account table. This is necessary to have a foreign key constraint pointing to these columns on the authenticator table.
Add the WebAuthn provider to your configuration. You will need to have at least one other provider, because Passkey registration requires a user to be signed in.
4. Pass the registered authenticator entries to the UI
By passing the authenticators to the layout, we can display the list of registered passkeys to the user. It will be accessible in the built-in $page.data.authenticators store.
5. Create an endpoint to rename or delete a passkey
This API endpoint will be used in the next step to rename a passkey after it has been registered.
ts
import { authName } from "$lib/util.js";import { db, q } from "$server/db/index.js";import { accounts, authenticators } from "$server/db/schema.js";import { json } from "@sveltejs/kit";import { and, eq } from "drizzle-orm";export type RenameWebAuthnResponse = { success: true; name: string } | { success: false; error: string; throw?: boolean };export async function POST({ request, locals }) { const session = locals.session; if (!session?.user?.id) return json({ error: "Unauthorized" }, { status: 401 }); let { name, id } = (await request.json()) as { name: string; id?: string }; try { const passkeys = await q.authenticators.findMany({ where: (table, { eq, and }) => and(eq(table.userId, session.user.id)) }); const auth = passkeys.find((a) => (id ? a.credentialID === id : a.name === "")); if (!auth) return json({ success: false, error: "No passkey found", throw: true }); if (!name.trim()) name = authName(auth); const existing = passkeys.find((a) => a.name === name); if ((!auth.name && existing) || (id && existing && existing.credentialID !== id)) throw new Error("Name already exists"); await db .update(authenticators) .set({ name }) .where(and(eq(authenticators.userId, auth.userId), eq(authenticators.providerAccountId, auth.providerAccountId))); return json({ success: true, name }); } catch (e) { if (e instanceof Error) return json({ success: false, error: e.message }); else { console.error(e); return json({ success: false, error: "Unknown error" }); } }}export type DeleteWebAuthnResponse = { success: true } | { success: false; error: string };export async function DELETE({ request, locals }) { try { const session = locals.session; if (!session?.user?.id) return json({ error: "Unauthorized" }, { status: 401 }); const { id } = (await request.json()) as { id: string }; const auth = await q.authenticators.findFirst({ where: (table, { eq }) => eq(table.userId, session.user.id) && eq(table.credentialID, id) }); if (!auth) throw new Error("No passkey found"); await db .delete(accounts) .where(and(eq(accounts.userId, auth.userId), eq(accounts.providerAccountId, auth.providerAccountId))); return json({ success: true }); } catch (e) { if (e instanceof Error) return json({ success: false, error: e.message }); else { console.error(e); return json({ success: false, error: "Unknown error" }); } }}
6. Add the passkey list and registration button
Registering a passkey requires the user to be signed in, so the registration should be somewhere in the user settings.
In this example, when the user clicks the “Add Passkey” button, the passkey function is called with the action set to register. This will trigger the registration flow. The redirect option is set to false to prevent the user from being redirected after the registration is complete.
Instead, the user will be directed to create a name for the new passkey using the renameWebAuthn function. After the rename flow is complete, the invalidateAll function is called to rerun the load functions that belong to the current page.
svelte
<script lang="ts"> import { invalidateAll } from "$app/navigation"; import { page } from "$app/stores"; import { errorToast, successToast } from "$lib/factories"; import { authName } from "$lib/util"; import type { AuthClient } from "$server/db/schema"; import type { DeleteWebAuthnResponse, RenameWebAuthnResponse } from "$src/routes/(api)/webAuthn/+server"; import { signIn } from "@auth/sveltekit/webauthn"; import { tick } from "svelte"; import { scale } from "svelte/transition"; $: authenticators = $page.data.authenticators as AuthClient[]; let renaming = false; let defaultName = ""; let renameId: string | undefined; let renameName = ""; let renameError = ""; let renameRef: HTMLInputElement | undefined; async function initRename(id?: string, currentName = "", error = "") { if (!error) defaultName = currentName; renameId = id; renameName = currentName; renameError = error; renaming = true; await tick(); if (renameRef) renameRef.focus(); } async function renameWebAuthn(isDefault = false) { if (isDefault && defaultName) { renaming = false; return; } const id = renameId; const name = isDefault ? "" : renameName; const response = await fetch("/webAuthn", { method: "POST", body: JSON.stringify({ name, id }) }); const value = (await response.json()) as RenameWebAuthnResponse; if (value.success) { if (defaultName) { successToast(`Passkey "${defaultName}" renamed to "${value.name}"`); } else { successToast(`Passkey "${value.name}" created`); } invalidateAll(); renaming = false; } else { if (value.throw) { errorToast(value.error); renaming = false; } else initRename(id, name, value.error); } } async function deleteWebAuthn(id: string) { const auth = authenticators.find((a) => a.credentialID === id); if (!auth) return; if (confirm(`Are you sure you want to delete "${authName(auth)}"?`)) { const response = await fetch("/webAuthn", { method: "DELETE", body: JSON.stringify({ id }) }); const value = (await response.json()) as DeleteWebAuthnResponse; if (value.success) { successToast(`Passkey "${authName(auth)}" deleted`); invalidateAll(); } else { errorToast(value.error); } } }</script><ul class="menu menu-lg w-full px-0 [&_li>*]:px-2"> <li class="menu-title"> <span class="font-bold">Paaskeys</span> </li> {#each authenticators as authenticator} {@const name = authName(authenticator)} <li> <button class="flex gap-2" on:click={() => initRename(authenticator.credentialID, name)}> <span class="iconify size-6 material-symbols--passkey"></span> <span class="w-44 flex-1 overflow-hidden text-ellipsis whitespace-nowrap">{name}</span> <button class="btn btn-square btn-error btn-sm" on:click|stopPropagation={() => deleteWebAuthn(authenticator.credentialID)} > <span class="iconify size-5 mdi--delete" /> </button> </button> </li> {/each} <li> <button type="button" on:click={() => signIn("webauthn", { action: "register", redirect: false }) .then((resp) => { if (resp?.ok) initRename(); else errorToast("Failed to register passkey"); }) .catch(console.error)} > <span class="iconify size-6 mdi--plus"></span> <span>Add Passkey</span> </button> </li></ul><dialog class="modal !bg-base-300/75" open={renaming} aria-labelledby="modal-title" aria-describedby="modal-content"> {#if renaming} <div class="modal-box relative cursor-default bg-base-100 drop-shadow-lg" transition:scale={{ duration: 500, opacity: 0.5, start: 0.75 }} > <button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" on:click={() => renameWebAuthn(true)}> <span class="iconify mdi--close"></span> </button> <h3 id="modal-title" class="cursor-text text-lg font-bold text-black dark:text-white">Rename Passkey</h3> <form on:submit|preventDefault={() => renameWebAuthn()}> <label for="passkeyName" class="label"> <span class="label-text">Passkey Name</span> </label> <input type="text" id="passkeyName" bind:value={renameName} bind:this={renameRef} class="input input-bordered w-full focus:border-primary" maxlength="20" /> {#if renameError} <label for="passkeyName" class="label"> <span class="label-text-alt text-error">{renameError}</span> </label> {/if} <div class="modal-action"> <button class="btn btn-primary">Save</button> </div> </form> </div> <button class="modal-backdrop" on:click={() => history.back()}>✕</button> {/if}</dialog>
7. Add the sign in flow
The sign in flow is similar to the registration flow. You call the same passkey function with the action set to authenticate instead. The callbackUrl option is set to the URL of the page you want to redirect to after the sign in is complete.
svelte
<script lang="ts"> import { page } from "$app/stores"; import { signIn as passkey } from "@auth/sveltekit/webauthn";</script><button class="flex h-16 items-center gap-4 rounded-lg bg-base-200 px-8 py-4 text-base-content transition-colors hover:bg-base-300" on:click={() => passkey("webauthn", { callbackUrl: $page.url.searchParams.get("redirect") || "/characters", action: "authenticate" })} aria-label="Sign in with Passkey"> <span class="iconify h-8 w-8 material-symbols--passkey"></span> <span class="flex h-full flex-1 items-center justify-center text-xl font-semibold">Sign In with Passkey</span></button><span class="max-w-72 text-balance text-center text-xs text-base-content"> You must create an account and enable Passkey in settings before you can sign in with this.</span>
Making the passkey sign in flow automatic
If you wish to have the sign in flow automatically initiate when the login page is loaded, you can call the passkey function programatically. If you do this, it would be a good idea to set a localStorage variable when the user is signed in if they have registered a passkey. Then on the login page only initiate the sign in flow if the user has a passkey registered.
Using the same global layout from step 4, you can create such a local variable.
svelte
<script lang="ts"> import { browser } from "$app/environment"; export let data; $: if (browser && data.session?.user) { if (data.authenticators.length) { localStorage.setItem("webauthn-enabled", "true"); } else { localStorage.removeItem("webauthn-enabled"); } }</script>
Then on your login page, initiate the sign in flow only if the local storage variable is set.
svelte
<script lang="ts"> import { browser } from "$app/environment"; import { page } from "$app/stores"; import { signIn as passkey } from "@auth/sveltekit/webauthn"; $: if (browser) { if (localStorage.getItem("webauthn-enabled")) { passkey("webauthn", { callbackUrl: $page.url.searchParams.get("redirect") || "/characters", action: "authenticate" }); } }</script>