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.
Table of Contents
- 1. What WebAuthn actually is
- 2. Comparison to classic password plus two-factor authentication
- 3. Server-side challenge generation through Nitro
- 4. Registration on the client with navigator.credentials
- 5. Login and verifying the assertion
- 6. Fallback strategy for browsers without WebAuthn support
- 7. Usernameless login and conditional UI
- 8. Account recovery and multi-device handling
- 9. Security aspects and an operational checklist
- 10. Summary
- 11. FAQ
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.