Configuration

Every option on PasskeyServerConfig, what it changes, and when to move it.

const passkeys = new PasskeyServer({
  rpName: 'Acme',
  origin: 'https://acme.com',
  store: myStore,
});

Three required options. The rest have defaults chosen so that the common case needs no configuration at all.

Required

rpNameoption

rpName: string

The site name shown in the operating system's passkey prompt.

Keep it short

originoption

origin: string | readonly OriginMatcher[]

The origin or origins your login page is served from, scheme and port included.

origin: 'https://acme.com'
origin: ['https://acme.com', 'https://www.acme.com']

Every response carries the origin the browser saw, and passkify rejects any that is not in this list.

Why exact strings, and why a pattern is dangerous

storeoption

store: PasskeyStore

Where users, credentials and challenges live. See storage.

Use new MemoryStore() while you are trying things out; it is a complete, readable implementation you can use as a template for a real one.

Identity

rpIDoption

rpID?: string

The domain your passkeys are bound to. Must be the origin's host or a parent domain of it.

// Serving app.acme.com and www.acme.com from one account system:
{ origin: ['https://app.acme.com', 'https://www.acme.com'], rpID: 'acme.com' }

Decide this before your first user registers

Ceremony policy

userVerificationoption

userVerification?: 'required' | 'preferred' | 'discouraged'

Whether the authenticator must verify who the user is (PIN, fingerprint, face) rather than only that someone is present.

'required'strictest
'preferred'default
'discouraged'loosest

Why 'preferred' rather than 'required'

residentKeyoption

residentKey?: 'required' | 'preferred' | 'discouraged'

Whether the credential is discoverable, meaning it can be used to sign in without typing a username first.

Why it works this way

authenticatorAttachmentoption

authenticatorAttachment?: 'platform' | 'cross-platform'

Restrict which kind of authenticator may be used.

'platform'built in
'cross-platform'roaming

Why the default is unset

Timing

timeoutoption

timeout?: number

How long the browser prompt stays open, in milliseconds. Passed to the authenticator as a hint.

Why it works this way

challengeTimeoutoption

challengeTimeout?: number

How long a challenge remains valid server-side, in milliseconds.

Why it is longer than the browser timeout

Cryptography

challengeSizeoption

challengeSize?: number

Challenge length in bytes. The specification's floor is 16, and passkify refuses anything smaller.

Why it works this way

supportedAlgorithmsoption

supportedAlgorithms?: readonly number[]

COSE algorithm identifiers offered to authenticators, best first. The defaults are ES256 and RS256.

import { COSEAlgorithm } from 'passkify';
 
supportedAlgorithms: [COSEAlgorithm.ES256, COSEAlgorithm.RS256, COSEAlgorithm.EdDSA]

Why the list is deliberately short

Attestation

attestationoption

attestation?: 'none' | 'indirect' | 'direct' | 'enterprise'

How much the authenticator should say about itself.

Leave this alone

attestationRootCertificatesoptionadvanced

attestationRootCertificates?: readonly (string | Uint8Array)[]

PEM or DER root certificates to validate attestation chains against.

Why it works this way

requireBackupEligibleoption

requireBackupEligible?: boolean

Reject credentials that cannot sync between devices.

Why off by default

Hooks

hooksoption

hooks?: PasskeyHooks

Callbacks fired after a successful ceremony. Throwing from a hook fails the ceremony.

hooks.onRegisteredhook

(event: { user, credential, isNewUser }) => void | Promise<void>

A credential was verified and stored. Good place for a welcome email or an audit record.

hooks.onAuthenticatedhook

(event: { user, credential }) => void | Promise<void>

A login succeeded.

hooks.onCounterRegressionhook

(event: { user, credential, storedCounter, presentedCounter }) => boolean | Promise<boolean>

A credential's signature counter went backwards, which can mean the authenticator has been cloned. Return true to allow the login anyway.

onCounterRegression: ({ user, storedCounter, presentedCounter }) => {
  logger.warn('possible cloned authenticator', {
    userId: user.id, storedCounter, presentedCounter,
  });
  return false;   // reject, the default
},

Why this is a hook rather than a boolean setting

Full example

import { PasskeyServer, COSEAlgorithm } from 'passkify';
import { store } from './store.js';
 
export const passkeys = new PasskeyServer({
  rpName: 'Acme',
  origin: ['https://acme.com', 'https://www.acme.com'],
  rpID: 'acme.com',
  store,
 
  userVerification: 'required',
  residentKey: 'preferred',
  timeout: 120_000,
  challengeTimeout: 300_000,
  supportedAlgorithms: [COSEAlgorithm.ES256, COSEAlgorithm.RS256],
 
  hooks: {
    onRegistered: ({ user, isNewUser }) => audit('passkey.registered', user.id, { isNewUser }),
    onAuthenticated: ({ user }) => audit('passkey.login', user.id),
    onCounterRegression: ({ user }) => {
      audit('passkey.counter_regression', user.id);
      return false;
    },
  },
});