@1auth/authn-webauthn

WebAuthn/FIDO2 credential support for passwordless authentication.

Install

npm i @1auth/authn-webauthn

Usage

import webauthn from '@1auth/authn-webauthn'

webauthn({
  origin: process.env.ORIGIN,
  name: 'Organization Name',
  userVerification: 'preferred'
})

Configuration options

OptionTypeDefaultDescription
originstringrequiredApplication origin URL
namestringrequiredRelying party name
residentKeystring'discouraged'Resident key requirement
userVerificationstring'preferred'User verification level
preferredAuthenticatorTypestring—'securityKey', 'localDevice', or 'remoteDevice'
userNamefunction(account) => account.value ?? 'username'Display name shown in the authenticator prompt, resolved from the account row
notifyIdstring'authn-webauthn'Prefix for the notify template ids: {notifyId}-create, {notifyId}-expire, {notifyId}-remove

verify and authenticate require the user verified (UV) flag only when userVerification is 'required', so what the browser is asked for and what the server insists on always agree. With the default 'preferred', an authenticator that cannot verify the user still gets in. Set 'required' when the credential stands in for a password; the PassKey and SecurityKey presets do.

Authenticator types

preferredAuthenticatorType only sends hints and authenticatorAttachment to the browser. Nothing stops the user registering something else, so when it is set, verify also checks what actually turned up and rejects a mismatch with 401 Unauthorized:

TypeAcceptsSignal
'securityKey'Hardware bound credentialcredentialDeviceType: 'singleDevice'
'localDevice'Syncable passkey on this devicecredentialDeviceType: 'multiDevice', authenticatorAttachment: 'platform'
'remoteDevice'Syncable passkey over hybridcredentialDeviceType: 'multiDevice', authenticatorAttachment: 'cross-platform'

authenticatorAttachment is optional in the spec, so local versus remote is only checked when the browser reports it. The security relevant split — hardware key versus synced passkey — is the backup eligibility flag, which is always present.

Pass it per registration to offer more than one choice off a single instance:

await webauthn.create(sub, { preferredAuthenticatorType: 'remoteDevice' })

Running PassKey and SecurityKey side by side

The default export configures one shared instance, so calling it twice overwrites the first config. To run two ceremonies with different policies, use @1auth/authn-webauthn-passkey and @1auth/authn-webauthn-securitykey, each holding its own createInstance() and credential id, or call createInstance() yourself:

import { createInstance } from '@1auth/authn-webauthn'

const passkey = createInstance()
passkey.configure({
  origin: process.env.ORIGIN,
  name: 'Organization Name',
  notifyId: 'authn-webauthn-passkey',
  residentKey: 'required',
  userVerification: 'required',
  preferredAuthenticatorType: 'localDevice',
  secret: passkey.secret({ id: 'WebAuthnPassKey' }),
  token: passkey.token({ id: 'WebAuthnPassKey', expire: 5 * 60 }),
  challenge: passkey.challenge({ id: 'WebAuthnPassKey', expire: 2 * 60 })
})

await passkey.count(sub) // its credentials only

Credentials are stored as {id}-{type}, so a distinct id keeps one instance’s credentials out of another’s list/allowCredentials. Instances share the @1auth/authn, @1auth/crypto, and @1auth/account singletons — same store, table, and encryption keys.

The two ceremonies

WebAuthn is two exchanges, and both are three-party: your server never talks to the authenticator, the browser brokers it. The credential’s private key never leaves the device.

Registration — create then verify

Authenticator          Browser              Your app          authn-webauthn
     │                    │                    │                    │
     │                    │  POST /register    │                    │
     │                    ├───────────────────►│                    │
     │                    │                    │  create(sub)       │
     │                    │                    ├───────────────────►│
     │                    │                    │      excludes any  │
     │                    │                    │      credential    │
     │                    │                    │      already on    │
     │                    │                    │      the account   │
     │                    │  { id, secret }    │◄───────────────────┤
     │                    │◄───────────────────┤   token row stored │
     │                    │                    │   otp, 10min       │
     │ navigator.credentials.create(secret)    │                    │
     │◄───────────────────┤                    │                    │
     │                    │                    │                    │
  ┌──┴───────────────┐    │                    │                    │
  │ user gesture,    │    │                    │                    │
  │ key pair made,   │    │                    │                    │
  │ private half     │    │                    │                    │
  │ never leaves     │    │                    │                    │
  └──┬───────────────┘    │                    │                    │
     │  attestation       │                    │                    │
     ├───────────────────►│                    │                    │
     │                    │  POST /register    │                    │
     │                    │  { response }      │                    │
     │                    ├───────────────────►│                    │
     │                    │                    │  verify(sub, resp) │
     │                    │                    ├───────────────────►│
     │                    │                    │      token is otp: │
     │                    │                    │      consumed, and │
     │                    │                    │      the credential│
     │                    │                    │      is stored     │
     │                    │  201               │◄───────────────────┤
     │                    │◄───────────────────┤                    │

Authentication — createChallenge then authenticate

Authenticator          Browser              Your app          authn-webauthn
     │                    │                    │                    │
     │                    │  POST /login       │                    │
     │                    ├───────────────────►│                    │
     │                    │                    │ createChallenge()  │
     │                    │                    ├───────────────────►│
     │                    │                    │   previous         │
     │                    │                    │   challenges are   │
     │                    │                    │   removed first;   │
     │                    │                    │   one row per      │
     │                    │                    │   credential, each │
     │                    │                    │   otp and 10min    │
     │                    │  { id, secret }    │◄───────────────────┤
     │                    │◄───────────────────┤                    │
     │ navigator.credentials.get(secret)       │                    │
     │◄───────────────────┤                    │                    │
     │  assertion         │                    │                    │
     ├───────────────────►│                    │                    │
     │                    │  POST /login       │                    │
     │                    │  { response }      │                    │
     │                    ├───────────────────►│                    │
     │                    │                    │ authenticate(user, │
     │                    │                    │              resp) │
     │                    │                    ├───────────────────►│
     │                    │                    │   checked against  │
     │                    │                    │   the credential as│
     │                    │                    │   it is now: gone, │
     │                    │                    │   expired or behind│
     │                    │                    │   its counter is a │
     │                    │                    │   401              │
     │                    │                    │   challenge is otp:│
     │                    │                    │   consumed. cleanup│
     │                    │                    │   writes the new   │
     │                    │                    │   signature counter│
     │                    │                    │   back to the      │
     │                    │                    │   credential       │
     │                    │  sub               │◄───────────────────┤
     │                    │◄───────────────────┤                    │

Both ceremonies lean on the same property: the challenge and the registration token are stored as one-time credentials, so a replayed response finds nothing to match against.

API

createInstance()

Create an independent instance with its own options. Returns {configure, ...} carrying every export below. The module’s default export is configure on a built in instance.

token(options)

Token configuration for registration.

secret(options)

Secret storage configuration for credentials.

challenge(options)

Authentication challenge configuration.

count(sub)

Count WebAuthn credentials for a subject.

list(sub)

List all WebAuthn credentials.

select(sub, id)

Get a specific WebAuthn credential.

authenticate(username, input)

Authenticate a user with a WebAuthn assertion.

create(sub, {preferredAuthenticatorType})

Start the WebAuthn registration process. Returns registration options. preferredAuthenticatorType defaults to the configured value and is enforced on verify.

verify(sub, response, {name}, notify)

Verify a WebAuthn registration response and store the credential. A credential id can only be registered once, on any account (WebAuthn §7.1 step 22): a second registration throws 409 Conflict. An expired or removed registration can be replaced.

createChallenge(sub, {username})

Generate an authentication challenge for an existing credential. Returns { id, secret }, where secret goes to the browser and id is an array with one stored challenge id per credential. An account with no credentials, and with username an unknown account, gets a decoy instead: the same shape, nothing stored, and no id. Use count(sub) to decide whether to offer WebAuthn at all.

expire(sub, id)

Expire a WebAuthn credential, and remove its outstanding challenges.

remove(sub, id)

Delete a WebAuthn credential, and remove its outstanding challenges.

Post-quantum

Assertions are signed by the authenticator with ES256 or RS256, which a quantum computer breaks via Shor’s algorithm — but the algorithms are fixed by authenticator hardware, not by 1auth. COSE identifiers for ML-DSA (−48/−49/−50) already exist; support lands when authenticators and @simplewebauthn/server ship it. Signatures are not retroactively forgeable, so recorded assertions carry no harvest-now-decrypt-later risk. See the full assessment in Post-quantum.