Passwordless Sign-In with Nuxt 4 + TypeScript + Passkeys (WebAuthn) + Nitro SQL

Every auth tutorial ends the same way: hash a password, store it, hope nobody reuses it. Passkeys skip that entirely. The user's device holds a private key, your server holds a public key, and there is no shared secret to leak, phish, or rotate.
The interesting part is that you don't need a third-party identity provider to do it. Nuxt 4 ships a server, that server ships a SQL layer, and one module wraps the whole WebAuthn ceremony.
This guide wires four pieces into one app:
- nuxt-auth-utils for the WebAuthn handlers and sealed-cookie sessions
- SimpleWebAuthn underneath, doing the actual cryptographic verification
- Nitro's built-in SQL layer (
useDatabase, powered by db0) for users and credentials — no ORM, no migration tool requireUserSession+ route middleware so protected pages are protected on both sides
Tags: Nuxt 4, TypeScript, WebAuthn, Passkeys, Nitro, db0, SQLite
Time to read: 16 min
What you'll build: a notes app you sign into with Face ID, Touch ID, Windows Hello or a hardware key. No password field anywhere.
Why this combination
nuxt-auth-utils is the only piece here that isn't already in the box, and it earns its place by being thin. It doesn't own your user table, it doesn't define a schema, and it doesn't hand you a <SignIn> component. It gives you two event handlers with callbacks at the points where your data lives, and a cookie session that's sealed rather than stored server-side.
SimpleWebAuthn does the part you should not write yourself: parsing attestation objects, validating COSE keys, checking signature counters. nuxt-auth-utils is a wrapper around it, which matters when you read errors — the stack traces name SimpleWebAuthn, and its docs are the ones that will actually explain them.
Nitro's SQL layer is worth reaching for precisely because passkey storage is so small. Two tables, six columns, no relations worth modelling. Pulling in an ORM and a migration CLI for that is ceremony. useDatabase() is auto-imported in server/, uses tagged templates with automatic parameter binding, and swaps SQLite for Postgres or libSQL by changing config rather than code.
The session is a sealed cookie, not a row. WebAuthn already gives you a stateless proof of identity per login; keeping a server-side session table to record the result of that is work you don't need at this size.
Prerequisites
- Node.js 22.13+ (there's a real reason for the odd minimum — see step 2)
- Nuxt 4 and basic Composition API knowledge
- A device with a platform authenticator (any recent Mac, iPhone, Android phone, or Windows machine with Hello), or a hardware key
1) Scaffold and install
npx nuxi@latest init nuxt4-passkeys
cd nuxt4-passkeys
npx nuxi@latest module add auth-utils
npm i @simplewebauthn/server@11 @simplewebauthn/browser@11
npm i zod
Pin
@simplewebauthnto v11. nuxt-auth-utils declares both packages as optional peer dependencies at^11.0.0, and v12 moved types around. Installing@latesthere is the single most common way to end up with a module that fails to build. If the peers are missing entirely, the module logs an error and callsprocess.exit(1)during setup — an abrupt failure, but at least an unambiguous one.
Then turn WebAuthn on. It's opt-in, and nothing is registered until you set the flag:
// nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
modules: ['nuxt-auth-utils'],
auth: {
webAuthn: true,
},
nitro: {
experimental: {
database: true,
},
},
})
You also need a session password of at least 32 characters:
# .env
NUXT_SESSION_PASSWORD=at-least-32-characters-of-random-noise
In development the module generates one and appends it to .env for you on first run. In production it will not — an unset password means unsealable cookies, so set it explicitly in your deploy environment.
2) The database — and the connector alias that changed
nitro.experimental.database: true does two things: it auto-imports useDatabase in server/, and in dev it configures a default SQLite connection rooted at your project directory. You get a working database with no further config.
The trap is which SQLite. Nitro's default connection asks db0 for the connector named sqlite, and as of db0 0.3.1 that name aliases node-sqlite — Node's built-in node:sqlite module. Up to and including 0.3.0 it aliased better-sqlite3. Nuxt 4 currently resolves db0 0.3.4 through Nitro, so you get the new behaviour, and on Node 20 the default configuration fails at the first query with:
`node:sqlite` module is not available.
Please ensure you are running in Node.js >= 22.5 or Deno >= 2.2.
Take that message with a pinch of salt. node:sqlite landed in 22.5, but it stayed behind --experimental-sqlite until 22.13 — and db0 detects it with process.getBuiltinModule('node:sqlite'), which returns undefined when the flag isn't set. On 22.5 through 22.12 you get db0's "not available" error while running a Node that, technically, has the module.
Two ways out. Either run Node 22.13+ and take the zero-dependency default, or name the connector explicitly:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: { database: true },
database: {
default: {
connector: 'better-sqlite3',
options: { name: 'app' },
},
},
},
})
npm i better-sqlite3
The file naming differs between the two, which is its own small surprise: node-sqlite writes .data/{name}.sqlite, better-sqlite3 writes .data/{name}.sqlite3. Switch connectors and your data appears to vanish. It's sitting next to the new file.
Being explicit is the better default regardless — connector aliases are exactly the kind of thing that moves under you between minor versions.
3) Schema in a Nitro plugin
There's no migration tool here, and for two tables you don't need one. A Nitro plugin runs once at server startup, before any request is handled:
// server/plugins/database.ts
export default defineNitroPlugin(async () => {
const db = useDatabase()
await db.sql`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
createdAt INTEGER NOT NULL
)`
await db.sql`
CREATE TABLE IF NOT EXISTS credentials (
userId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
id TEXT UNIQUE NOT NULL,
publicKey TEXT NOT NULL,
counter INTEGER NOT NULL,
backedUp INTEGER NOT NULL,
transports TEXT NOT NULL,
PRIMARY KEY ("userId", "id")
)`
await db.sql`
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
userId INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL
)`
})
Every column in credentials is load-bearing:
id— the credential ID the authenticator generated. Unique across all users; it's what the browser sends back at login, and how you find the right public key.publicKey— base64url-encoded. nuxt-auth-utils hands it to you already encoded and expects it back in the same form.counter— incremented by the authenticator on each use. Security keys maintain it; most platform authenticators (and anything syncing through iCloud Keychain or a password manager) leave it at zero. Store it and update it, but don't build cloning detection on it unless you know your users are on hardware keys.backedUp— whether the credential syncs across devices.INTEGERbecause SQLite has no boolean, so this needs converting in both directions.transports— a JSON-encoded array like["internal","hybrid"]. The browser uses it to show the right prompt: "use Touch ID" versus "scan the QR code with your phone".
One user has many credentials, by design: the same account gets a passkey on the laptop and another on the phone. The composite primary key mirrors that relationship, though id TEXT UNIQUE is what actually enforces uniqueness.
That DDL is SQLite dialect, not portable SQL. AUTOINCREMENT doesn't exist in Postgres, and the unquoted mixed-case column names would fold to lowercase there while the quoted ones in the PRIMARY KEY clause wouldn't. Moving to another connector means rewriting this file — the queries in the handlers survive, the schema doesn't.
4) Type the session
The session is a plain object you define. Declare its shape once and both useUserSession() in components and getUserSession(event) on the server are typed:
// shared/types/auth.d.ts
declare module '#auth-utils' {
interface User {
id: number
email: string
}
interface UserSession {
loggedInAt: number
}
}
export {}
Keep this small on purpose. Session data is encrypted into a cookie, and cookies cap out at 4096 bytes. An id and an email is the right amount; a serialised user profile is not.
5) Registration
Both WebAuthn handlers are two-phase: the client posts once to get challenge options, the browser performs the ceremony, then the client posts the result back to the same URL. The module routes on a verify flag in the body, so this is one file, not two.
// server/api/webauthn/register.post.ts
import { z } from 'zod'
export default defineWebAuthnRegisterEventHandler({
async validateUser(userBody, event) {
// If someone is already signed in, they're adding a second passkey —
// make sure it's for their own account and not someone else's.
const session = await getUserSession(event)
if (session.user?.email && session.user.email !== userBody.userName) {
throw createError({ statusCode: 400, message: 'Email does not match the current session' })
}
return z.object({
userName: z.string().trim().toLowerCase().pipe(z.email()),
displayName: z.string().trim().max(64).optional(),
}).parse(userBody)
},
async onSuccess(event, { credential, user }) {
const db = useDatabase()
await db.sql`BEGIN TRANSACTION`
try {
let { rows: [dbUser] } = await db.sql<{ rows: { id: number, email: string }[] }>`
SELECT id, email FROM users WHERE email = ${user.userName}`
if (!dbUser) {
await db.sql`INSERT INTO users (email, createdAt) VALUES (${user.userName}, ${Date.now()})`
dbUser = (await db.sql<{ rows: { id: number, email: string }[] }>`
SELECT id, email FROM users WHERE email = ${user.userName}`).rows[0]!
}
await db.sql`
INSERT INTO credentials (userId, id, publicKey, counter, backedUp, transports)
VALUES (
${dbUser.id},
${credential.id},
${credential.publicKey},
${credential.counter},
${credential.backedUp ? 1 : 0},
${JSON.stringify(credential.transports ?? [])}
)`
await db.sql`COMMIT`
await setUserSession(event, {
user: { id: dbUser.id, email: dbUser.email },
loggedInAt: Date.now(),
})
}
catch (error) {
await db.sql`ROLLBACK`
throw createError({
statusCode: 500,
message: error instanceof Error && error.message.includes('UNIQUE constraint failed')
? 'That passkey is already registered'
: 'Failed to store credential',
})
}
},
})
A few things worth pulling out.
validateUser runs before any crypto happens. It receives body.user — not the whole request body — which is why the schema above describes userName and displayName at the top level. Whatever it returns becomes the typed user in onSuccess. This is the only place to reject a signup; by the time onSuccess fires, the browser has already minted a keypair.
Order the string transforms before the format check. Zod applies checks in declaration order, so z.string().email().trim() validates the untrimmed input and rejects " me@example.com ". Trim, lowercase, then pipe into z.email(). (.email() as a ZodString method still works in Zod 4 but is deprecated in favour of the top-level z.email().)
The db.sql generic replaces the entire result, not the row type. The signature is sql<T = DefaultSQLResult>(...), so you write db.sql<{ rows: User[] }> and not db.sql<User>. Get this wrong and TypeScript will tell you rows doesn't exist on a type you thought was an array.
Insert-then-select is not laziness. Both SQLite connectors do return lastInsertRowid, but the non-SQLite ones don't — the Postgres connector hands back node-postgres' result object instead. Reading the row back inside the transaction works everywhere.
BEGIN sits outside the try. Deliberately. If the transaction never opened, ROLLBACK throws cannot rollback - no transaction is active, and that exception replaces the real one on its way up. Open first, then guard the work.
The transaction is doing real work. A user row without its credential is an account nobody can ever sign into, and there's no UI to repair it. Either both rows land or neither does.
6) Authentication
Login needs two callbacks — how to look a credential up, and what to do once it verifies — plus an optional third that makes the experience much better.
// server/api/webauthn/authenticate.post.ts
interface CredentialRow {
userId: number
id: string
publicKey: string
counter: number
backedUp: number
transports: string
}
export default defineWebAuthnAuthenticateEventHandler({
async allowCredentials(event, userName) {
const { rows } = await useDatabase().sql<{ rows: { id: string }[] }>`
SELECT credentials.id
FROM users
INNER JOIN credentials ON credentials.userId = users.id
WHERE users.email = ${userName}`
if (!rows.length)
throw createError({ statusCode: 400, message: 'No passkey found for that address' })
return rows
},
async getCredential(event, credentialId) {
const { rows } = await useDatabase().sql<{ rows: CredentialRow[] }>`
SELECT * FROM credentials WHERE id = ${credentialId}`
if (!rows.length)
throw createError({ statusCode: 400, message: 'Credential not found' })
const [credential] = rows
return {
...credential!,
backedUp: Boolean(credential!.backedUp),
transports: JSON.parse(credential!.transports),
}
},
async onSuccess(event, { credential, authenticationInfo }) {
const db = useDatabase()
const { rows } = await db.sql<{ rows: { id: number, email: string }[] }>`
SELECT users.id, users.email
FROM credentials
INNER JOIN users ON users.id = credentials.userId
WHERE credentials.id = ${credential.id}`
await db.sql`
UPDATE credentials SET counter = ${authenticationInfo.newCounter}
WHERE id = ${credential.id}`
const user = rows[0]!
await setUserSession(event, {
user: { id: user.id, email: user.email },
loggedInAt: Date.now(),
})
},
})
That optional third callback is allowCredentials, and it changes the experience noticeably. Supply it and the browser knows exactly which credentials are valid, so it goes straight to the fingerprint prompt. Omit it — or send no userName, since the module only calls it when one is present — and the user gets a picker listing every passkey they own for the site.
getCredential is where the SQLite impedance mismatch gets paid off. backedUp comes back as 0 or 1 and transports as a string; SimpleWebAuthn wants a boolean and an array. Two lines, but skip them and verification fails with an error that points at the crypto rather than at the type coercion that actually caused it.
7) Turn on replay protection
Here's the part the README flags and most implementations skip.
WebAuthn defends against replay attacks with a challenge: the server generates a random value, the authenticator signs it, the server checks it got back what it sent. By default nuxt-auth-utils does not do this. If you don't supply storeChallenge, the module sets challenge to an empty string on the way out and compares against an empty string on the way back. Verification passes, and a captured assertion stays valid forever.
Fixing it takes six lines per handler, using Nitro's KV layer:
// server/utils/challenge.ts
import type { H3Event } from 'h3'
const storage = () => useStorage<string>('webauthn')
export async function storeChallenge(_event: H3Event, challenge: string, attemptId: string) {
await storage().setItem(attemptId, challenge)
}
export async function getChallenge(_event: H3Event, attemptId: string) {
const challenge = await storage().getItem(attemptId)
// Single use, always — remove it before deciding whether it was valid.
await storage().removeItem(attemptId)
if (!challenge)
throw createError({ statusCode: 400, message: 'Challenge expired' })
return challenge
}
Then pass them to both handlers:
export default defineWebAuthnRegisterEventHandler({
storeChallenge,
getChallenge,
// validateUser, onSuccess as above
})
Note the ordering inside getChallenge: the delete happens before the check, not after. Remove it only on success and a failed attempt leaves a reusable challenge sitting in storage, which is the bug you were trying to prevent.
useStorage() defaults to in-memory, so challenges die on restart. That's acceptable — the window between the two requests is seconds — but it does mean two server instances behind a load balancer will hand each other's challenges back as expired. Mount Redis for the webauthn namespace and the problem goes away without touching this file:
// nuxt.config.ts
nitro: {
storage: {
webauthn: { driver: 'redis', url: process.env.REDIS_URL },
},
},
There's no TTL above, so unused challenges accumulate. The Redis driver takes a ttl option; with the memory driver, treat process restarts as your cleanup.
8) The client
useWebAuthn handles both round trips and the browser API in between:
<!-- app/pages/login.vue -->
<script setup lang="ts">
const { register, authenticate, isSupported } = useWebAuthn()
const { fetch: refreshSession } = useUserSession()
const email = ref('')
const error = ref('')
const busy = ref(false)
async function run(action: () => Promise<unknown>) {
error.value = ''
busy.value = true
try {
await action()
await refreshSession()
await navigateTo('/notes')
}
catch (err: unknown) {
// A user dismissing the system prompt throws NotAllowedError. Not an error worth shouting about.
error.value = err instanceof Error && err.name === 'NotAllowedError'
? 'Cancelled.'
: 'Something went wrong. Try again.'
}
finally {
busy.value = false
}
}
</script>
<template>
<main>
<h1>Sign in</h1>
<p v-if="!isSupported">
This browser doesn't support passkeys.
</p>
<form v-else @submit.prevent="run(() => authenticate(email))">
<input v-model="email" type="email" autocomplete="username webauthn" required placeholder="you@example.com">
<button type="submit" :disabled="busy">Sign in with a passkey</button>
<button type="button" :disabled="busy" @click="run(() => register({ userName: email }))">
Create an account
</button>
</form>
<p v-if="error">{{ error }}</p>
</main>
</template>
Markup is left unstyled so the mechanics stay visible.
isSupported is a ref that stays false until onMounted — it's set from browserSupportsWebAuthn(), which touches window. That's the right behaviour for SSR, but it means the server renders the unsupported branch and the client swaps it after hydration. If that flash bothers you, render a neutral placeholder until it resolves rather than trying to hoist the check earlier.
autocomplete="username webauthn" is the hint that lets browsers offer a passkey directly from the field's autofill dropdown. Making that path actually work also needs useWebAuthn({ useBrowserAutofill: true }) and a call to authenticate() on mount — worth doing once the basics are solid.
Calling refreshSession() after the ceremony is not optional. The handler set a cookie, but the client-side session state was populated before that happened and won't notice on its own.
9) Protecting things
Two layers, because they solve different problems.
On the server, requireUserSession throws a 401 if there's no user:
// server/api/notes.get.ts
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const { rows } = await useDatabase().sql<{ rows: { id: number, body: string }[] }>`
SELECT id, body FROM notes WHERE userId = ${user.id} ORDER BY id DESC`
return rows
})
On the client, route middleware keeps signed-out users off the page in the first place:
// app/middleware/auth.ts
export default defineNuxtRouteMiddleware(() => {
const { loggedIn } = useUserSession()
if (!loggedIn.value) return navigateTo('/login')
})
<script setup lang="ts">
definePageMeta({ middleware: 'auth' })
const { data: notes } = await useFetch('/api/notes')
</script>
The middleware is a convenience, not a control. The server check is the one that matters — and it's why every query above filters on user.id from the session rather than an id from the request.
For header UI, <AuthState> avoids the hydration mismatch you'd otherwise hit on cached or prerendered routes:
<template>
<header>
<AuthState v-slot="{ loggedIn, clear }">
<button v-if="loggedIn" @click="clear">Sign out</button>
<NuxtLink v-else to="/login">Sign in</NuxtLink>
</AuthState>
</header>
</template>
One caveat on useFetch and sessions: during SSR, $fetch doesn't forward cookies. useFetch handles this for you, but a bare $fetch inside useAsyncData does not — use useRequestFetch() there.
10) Revocation, via session hooks
Sealed cookies have one real drawback: nothing on the server can invalidate one. Delete the user row and their cookie still decrypts to a valid-looking session until it expires.
The fetch hook is where you re-check reality:
// server/plugins/session.ts
export default defineNitroPlugin(() => {
sessionHooks.hook('fetch', async (session) => {
if (!session.user) return
const { rows } = await useDatabase().sql<{ rows: { id: number }[] }>`
SELECT id FROM users WHERE id = ${session.user.id}`
if (!rows.length)
throw createError({ statusCode: 401, message: 'Account no longer exists' })
})
})
Be precise about when this fires, because the name oversells it. The hook is called from exactly one place: the module's own GET /api/_auth/session route — so on the SSR session load, and on every useUserSession().fetch(). It does not run inside getUserSession or requireUserSession. A deleted user's cookie keeps passing the check in server/api/notes.get.ts until the client refetches its session.
So this buys you revocation at page-load granularity, which is usually what you want, for one indexed lookup per session fetch. If you need it per request, add the same check to a server middleware — and cache it with defineCachedFunction on a short TTL, so the cost is a revocation window measured in seconds rather than a query on every API call.
11) Try it
npm run dev
Open http://localhost:3000/login, enter an email, and hit Create an account. Your OS prompts for biometrics. Sign out, sign back in — no password, ever.
localhost is a secure context by definition, which is why this works over plain HTTP in dev.
Use the name, though, not the address. Secure context and "valid RP ID" are two separate gates, and IP literals fail the second one: Chromium and Safari require the rpID to look like a domain, so 127.0.0.1 is rejected even though it is a secure context. Firefox is more permissive, which is a good way to convince yourself it works and then discover it doesn't. A LAN address like 192.168.1.20 fails both gates. Testing on a phone means a tunnel with a real certificate and a real hostname.
To confirm the challenge wiring is live, look at the first response in DevTools → Network. creationOptions.challenge should be a long base64url string. An empty string means storeChallenge isn't reaching the handler.
Then check it builds:
npx nuxt typecheck
npx nuxt build
node .output/server/index.mjs
12) Deploying — where rpID will bite you
This is the section to read twice, because everything above works perfectly on localhost and then falls over behind a proxy.
The module never asks you for a domain. It derives one per request:
const url = getRequestURL(event)
// rpID: url.hostname
// expectedOrigin: url.origin
And h3's getRequestURL, called with no options, reads the Host header — not X-Forwarded-Host. Protocol is the exception: an X-Forwarded-Proto: https header is honoured by default.
nginx's default upstream Host header is $proxy_host, meaning your Node server sees 127.0.0.1:3000. Passkeys get registered against an rpID of 127.0.0.1, and the browser refuses to use them on your real domain. So:
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host; # rpID comes from this
proxy_set_header X-Forwarded-Proto $scheme; # expectedOrigin needs https
proxy_set_header X-Real-IP $remote_addr;
}
Both lines are required. Miss the first and the rpID is wrong. Miss the second and expectedOrigin is http://your-domain while the browser reports https://your-domain, and verification fails on a string comparison with an error that says nothing about proxies.
Three more deployment facts:
Credentials are bound to the exact hostname. example.com and www.example.com are different relying parties. Pick one, redirect the other, and do it before anyone registers — a passkey created on the wrong host cannot be migrated, only replaced.
HTTPS is mandatory outside localhost. No exceptions, no flags.
Set NUXT_SESSION_PASSWORD yourself. The dev-time generator does not run in production, and it must be identical across every instance or your instances will invalidate each other's cookies. Changing it signs everybody out — which is, incidentally, your emergency logout switch.
Where to take it
- A passkey manager page. List
credentialsfor the signed-in user withbackedUpandtransports, and let them revoke one. Refuse to delete the last one, or you've locked them out. excludeCredentials. Pass the user's existing credential IDs to the register handler and the browser stops them registering the same authenticator twice.- Conditional UI.
useBrowserAutofill: trueplus anauthenticate()call on mount puts passkeys in the email field's dropdown. - AAGUID decoration.
credential.aaguidinonSuccessidentifies the authenticator model — match it against the community AAGUID list to show "1Password" or "iCloud Keychain" instead of a generic key icon. - Postgres. Change
connectortopostgresqland pointoptions.urlat your database. The handler queries carry over as-is; the schema plugin doesn't — swapAUTOINCREMENTforGENERATED BY DEFAULT AS IDENTITY,INTEGER backedUpforBOOLEAN, and quote the mixed-case column names consistently or lowercase them all.
Wrapping up
The whole auth layer is around 120 lines of server code, two tables, and no external identity provider. The pieces fit because each one stops at the right boundary: nuxt-auth-utils owns the ceremony and nothing else, useDatabase gives you SQL without an ORM, and the session is a cookie because at this size a session table would be inventory rather than infrastructure.
Three things to carry into your own build. Turn on storeChallenge — the default is not replay-safe, and it fails silently. Coerce SQLite's integers and JSON strings back into booleans and arrays at the boundary, or debug crypto errors that aren't crypto errors. And set proxy_set_header Host $host before you let a single user register, because rpID is derived from that header and a passkey bound to the wrong one is unrecoverable.





