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.
This tutorial forms the second part of our SvelteKit series. If you haven’t already done so, please read the first tutorial. In this post I’ll show you how to manage local users and sessions using Lucia, an awesome authentication framework that works well with SvelteKit.
Starter App
I've created a starter app which uses passkeys, Lucia sessions, social login and more. Create your own app using the wizard: pnpm create @passlock/sveltekit
Overview
This tutorial is a bit longer than the first instalment as we’re covering a few concepts. It will probably take around 60 minutes to complete.
The prisma init script should have created a prisma directory in your application route. It should also have updated your .env file with a DATABASE_URL key:
Next, define the database schema by editing prisma/schema.prisma:
// prisma/schema.prismagenerator client { provider = "prisma-client-js"}datasource db { provider = "sqlite" url = env("DATABASE_URL")}model User { id String @id @default(uuid()) email String @unique givenName String? familyName String? sessions Session[]}model Session { id String @id @default(uuid()) userId String expiresAt DateTime user User @relation(references: [id], fields: [userId], onDelete: Cascade)}
Run the initial migration:
pnpm dlx prisma migrate dev --name init
NOTE
This will create a SQLite database at prisma/dev.db along with a journal file and migrations directory. If you look at prisma/migrations/xxx_init/migration.sql you'll see the DDL scripts used to create the user and session tables.
User registration
Previously we registered a passkey on the users device. We’ll now create a user in our database and link the passkey to the local user. Update the registration form action:
// src/routes/register/+page.server.tsimport type { Actions } from './$types'import { Passlock, TokenVerifier } from '@passlock/sveltekit'import { PUBLIC_PASSLOCK_TENANCY_ID } from '$env/static/public'import { PASSLOCK_API_KEY } from '$env/static/private'import { PrismaClient } from '@prisma/client'import { error, redirect } from '@sveltejs/kit'const client = new PrismaClient()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 result = await tokenVerifier.exchangeToken(token) if (Passlock.isPrincipal(result)) { // FIXME JUST FOR DEVELOPMENT TESTING!!! await client.user.deleteMany() // create a user in the local db await client.user.create({ data: { id: result.sub, email: result.email as string, givenName: result.givenName, familyName: result.familyName } }) redirect(302, '/login') } else { console.error(result) error(500, result.message) } }}
NOTE
Remember to remove the client.user.deleteMany() call! We're deleting all users before creating a new one to avoid duplicate constraint errors on the email field. You obviously wouldn't use this in production!
Try to register a user
IMPORTANT
If you previously registered a passkey ensure you delete the account in your Passlock console. Otherwise you'll get errors telling you that the user already exists.
You can now try to register a user account and associated passkey. Navigate to the /register page. The client side template and form action will together:
Register a passkey on the users device
Register the public key component in your Passlock vault
Create a local Lucia user and link the passkey to that account
If all goes well, you should be redirected to the login page.
User authentication
Previously, we prompted the user to authenticate with their passkey, then verified the token in our form action. We’ll build on that work to:
Lookup a local user using the sub property
Create a Lucia session for that user
Invalidate any other user sessions
We’ll update the login action to hook into Lucia, but before we do that, let’s setup Lucia itself. Create a file at src/lib/server/auth.ts:
// src/lib/server/auth.tsimport { Lucia } from 'lucia'import { dev } from '$app/environment'import { PrismaAdapter } from '@lucia-auth/adapter-prisma'import { PrismaClient } from '@prisma/client'const client = new PrismaClient()const adapter = new PrismaAdapter(client.session, client.user)export const lucia = new Lucia(adapter, { sessionCookie: { attributes: { // set to `true` when using HTTPS secure: !dev } }})declare module 'lucia' { interface Register { Lucia: typeof lucia }}
Next, update the login action:
// src/routes/login/+page.server.tsimport type { Actions } from './$types'import { Passlock, TokenVerifier } from '@passlock/sveltekit'import { PUBLIC_PASSLOCK_TENANCY_ID } from '$env/static/public'import { PASSLOCK_API_KEY } from '$env/static/private'import { lucia } from '$lib/server/auth'import { error, redirect } from '@sveltejs/kit'const tokenVerifier = new TokenVerifier({ tenancyId: PUBLIC_PASSLOCK_TENANCY_ID, apiKey: PASSLOCK_API_KEY})export const actions: Actions = { default: async ({ request, cookies }) => { const formData = await request.formData() const token = formData.get('token') as string const result = await tokenVerifier.exchangeToken(token) if (Passlock.isPrincipal(result)) { // delete other active sessions await lucia.invalidateUserSessions(result.sub) const session = await lucia.createSession(result.sub, {}) const sessionCookie = lucia.createSessionCookie(session.id) // set a cookie representing the new session cookies.set(lucia.sessionCookieName, sessionCookie.value, { path: '/', ...sessionCookie.attributes }) redirect(302, '/') } else { console.error(result) error(500, result.message) } }}
TIP
You don't have to invalidate the other sessions, but it's good practice to do so.
Try logging in
Visit the /login page and try to login using the same email address you used for registration. If all goes well, you should be redirected back to the home page.
Now check your browser dev tools. You should see a new cookie named auth_session with a long, cryptographically random token.
Finally, we need to protect the routes to ensure that only authenticated users can access them…
Protect the routes
Now that we can authenticate users, we want to protect access to certain routes. We can build quite sophisticated authorization policies, but for now we’ll just protect a single route, permitting access for any authenticated user. We’ll do this using SvelteKit hooks. Our hook will:
We’ll check for the auth_session cookie, lookup the associated session and user then attach them to the request via app locals:
// src/hooks.server.tsimport { lucia } from '$lib/server/auth'import { type Handle } from '@sveltejs/kit'export const handle: Handle = async ({ event, resolve }) => { const sessionId = event.cookies.get(lucia.sessionCookieName) // if no sessionId we can assume there is no session or user const { session, user } = sessionId ? await lucia.validateSession(sessionId) : { session: null, user: null } if (session && session.fresh) { // refreshed session, update the cookie const sessionCookie = lucia.createSessionCookie(session.id) event.cookies.set(sessionCookie.name, sessionCookie.value, { path: '/', ...sessionCookie.attributes }) } if (!session) { // effectively delete the session cookie // by overwriting it with a blank value const sessionCookie = lucia.createBlankSessionCookie() event.cookies.set(sessionCookie.name, sessionCookie.value, { path: '/', ...sessionCookie.attributes }) } // attach the user and session to the request event.locals.user = user event.locals.session = session return resolve(event)}
Try the status route
At the moment we haven’t protected any routes, we simply attach a user to the request. Lets test it. Create a new /status route:
<!-- src/routes/status/+page.svelte --><script lang="ts"> import type { PageData } from './$types' export let data: PageData</script>userId: {data.user?.id}
All we're doing here is grabbing the user from the request and passing it to the template.
Login using your passkey, then visit the /status page. If all goes well, you should see your userId displayed. Now clear your cookies and visit the status page again. userId should be undefined.
Protect the route
There are many better ways of doing this, but let’s go for a simple approach. Update the hooks and check the route id. If routeId === /status and the user is not authenticated we’ll redirect them to the login page:
Clear your cookies and visit /status. You should be redirected to the login page.
Sign out
Clearing cookies all the time is pretty annoying, so lets add a /logout route:
<!-- src/routes/logout/+page.svelte --><script lang="ts"> import type { PageData } from './$types' export let data: PageData</script><form method="post"> <button type="submit">Logout</button></form>
// src/routes/logout/+page.server.tsimport type { Actions } from './$types'import { lucia } from '$lib/server/auth'import { redirect } from '@sveltejs/kit'export const actions: Actions = { default: async ({ locals, cookies }) => { if (locals.session) { await lucia.invalidateSession(locals.session.id) cookies.delete(lucia.sessionCookieName, { path: '/' }) } redirect(302, '/login') }}
Try signing out
Navigate to the /logout page and sign out. You should be redirected to the login page. If you check your browser cookies, you should see that the auth_session cookie has been removed.
Summary
We wired up passkey registration with local account registration. We used the sub field, which represents a user in the Passlock system, as the local user id. However we could have generated a local user id and added a column passlock_sub to the user table.
Instead of using the passlock sub we could insead have used Passlock’s authId (authenticator id) as this represents the passkey itself. However as you’ll later discover, there are some benefits to referencing sub as it allows us to easily add social login to our SvelteKit apps.
Next steps
At this point we have a functional app but it’s still rough around the edges. Passkey registration and authentication seems slow, and the app appears to hang. We’ll deal with that in the next instalment in this series, improving passkey usability
If you've followed the previous instalments in this series, you'll have built a SvelteKit app with passkey authentication, session management and authorization. It's functional, but it feels a bit clunky. Let's improve things.
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.
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.