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: stringThe site name shown in the operating system's passkey prompt.
Keep it short
It appears mid-sentence in a system dialog: "Do you want to save a passkey for Acme?" A long legal entity name reads badly there and gets truncated on narrow phone dialogs. Use "Acme", not "Acme Corporation Customer Portal".
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
This list is the second half of the anti-phishing guarantee. The browser
enforces the rpID binding; this check enforces that the ceremony ran on a page
you actually serve.
A matcher may also be a RegExp or a predicate, for wildcard-subdomain setups.
Anchor it if you use one. An unanchored pattern like
/https:\/\/.*acme\.com/ matches https://acme.com.evil.net, and that
single missing anchor hands an attacker your users' credentials.
Behind a proxy, use the public origin
The browser reports the origin it loaded the page from, which is your public
URL, not http://localhost:8080 where your app happens to listen.
storeoption
store: PasskeyStoreWhere 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?: stringThe 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
Credentials are permanently bound to the rpID they were created under.
Changing it later does not migrate anything: every existing passkey stops
working and every user has to enrol again.
If there is any chance you will want one passkey to work across subdomains, set
it to your apex domain now. Going from acme.com to app.acme.com later is
impossible without a mass re-enrolment; the reverse is equally impossible.
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'strictestThe response is rejected unless the User Verified flag is set. Makes a passkey two factors in one gesture.
'preferred'defaultAsks for verification, accepts a response without it.
'discouraged'loosestOnly presence. Faster, and appropriate for a second factor alongside a password.
Why 'preferred' rather than 'required'
'required' is the correct choice when a passkey is your only factor, and you
should use it there. It is not the default because it turns some older security
keys, and some authenticators with no PIN configured, into a dead end: the
ceremony fails and the user has no way to proceed.
'preferred' gets verification everywhere it is available, which in practice is
nearly everywhere, without locking anyone out. Move to 'required' deliberately,
once you know your users' authenticators can satisfy it.
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
'required' guarantees usernameless login but can fail outright on
authenticators with limited credential storage, which includes many hardware
security keys. 'preferred' produces a discoverable credential wherever
possible and falls back silently where not, so login() works for the vast
majority while nobody is turned away.
authenticatorAttachmentoption
authenticatorAttachment?: 'platform' | 'cross-platform'Restrict which kind of authenticator may be used.
'platform'built inTouch ID, Face ID, Windows Hello, Android screen lock. Bound to this device.
'cross-platform'roamingUSB security keys, and phones used over the hybrid transport.
Why the default is unset
Leaving it unset lets the browser offer everything the user has, including
scanning a QR code to use a phone. Setting 'platform' removes that option, so
a visitor on a shared or work computer has no way to sign in at all. Set it only
when you have a policy reason, not to simplify the prompt.
Timing
timeoutoption
timeout?: numberHow long the browser prompt stays open, in milliseconds. Passed to the authenticator as a hint.
Why it works this way
Sixty seconds is enough to notice a prompt and touch a sensor. It is not enough to find a phone in another room, so raise it toward 120000 if your flow expects a cross-device hybrid scan.
challengeTimeoutoption
challengeTimeout?: numberHow long a challenge remains valid server-side, in milliseconds.
Why it is longer than the browser timeout
The browser prompt and the server's patience are different clocks. A user might
dismiss the prompt, find their security key, and click your button again; a
tight server window would reject that second attempt with a confusing
challenge_not_found.
Five minutes covers realistic hesitation without leaving a useful replay surface, especially since the challenge is single-use regardless.
Cryptography
challengeSizeoption
challengeSize?: numberChallenge length in bytes. The specification's floor is 16, and passkify refuses anything smaller.
Why it works this way
Sixteen bytes is already beyond guessing. Thirty-two costs nothing, matches SHA-256's output width, and removes any need to think about it again.
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
ES256 covers essentially every shipping passkey provider. RS256 is there for older Windows Hello and TPM-backed keys. Both are mandatory-to-implement in the WebAuthn specification, so the pair is universal.
A shorter list means fewer verification code paths that have to be right. passkify can verify ES384, ES512, PS256/384/512, RS384, RS512, RS1 and EdDSA, and you can enable any of them here, but offering algorithms nobody uses adds risk without adding reach.
Attestation
attestationoption
attestation?: 'none' | 'indirect' | 'direct' | 'enterprise'How much the authenticator should say about itself.
Leave this alone
Requesting attestation shows an additional, more alarming consent prompt on some platforms, and consumer passkey providers return an anonymous statement anyway. You pay a conversion cost for information you do not get.
Change it only if you are an enterprise pinning specific authenticator models,
and then also set attestationRootCertificates. See
attestation.
attestationRootCertificatesoptionadvanced
attestationRootCertificates?: readonly (string | Uint8Array)[]PEM or DER root certificates to validate attestation chains against.
Why it works this way
Without roots, AttestationResult.trusted is always false, and honestly so:
a chain that validates against nothing proves nothing. passkify does not ship a
FIDO Metadata Service blob, because MDS is a live, signed, rotating document and
an embedded stale copy would be a guarantee the library cannot keep.
requireBackupEligibleoption
requireBackupEligible?: booleanReject credentials that cannot sync between devices.
Why off by default
Turning this on locks out every hardware security key, because they are device-bound by design. It makes sense only if your recovery story depends on credentials surviving a lost phone, and you would rather refuse enrolment than support a user with one device-bound passkey.
A softer approach: leave it off, record deviceType (passkify always does), and
prompt users whose only passkey is singleDevice to add a second one.
Hooks
hooksoption
hooks?: PasskeyHooksCallbacks 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.
This hook cannot set a cookie
It receives no request or response object. Session establishment belongs in the
HTTP adapter's onLogin, which does. Use this hook for
side effects that are not request-scoped.
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
Counter regression is a signal, not a verdict. It genuinely does indicate cloning; it also fires for buggy authenticator firmware and for credentials restored from a backup. Which of those you are looking at depends on your user base, and the right response is usually to log and alert rather than silently allow or silently deny.
A boolean could only express "reject" or "ignore". A hook lets you record the event, notify the user, and then decide.
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;
},
},
});