Passkeys and WebAuthn in Nuxt Apps: Building Passwordless Login the Right Way
AI generated
{ }
Nuxt 3 · Authentication
Passkeys in Nuxt: WebAuthn Without a Password, But With a Fallback
Challenge-response through Nitro server routes and when classic login still makes sense

Passkeys replace the classic password with a cryptographic key pair that the browser manages through the WebAuthn API. This article shows how a Nuxt application implements passkey registration and login, what role Nitro server routes play in generating the challenge, and why a fallback for older browsers is still necessary.

17 min read Nuxt 3 WebAuthn

1. What WebAuthn actually is

WebAuthn is a W3C standard that enables authentication through asymmetric cryptography instead of a shared secret such as a password. During registration, an authenticator, for example a laptop's fingerprint sensor, Windows Hello, or a separate security key, generates a key pair whose private half never leaves the authenticator. The server stores only the public half and can later verify any signature produced by it, without ever holding a secret of its own that could be stolen.

The term passkey refers to a particular, user-friendly flavor of a WebAuthn credential, a so-called discoverable credential that the browser remembers and that automatically syncs across a user's devices through a platform account, such as iCloud Keychain or the Google Password Manager. Older, non-discoverable WebAuthn credentials, by contrast, stayed bound to a single device and had to be registered separately on every device, which made them noticeably less practical for broad adoption.

2. Comparison to classic password plus two-factor authentication

Classic login with a password and a second factor remains vulnerable to phishing despite the added protection, since a password can be typed into a fake site just as easily as into the real one, and a one-time code delivered by SMS or app can be relayed to an attacker in real time. WebAuthn signatures, by contrast, are strictly bound to the origin under which they were created, so a signature generated for mironsoft.de is never valid on any other domain, even if the fake page looks visually identical.

Convenience usually favors passkeys as well: a single biometric prompt or a short PIN entry replaces typing a password and then retrieving a code from a separate app or SMS. The switch does bring its own UX challenges though, such as clear guidance when setting up a passkey on a new device for the first time, and a well thought out recovery path in case every registered device is lost at once.

3. Server-side challenge generation through Nitro

The registration flow starts on the server: it generates a random, single-use challenge and combines it with information about the user and the relying party to build the publicKeyCredentialCreationOptions sent to the client. The browser calls navigator.credentials.create() with these options, has the user confirm through the authenticator, and sends the resulting attestation response back to the server, which verifies it and, on success, permanently stores the public key together with the credential id.

In practice, a library such as simplewebauthn handles this cryptographic detail work both when generating the options and during later verification, so custom code mostly deals with storage and linking the credential to the user account. The snippet below shows a Nitro server route that generates the registration options and stores the associated challenge briefly in Nitro storage, so it can be checked against in the next step.


// server/api/webauthn/register-options.post.ts
import { generateRegistrationOptions } from '@simplewebauthn/server'
import { defineEventHandler, readBody } from 'h3'

export default defineEventHandler(async (event) => {
  const { userId, username } = await readBody(event)

  const options = await generateRegistrationOptions({
    rpName: 'Mironsoft Shop',
    rpID: 'mironsoft.de',
    userID: Buffer.from(userId),
    userName: username,
    attestationType: 'none',
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'preferred'
    }
  })

  await useStorage('webauthn-challenges').setItem(userId, options.challenge)

  return options
})

4. Registration on the client with navigator.credentials

On the client side, the startRegistration function from the @simplewebauthn/browser package takes the options delivered by the server and handles the correct call to navigator.credentials.create(), including the necessary Base64URL encoding of the binary fields. The browser then shows the platform's own prompt, for example the fingerprint sensor or Windows Hello, and on success returns an attestation response, which is then sent unmodified to a second server route.

That second route calls verifyRegistrationResponse, compares the challenge contained in the response with the previously stored value, checks that origin and rpID match, and, on success, extracts the public key. Only after that does the new credential, consisting of the credential id, the public key, and a signature counter, get permanently linked to the relevant user account in the database.

5. Login and verifying the assertion

During login, the server again generates a fresh challenge, this time together with an allowCredentials list pointing at the credential ids known for that user, or, in the usernameless case, without that list at all. The browser calls navigator.credentials.get(), the user picks their passkey, and the authenticator signs the challenge with the private key that was never transmitted, without that key ever having to leave the authenticator.

On the server, verifyAuthenticationResponse checks the signature against the stored public key and also compares the supplied signature counter with the last stored value, to detect cloned authenticators. If the check passes, the application issues a session as usual, for example through an httpOnly cookie, and the passkey login is complete.

6. Fallback strategy for browsers without WebAuthn support

Whether a browser supports WebAuthn at all can be checked with simple feature detection against window.PublicKeyCredential, and PublicKeyCredential.isConditionalMediationAvailable() additionally reveals whether the autofill variant is available as well. If either capability is missing, the application should fall back to a classic login form right away, rather than confronting the user with a passkey request that is bound to fail.

In practice it is worth offering password or magic-link login as a permanent parallel method instead of forcing passkeys, since older browsers, some embedded in-app webviews, and heavily locked-down corporate environments still do not support WebAuthn completely. A forced passkey-only approach would simply lock this group of users out of the application.

7. Usernameless login and conditional UI

Because discoverable credentials are stored directly in the browser or in the system's credential manager, WebAuthn allows logging in without typing a username first: the browser instead shows a picker of the passkeys stored for the current relying party, from which the user selects the right one. This so-called usernameless or discoverable login further reduces the number of interaction steps required.

The conditional UI extension goes one step further and shows matching passkeys as an autofill suggestion directly inside the regular username input field. Technically, this is done by calling navigator.credentials.get() with the mediation: 'conditional' option as soon as the page loads, without the user having to trigger anything first, the browser simply waits in the background for a selection.

8. Account recovery and multi-device handling

Every application with passkey login needs a plan for the case where a user loses all registered devices at once, for example classic email verification, backup codes, or a manual support process. Equally important is a management interface where users can register several passkeys for different devices and remove individual ones again when needed.

Platform passkeys usually sync automatically within one ecosystem, for example through iCloud Keychain across every Apple device tied to an account, or through the Google Password Manager across Android devices. Switching between different ecosystems, for example from an Android phone to a Windows machine, instead goes through the so-called hybrid transport via QR code. For every registered credential, the database should also store metadata such as device name and creation date, so the management interface stays understandable.

9. Security aspects and an operational checklist

The rpID must exactly match the domain the application is served under, otherwise the check already fails inside the browser before any request even reaches the server. Just as mandatory is a server-side origin check on every verification, and stored challenges should only stay valid for a few minutes and be deleted immediately after use, to rule out replay attacks.

It is also worth adding rate limiting on the registration and login endpoints, keeping an eye out for anomalies in the signature counter, and regularly updating the WebAuthn library in use, since details of the specification and of browser implementations keep evolving. Before every rollout it also pays to test on the most important platforms, that is Chrome on Android, Safari on iOS, and Windows Hello, since small behavioral differences between implementations do show up in practice.

Aspect Password + 2FA Passkey (WebAuthn)
Phishing resistance Password can be typed, OTP can be relayed Bound to the origin, effectively phishing-resistant
User effort Remember a password plus retrieve a code One biometric prompt or a PIN
Server-side storage Password hash plus a 2FA secret Only a public key, no secret at all
Device loss Email reset is usually enough Another registered device or a recovery path is needed
Browser support Universal Modern browsers, a fallback is still needed

Mironsoft

Vue architecture, Composition API, and Nuxt performance

Vue applications that don't get more complicated with every feature?

We review existing Vue and Nuxt projects for unstructured composables, unnecessary reactivity, and bloated bundles, then build an architecture that absorbs new features without making the codebase harder to follow.

Architecture Review

Checking composables, state management, and component structure for maintainability.

Performance Audit

Systematically optimizing reactivity overhead, bundle size, and Nuxt rendering strategy.

Nuxt Integration

Building robust, type-safe SSR/SSG setup and API integration.

10. Summary

Passkeys and WebAuthn in Nuxt at a Glance

Standard

WebAuthn (W3C), passkey as the user-friendly branding of a discoverable credential.

Server side

Challenge generation and verification through Nitro server routes.

Security

Bound to the origin, making it effectively phishing-resistant.

Fallback

Password or magic-link login for unsupported environments.

11. FAQ: Passkeys and WebAuthn in Nuxt at a Glance

1What is the difference between a passkey and a classic WebAuthn credential?
A passkey is a WebAuthn credential created as a discoverable credential and synced across devices through a platform account such as iCloud Keychain or the Google Password Manager, while older WebAuthn credentials often stayed bound to a single device.
2Do I need to implement WebAuthn myself from scratch?
No, libraries such as simplewebauthn handle the cryptographic verification of attestation and assertion on the server as well as the browser API calls on the client, so custom code mainly deals with options, storage, and session management.
3Why does WebAuthn need a server-generated challenge?
The challenge prevents replay attacks, since every registration and every login signs a new, random, single-use string that the server then checks against the value it stored.
4What happens if a user loses all their devices?
Without a registered backup device, an alternative recovery path such as email verification or a support process is needed, which is why an application should always offer at least one recovery option alongside passkeys.
5Can I get rid of password login entirely?
Technically possible, but because of differing device and browser landscapes as well as corporate environments, a transition period usually makes sense, where passkey is the preferred method and password remains a parallel fallback.
6What is conditional UI in WebAuthn?
Conditional UI lets the browser show matching passkeys directly as an autofill suggestion inside the username field, without the user having to explicitly press a login button first.
7How do I detect cloned authenticators?
Every assertion carries a signature counter, and if it does not increase monotonically compared to the last stored value, that points to a cloned or tampered credential, and the login should be rejected.
8Does the rpID have to exactly match the domain?
Yes, the rpID must match the domain, or a parent registrable domain, under which the application is served, otherwise verification already fails inside the browser.
9Do passkeys also work in native apps or webviews?
That depends on the specific webview and operating system, some embedded webviews do not fully support WebAuthn, which is why a fallback to password or opening the system browser makes sense there.
10Where is the user's data stored with a passkey?
The private key never leaves the device, or rather the authenticator's secure storage, the server stores only the public key plus a credential id for each registered device.