In this tutorial you'll learn how to add passkey authentication to your SvelteKit apps. You'll register a passkey and use it to login. In subsequent tutorials I'll show you how to add session management, social login and more.
Passkeys offer significant security and usability enhancements over passwords. We can use passkeys in isolation or as a secondary authentication factor. Whilst the technology behind passkeys is complex, frameworks and libraries such as Passlock make adoption simple.
This tutorial will take less than 30 mins to complete.
Feeling lazy?
I've put together a starter app which supports passkeys, social sign in and other features. You can choose from multiple UI frameworks including Daisy UI, Preline and Shadcn. Use the CLI script and follow the prompts:
pnpm create @passlock/sveltekit
That’s it! check out the generated source code, which has plenty of comments.
This won’t win any design awards but I want to keep things real simple. Next, create a placeholder form action at src/routes/register/+page.server.ts:
// src/routes/register/+page.server.tsimport type { Actions } from './$types'export const actions: Actions = { default: async () => { // TODO }}
TIP
We create a placeholder form action because we want to reference the generated SubmitFunction type in the template and use it for our progressive enhancement. If you're not already running the dev server, you can generate $types by running pnpm exec svelte-kit sync
Now for the real work! Update the template so it intercepts the form submission and registers a passkey on the user’s device:
<!-- src/routes/register/+page.svelte --><script lang="ts"> import { enhance } from '$app/forms' import type { SubmitFunction } from './$types' import { Passlock, PasslockError } from '@passlock/sveltekit' // we'll fill in the tenancyId and clientId later const passlock = new Passlock({ tenancyId: 'TBC', clientId: 'TBC' }) // during form submission, ask Passlock to register a // passkey on the user's device. This will return a // secure token, representing the newly created passkey const registerPasskey: SubmitFunction = async ({ cancel, formData }) => { const email = formData.get('email') as string const givenName = formData.get('givenName') as string const familyName = formData.get('familyName') as string const user = await passlock.registerPasskey({ email, givenName, familyName }) if (!PasslockError.isError(user)) { // attach the token to the request formData.set('token', user.token) } else { cancel() // prevent form submission alert(user.message) } }</script><form method="post" use:enhance="{registerPasskey}"> Email: <input type="text" name="email" /> <br /> First name: <input type="text" name="givenName" /> <br /> Last name: <input type="text" name="familyName" /> <br /> <button type="submit">Register</button></form>
Explanation
We’re using SvelteKit’s progressive enhancement to intercept the form submission. We then use Passlock to register a passkey on the user’s device. Passlock will store the public key component of the passkey in your Passlock vault.
If all goes well, this will return a token that we can exchange for a user object in our form action.
TIP
We're intercepting the request and grabbing the fields from the request form data. Alternatively we could use Svelte's binding syntax to reference the current value, or a utility like Superforms. In fact the StarterApp I mentioned uses Superforms.
Process the token in the form action
The form will be submitted with an additional token field. We’ll use it to fetch the passkey details in the form action:
// src/routes/register/+page.server.tsimport type { Actions } from './$types'import { PasslockError, TokenVerifier } from '@passlock/sveltekit'const tokenVerifier = new TokenVerifier({ tenancyId: 'TBC', apiKey: 'TBC'})export const actions: Actions = { default: async ({ request }) => { const formData = await request.formData() const token = formData.get('token') as string const user = await tokenVerifier.exchangeToken(token) if (!PasslockError.isError(user)) { console.log(user) } else { console.error(user.message) } }}
The user includes a sub (subject / user id) field. Later we’ll use this to link the passkey registration to a local user.
Create a Passlock account
Within the +page.svelte and +page.server.ts files, we’ve used TBC for some Passlock config values. It’s time to replace these with real values.
Create a developer account at passlock.dev then head to the settings tab within your console. We’re after the tenancyId, clientId and API Key values.
NOTE
Passlock is a serverless passkey platform, that also supports social login, mailbox verification, audit trails and more. Passlock is free for personal and commercial projects.
Edit your .env file (or .env.local) and create entries for these values:
// src/routes/register/+page.server.tsimport { PUBLIC_PASSLOCK_TENANCY_ID } from '$env/static/public'import { PASSLOCK_API_KEY } from '$env/static/private'const tokenVerifier = new TokenVerifier({ tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, apiKey: PASSLOCK_API_KEY})...
Register a passkey
Although we’re not yet finished, you should be at a point where you can register a passkey.
Navigate to the /register page and complete the form. You should be prompted to create a passkey and the form action will spit out details of the passkey registration.
You should also see an entry in the users tab of your Passlock console. The console can be used to view security related events, suspend and delete users and more.
NOTE
At this stage things will seem slow and clunky. We'll address the performance issues in a subsequent tutorial, for now we just want to get things working.
Create a login route
We can now register a passkey on the users device. Let’s use that passkey to authenticate. The process is essentially the same as for registration.
Create a route at src/routes/login/+page.svelte:
<!-- src/routes/login/+page.svelte --><script lang="ts"> import { enhance } from '$app/forms' import { Passlock, PasslockError } from '@passlock/sveltekit' import type { SubmitFunction } from './$types' import { PUBLIC_PASSLOCK_TENANCY_ID, PUBLIC_PASSLOCK_CLIENT_ID } from '$env/static/public' const passlock = new Passlock({ tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, clientId: PUBLIC_PASSLOCK_CLIENT_ID }) const onSubmit: SubmitFunction = async ({ cancel, formData }) => { const email = formData.get('email') as string const user = await passlock.authenticatePasskey({ email }) if (!PasslockError.isError(user)) { formData.set('token', user.token) } else { cancel() // prevent form submission alert(user.message) } }</script><form method="post" use:enhance="{onSubmit}"> Email: <input type="text" name="email" /> <br /> <button type="submit">Login</button></form>
Notice how we’re calling authenticatePasskey and only passing the email this time.
// src/routes/login/+page.server.tsimport type { Actions } from './$types'import { PasslockError, TokenVerifier } from '@passlock/sveltekit'import { PUBLIC_PASSLOCK_TENANCY_ID } from '$env/static/public'import { PASSLOCK_API_KEY } from '$env/static/private'const tokenVerifier = new TokenVerifier({ tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, apiKey: PASSLOCK_API_KEY})export const actions: Actions = { default: async ({ request }) => { const formData = await request.formData() const token = formData.get('token') as string const user = await tokenVerifier.exchangeToken(token) if (!PasslockError.isError(user)) { console.log(user) } else { console.error(user.message) } }}
The form action is the same (at this stage). Passlock abstracts passkey registration and authentication into a common structure.
Login using a passkey
Navigate to the /login page, enter your email (the one you used for registration) and click login. If all goes well, you should see your user details in the server console.
Within your Passlock console, under the users tab you should see an entry. If you click on the user you’ll be able to see the passkey registration and authentication events.
Summary
We used the Passlock library to register a passkey on the users device. The public key component of the passkey is stored in your Passlock vault.
During authentication we ask the user to present their passkey. The Passlock library ensures the challenge signature matches the public key then generates a secure token. We pass this token to the backend form action.
The form action ensures the token is valid, exchanging it for details about the user and the passkey used to authenticate.
Supporting code
The final SvelteKit app is available in a GitHub repo. Clone the repo and check out the tutorial/pt-1 tag.
Next steps
We’ve made a great start but we now need to link the passkeys to local user accounts and sessions. It’s time to integrate Lucia and passkey authentication
If you've followed the previous installments in this series, you'll have built a SvelteKit app with passkey authentication, session management and authorization. It's functional, but slow and clunky. Let's improve things.
In this tutorial you'll build on your previous work to integrate passkeys with Lucia users and sessions. You'll handle session creation, expiry and invalidation and protect your routes using SvelteKit hooks.
All the major browsers now support passkeys, however biometric support is often limited to those browsers with tight platform integration e.g. Safari on iOS and Chrome on Android.
Passkeys are tied to specific (https) websites. Browsers won't use a private key intended for one website to sign a challenge generated by a different site.
Passkeys enable two factor authentication (including biometrics). Users can even use a biometric enabled device e.g. iPhone FaceID to authenticate against a device lacking this capability e.g. a desktop.