DEV Community

Cover image for How to add passkey login to your SvelteKit app
Toby Hobson for Passlock

Posted on • Originally published at passlock.dev

How to add passkey login to your SvelteKit app

In this tutorial you'll learn how to add passkey authentication to your SvelteKit apps. In subsequent tutorials I'll show you how to add session management, social login and more. Check out the full SvelteKit + Passkeys tutorial on my blog.

Quick start

I've put together a SvelteKit 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
Enter fullscreen mode Exit fullscreen mode

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

Alternatively read on to learn how to do this manually...

Create a SvelteKit app

Use SvelteKit's CLI to generate a skeleton project:

pnpm create svelte@latest my-app
Enter fullscreen mode Exit fullscreen mode

Note: Choose the skeleton project template, with Typescript support.

Add the library

We'll use Passlock, my SvelteKit passkey library for passkey registration and authentication:

pnpm add -D @passlock/sveltekit
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

Explanation

We're using SvelteKit's progressive enhancement to intercept the form submission. We then use the Passlock library 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.

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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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 cloud is a serverless passkey platform, that also supports social login, mailbox verification, audit logs and more. It's free for personal and commercial projects.

Find your Passlock config

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 = '...'
Enter fullscreen mode Exit fullscreen mode

If you don't have a .env file in your app root, create one.

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>
Enter fullscreen mode Exit fullscreen mode
// 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
})
Enter fullscreen mode Exit fullscreen mode

Try to 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.

Too slow?

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>
Enter fullscreen mode Exit fullscreen mode

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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

The form action is the same (at this stage). Passlock abstracts passkey registration and authentication into a common structure.

Try to login using your 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.

Get the 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 use Lucia authentication with passkeys.

Top comments (0)