Blog

How To Implementing Passkeys? (NuxtJs)

Sadeq Sheikhi
Sadeq Sheikhi
Published at: Sep 4, 2026
How To Implementing Passkeys? (NuxtJs)

In this guide I'll show you how to implement passkeys in Nuxt using WebAuthn and SimpleWebAuthn. We'll cover registration, passwordless authentication, credential storage, challenge handling, and the Nuxt client/server architecture.

If you're not familiar with how passkeys, authenticators, public and private keys, and WebAuthn work, read Passkeys Explained first.

The implementation here does not depend on a particular authentication product, database schema, or project-specific session system. The examples use @simplewebauthn/browser and @simplewebauthn/server, but the same boundaries work with other WebAuthn libraries too.

Passkey architecture in Nuxt

The final flow has four main pieces: the Nuxt client, Nitro API routes, a server-side passkey service, and credential storage. Registration creates a passkey for an authenticated user, while authentication verifies a WebAuthn assertion and then creates your application's normal session.

flowchart LR
    UI["Nuxt component"] -->|"startRegistration / startAuthentication"| Browser["Browser WebAuthn API"]
    UI -->|"options and verify requests"| API["Nitro API routes"]
    API --> Challenge["Short-lived challenge store"]
    API --> Service["Passkey service"]
    Service --> WebAuthn["@simplewebauthn/server"]
    Service --> Repo["Credential repository"]
    Repo --> DB[("Database")]
    Service --> Session["Session service"]

Keep the route layer thin. It should authenticate the current user where needed, validate the request envelope, manage the challenge, call the service, and return the project's standard API response. Put WebAuthn decisions and persistence mapping in a server-only service.

Install SimpleWebAuthn in Nuxt

pnpm add @simplewebauthn/browser @simplewebauthn/server

Keep the browser package out of server imports and the server package out of client bundles. Nuxt's auto-imports do not change that browser/server boundary.

Configure WebAuthn RP ID and origin

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    auth: {
      rpId: process.env.NUXT_AUTH_RP_ID || 'localhost',
      rpName: process.env.NUXT_AUTH_RP_NAME || 'Example App',
      origin: process.env.NUXT_AUTH_PASSKEY_ORIGIN || 'http://localhost:3000',
    },
  },
})

The RP ID is a hostname such as example.com, not a URL. The origin includes the scheme and port, such as https://example.com. They must match the origin from which the browser runs.

Document these variables in .env.example and configure them separately for local, staging, and production environments.

Store passkey credentials

At minimum, store:

type Passkey = {
  id: string
  userId: string
  credentialId: string
  publicKey: Uint8Array
  counter: number
  transports: string[] | null
  deviceName: string | null
  createdAt: Date
  lastUsedAt: Date | null
}

Put a unique constraint on credentialId and an index on userId. Store the public key as binary data. Never store a private key, biometric template, or raw authenticator secret.

Create passkey registration endpoints

Registration starts while the user is already authenticated. The server creates a WebAuthn challenge and returns registration options to the browser. The authenticator then creates the credential and the server verifies the result before storing its public data.

The options endpoint must therefore require an authenticated session. It can also request a discoverable credential when the product wants username-less login.

// server/api/passkeys/register/options.post.ts
export default defineEventHandler(async (event) => {
  const user = await requireUser(event)
  const config = useRuntimeConfig(event).auth
  const existing = await passkeys.findByUserId(user.id)

  const options = await generateRegistrationOptions({
    rpName: config.rpName,
    rpID: config.rpId,
    userID: Buffer.from(user.id),
    userName: user.email,
    userDisplayName: user.name || user.email,
    excludeCredentials: existing.map(credential => ({
      id: credential.credentialId,
      transports: credential.transports || undefined,
    })),
    authenticatorSelection: {
      residentKey: 'preferred',
      userVerification: 'required',
    },
  })

  await setPasskeyChallenge(event, options.challenge)
  return { data: options }
})

residentKey: 'preferred' allows the authenticator to create a discoverable credential, while userVerification: 'required' requires local user verification such as a biometric check, device PIN, or another authenticator-supported mechanism.

The verify endpoint reads the challenge, consumes it immediately, validates the body, and verifies the WebAuthn response.

// server/api/passkeys/register/verify.post.ts
export default defineEventHandler(async (event) => {
  const user = await requireUser(event)
  const body = await readValidatedBody(event, registerSchema.parse)
  const challenge = await consumePasskeyChallenge(event)
  const config = useRuntimeConfig(event).auth

  const result = await verifyRegistrationResponse({
    response: body.response,
    expectedChallenge: challenge,
    expectedOrigin: config.origin,
    expectedRPID: config.rpId,
    requireUserVerification: true,
  })

  if (!result.verified || !result.registrationInfo) {
    throw createError({
      statusCode: 400,
      statusMessage: 'Passkey verification failed',
    })
  }

  const { credential } = result.registrationInfo

  await passkeys.create({
    userId: user.id,
    credentialId: credential.id,
    publicKey: credential.publicKey,
    counter: credential.counter,
    transports: credential.transports || null,
    deviceName: body.deviceName || null,
  })

  return { data: { ok: true } }
})

In a production Nuxt application, wrap these handlers in the application's standard API error wrapper and use its normal response helpers.

Create passkey authentication endpoints

Passkey login follows the same challenge-response pattern. The server creates authentication options, the authenticator signs the challenge, and the server verifies that assertion using the public key stored during registration.

The options endpoint can be public, but it must be rate-limited. An empty allowCredentials list allows discoverable credentials, meaning the browser and authenticator can determine which account the passkey belongs to.

// server/api/passkeys/authenticate/options.post.ts
export default defineEventHandler(async (event) => {
  await applyAuthRateLimit(event)

  const config = useRuntimeConfig(event).auth

  const options = await generateAuthenticationOptions({
    rpID: config.rpId,
    allowCredentials: [],
    userVerification: 'required',
  })

  await setPasskeyChallenge(event, options.challenge)
  return { data: options }
})

Verification looks up the credential by the assertion's credential ID and then uses its stored public key and counter.

// server/api/passkeys/authenticate/verify.post.ts
export default defineEventHandler(async (event) => {
  await applyAuthRateLimit(event)

  const body = await readValidatedBody(event, authenticateSchema.parse)
  const challenge = await consumePasskeyChallenge(event)
  const config = useRuntimeConfig(event).auth

  const stored = await passkeys.findByCredentialId(body.response.id)

  if (!stored) throw unauthorized()

  const result = await verifyAuthenticationResponse({
    response: body.response,
    expectedChallenge: challenge,
    expectedOrigin: config.origin,
    expectedRPID: config.rpId,
    requireUserVerification: true,
    credential: {
      id: stored.credentialId,
      publicKey: stored.publicKey,
      counter: stored.counter,
      transports: stored.transports || undefined,
    },
  })

  if (!result.verified) throw unauthorized()

  await passkeys.updateCounter(
    stored.id,
    result.authenticationInfo.newCounter,
  )

  const session = await issueSession(stored.userId)

  return { data: session }
})

The counter is not a replacement for all replay protection, but updating it lets the library detect suspicious authenticator cloning in supported cases. After verification, use the project's existing session-cookie implementation. Passkeys should authenticate the user, not introduce an entirely separate session system.

Connect passkeys to the Nuxt client

<script setup lang="ts">
import { startAuthentication } from '@simplewebauthn/browser'

const loading = ref(false)
const error = ref<string | null>(null)

async function signIn() {
  loading.value = true
  error.value = null

  try {
    const options = await $fetch('/api/passkeys/authenticate/options', {
      method: 'POST',
    })

    const response = await startAuthentication({
      optionsJSON: options.data,
    })

    await $fetch('/api/passkeys/authenticate/verify', {
      method: 'POST',
      body: { response },
    })

    await navigateTo('/account')
  } catch (cause) {
    if (
      cause instanceof DOMException &&
      cause.name === 'NotAllowedError'
    ) return

    error.value = 'Passkey sign-in failed'
  } finally {
    loading.value = false
  }
}
</script>

Use your application's internal API caller instead of raw $fetch when a shared wrapper exists. Registration uses the same client-side sequence: request options, call startRegistration, and then send the resulting response back to the verification endpoint.

How the passkey flow works

Registration: Nuxt requests registration options, the server creates a challenge, the browser invokes the authenticator, and the server verifies the result before storing the public credential data.

Authentication: Nuxt requests authentication options, the authenticator signs the challenge, the server verifies that signature using the stored public key, and the application's normal session is created.

Passkey challenge storage

A short-lived, httpOnly, SameSite=Strict cookie is simple for a single Nuxt origin. A server-side store such as Redis is useful when requests can land on different instances or when you need richer ceremony state.

Whichever store you choose:

  • Expire challenges quickly.
  • Bind registration challenges to the authenticated user.
  • Consume them once.
  • Avoid putting secrets in client-visible storage.
  • Consider how two browser tabs or two concurrent ceremonies should behave.

Passkey management APIs

Add authenticated endpoints to list and remove a user's credentials. Return only display metadata such as ID, device name, creation date, and last-used date.

Check ownership in the service or database query, not only in the Nuxt component.

const owned = await passkeys.findById(id)

if (!owned || owned.userId !== session.userId) {
  throw createError({
    statusCode: 404,
    statusMessage: 'Passkey not found',
  })
}

await passkeys.remove(id)

Passkey security checklist

  • Verify the challenge, expected origin, and expected RP ID on every ceremony.
  • Require user verification when the application needs biometric or PIN confirmation.
  • Rate-limit public options and verification endpoints.
  • Clear or consume the challenge before verification work.
  • Keep refresh tokens in secure httpOnly cookies.
  • Do not log raw assertions or return internal verification errors to users.
  • Check account status before issuing a session.
  • Prevent credential enumeration with consistent errors.
  • Provide multiple passkeys and a carefully protected account recovery path.
  • Test unsupported browsers, cancellation, timeouts, wrong origins, missing challenges, replay attempts, and deleted credentials.

Testing passkeys in Nuxt

Unit-test your service around the WebAuthn library: options generation, failed verification, missing credentials, inactive users, counter updates, ownership checks, and session issuance.

Add HTTP integration tests for challenge cookies, authentication requirements, validation, rate limits, credential listing, and deletion.

For a full WebAuthn ceremony test, use a browser virtual authenticator or a maintained test fixture. A normal HTTP test cannot manufacture a valid assertion unless a test authenticator signs the challenge with the corresponding private key.

Final design principle

Nuxt is responsible for routing and rendering, WebAuthn is responsible for the authentication ceremony, and your server remains responsible for policy, persistence, and sessions.

Keeping those responsibilities separate makes passkeys easier to add, test, migrate, and eventually support across more than one frontend. It also means you can add passkey registration and passwordless login to an existing Nuxt authentication system without replacing your current session architecture.

Sadeq Sheikhi
Sadeq Sheikhi

Hi, Im a seniour software engineer building web platforms. I write about Nuxt, Typescript, DevOps, AI and engineering decisions behind real products, based on my real experience.

Read More

Passkeys Explained: How Passwordless Authentication Works

Passkeys make signing in feel almost ridiculously simple: choose an account, scan your fingerprint, and you're in. But underneath that tiny interaction is WebAuthn, public-key cryptography, authenticators, challenges, and a much stronger security model than traditional passwords. This article explains how it all works.