We're now live! Signup now

SvelteKit + Passkeys

Passkeys SvelteKit

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
Creating a SvelteKit app with passkey support
Use the wizard to generate an with passkey support

That’s it! check out the generated source code, which has plenty of comments.

If you like it, please give it a star 🙏

Read on to learn how to do this manually…

TIP
The final code for this tutorial is available in the supporting GitHub repo. See the link at the end of this post.

Create a SvelteKit app

Use SvelteKit’s CLI to generate a skeleton project:

pnpm create svelte@latest my-app
NOTE
Choose the skeleton project template, with Typescript support.

Add the library

We’ll use Passlock’s SvelteKit passkey library for passkey registration and authentication:

pnpm add -D @passlock/sveltekit

Create a registration route

Create a new template at src/routes/register/+page.svelte:

<!-- src/routes/register/+page.svelte -->
<form method="post">
  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>

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.ts
import 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.ts
import 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.
Find your config parameters in your Passlock console
Find your config parameters in your Passlock console

Edit your .env file (or .env.local) and create entries for these values:

# .env
PUBLIC_PASSLOCK_TENANCY_ID = '...'
PUBLIC_PASSLOCK_CLIENT_ID = '...'
PASSLOCK_API_KEY = '...'

If you don’t have a .env file in your app route, create one.

TIP
You can also download a ready made Vite compatable .env file from your Passlock console settings tab.

You can now reference these in your template and form actions:

<!-- src/routes/register/+page.svelte -->
<script lang="ts">
  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
  })

  ...
</script>
// src/routes/register/+page.server.ts
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
})

...

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.ts
import 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

Toby Hobson

Toby Hobson

Founder

Passkeys

SvelteKit Passkey Performance

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.

Passkeys

SvelteKit + Passkeys + Lucia

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.

Passkeys

Passkey browser support in 2024

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

Two factor authentication using passkeys

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.

Want product news and updates?

Sign up for our newsletter

We care about your data. Read our privacy policy .