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.
We're now live! Signup now
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.
This tutorial forms the third part of our SvelteKit series. If you haven’t already done so, please read the first and second tutorials. In this instalment, we’ll improve actual and perceived performance.
We’re not aiming for millisecond responses. Passkey authentication should happen relatively infrequently. Once the user has an active session, session authentication and request authorization takes over. Nevertheless, we want to offer users a decent experience.
We need to improve the actual performance, and perceived performance. Perceived performance is all about giving the user feedback that things are happening.
The app is a bit slow and clunky. There are a few reasons for this:
Ultimately we’ll be running in production mode with the inherent optimizations. Whilst we can optimize the choice of ciphers used for our passkeys, we risk introducing browser incompatabilities and security vulnerabilities. In most cases the tradeoff isn’t worth it.
That leaves us with the last area for improvement - optimizing for serverless.
When you first register or authenticate a passkey, Passlock will invoke an AWS lambda. This will most likely result in a “cold start” which isn’t ideal. However, we can work around this using Svelte’s onMount feature:
Update src/routes/register/+page.svelte
:
// src/routes/register/+page.svelte import { onMount } from 'svelte' const passlock = new Passlock({ ... }) onMount(async () => { await passlock.preConnect() })
registerPasskey
call.Now do the same for the login template at src/routes/login/+page.svelte
.
timestamp=xxx level=INFO fiber=#30 message="Pre-connecting to RPC endpoint"
Perceived performance is just as important as actual performance. Whilst waiting for a network request, we want to provide some feedback to the user, so they know something is happening. There are multiple ways of doing this, but we’ll go for a simple spinner.
Update src/routes/register/+page.svelte
:
<script lang="ts"> import { applyAction, enhance } from '$app/forms' import { Passlock, PasslockError } from '@passlock/sveltekit' import type { SubmitFunction } from './$types' import { goto } from '$app/navigation' import Spinner from '$lib/Spinner.svelte' import { onMount } from 'svelte' 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, endpoint: 'https://okbq1o3xde.execute-api.eu-west-2.amazonaws.com' }) let requestPending = false const onSubmit: SubmitFunction = async ({ formData, cancel }) => { // show the spinner when the request is made requestPending = true const email = formData.get('email') as string const givenName = formData.get('givenName') as string const familyName = formData.get('familyName') as string const result = await passlock.registerPasskey({ email, givenName, familyName }) if (PasslockError.isError(result)) { // if we can't register a passkey, abort requestPending = false cancel() alert(result.message) } else { formData.set('token', result.token) } return async ({ result }) => { // form action completed so hide the spinner requestPending = false if (result.type === 'redirect') { goto(result.location) } else { await applyAction(result) } } } onMount(async () => { await passlock.preConnect() }) </script> <form method="post" use:enhance="{onSubmit}"> Email: <input type="text" name="email" /> <br /> First name: <input type="text" name="givenName" /> <br /> Last name: <input type="text" name="familyName" /> <br /> <input type="hidden" name="token" /> <button type="submit">Register</button> {#if requestPending} <Spinner /> {/if} </form>
While the form submission is in progress we set requestPending
to true and therefore display the spinner. If the passkey registration fails we cancel the form submission and hide the spinner. Finally once we get a response back from the form action we hide the spinner.
Spinner.svelte
is just an SVG spinner wrapped as a Svelte component. You're free to use anything here, even a simple "loading..." message.Now do the same for the src/routes/login
route:
<!-- src/routes/login/+page.svelte --> <script lang="ts"> import { applyAction, enhance } from '$app/forms' import { Passlock, PasslockError } from '@passlock/sveltekit' import type { SubmitFunction } from './$types' import Spinner from '$lib/Spinner.svelte' import { onMount } from 'svelte' import { PUBLIC_PASSLOCK_TENANCY_ID, PUBLIC_PASSLOCK_CLIENT_ID, PUBLIC_PASSLOCK_ENDPOINT } from '$env/static/public' import { goto } from '$app/navigation' const passlock = new Passlock({ tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, clientId: PUBLIC_PASSLOCK_CLIENT_ID, endpoint: PUBLIC_PASSLOCK_ENDPOINT }) let requestPending = false const onSubmit: SubmitFunction = async ({ cancel, formData }) => { // show the spinner when the request is made requestPending = true const email = formData.get('email') as string const user = await passlock.authenticatePasskey({ email }) if (!PasslockError.isError(user)) { // if we can't login using a passkey, abort requestPending = false formData.set('token', user.token) } else { cancel() // prevent form submission alert(user.message) } return async ({ result }) => { // form action completed so hide the spinner requestPending = false if (result.type === 'redirect') { goto(result.location) } else { await applyAction(result) } } } onMount(async () => { await passlock.preConnect() }) </script> <form method="post" use:enhance="{onSubmit}"> Email: <input type="text" name="email" /> <br /> <button type="submit">Login</button> {#if requestPending} <Spinner /> {/if} </form>
We’ve improved perceived performance by a slight of hand. We’re pre-connecting to the Passlock serverless backend when the page loads and displaying a loading spinner whilst requests are pending.
tutorial/pt-3
tag.Passkeys are a great alternative to passwords, but many users still prefer to sign in to apps with a social account. Fortunately Passlock allows you to add social login to your SvelteKit apps.
Social login is the next tutorial in this SvelteKit authentication series…
Founder
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.
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.
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.
Roaming authenticators allow users to sign in to a website on one device, using a passkey stored on a different device.
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.