BetterIAM

Passkeys

Register passkeys, sign in with them (including discoverable autofill sign-in), use them as a second factor, and let people name and manage them.

Passwords and one-time codes can be typed into a convincing fake login page. cannot: they are WebAuthn credentials, a key pair kept on a device or synced by a platform account, and the browser only uses them on your own site. They also verify the person on the device (with biometrics or a PIN), so one passkey is both factors at once.

In Better IAM a passkey can sign a person in on its own, and satisfies MFA when it does, or it can answer the second step of a password or passwordless sign-in. Use passkeys when you want phishing-resistant sign-in, or as a faster second factor than an authenticator code.

Setup

Configure the relying party

WebAuthn ties every passkey to a relying party: the site it may be used on, identified by a domain (the RP ID). Set it under authentication.passkeys:

iam.ts
import { betterIam } from 'better-iam';
import { sqliteAdapter } from 'better-iam/adapter-sqlite';

export const iam = betterIam({
  database: sqliteAdapter({ filename: './iam.db' }),
  secret: process.env.BETTER_IAM_SECRET,
  baseURL: 'https://app.example.com',
  trustedOrigins: ['https://admin.example.com'],
  authentication: {
    passkeys: { rpID: 'example.com', rpName: 'Acme Cloud' },
  },
});
  • rpID must match every trusted origin: each origin's hostname must equal it or be a subdomain of it. Construction fails with INVALID_CONFIG otherwise.
  • rpName is the name authenticators show; it defaults to authentication.appName ("Better IAM" by default).
  • Without passkeys, every passkey call fails with FEATURE_DISABLED.
  • A request served on an organization's custom hostname outside the RP ID (such as login.acme.com for passkeys of example.com) also fails with FEATURE_DISABLED, because the browser would refuse the ceremony there.

Migrate

Passkeys need no migration of their own: they are stored in the document store that npx better-iam migrate created (see Schema). Run it once if you have not:

npx better-iam migrate --config better-iam.config.mjs

Allow the method

A tenant whose authentication policy sets allowedMethods must include passkey, or passkey sign-in is refused with METHOD_NOT_ALLOWED. Tenants without allowedMethods accept every enabled method.

Add the browser helpers

The WebAuthn helpers live in better-iam/client/passkeys, a separate entry point so the core client does not bundle them:

lib/iam-client.ts
import { createIamClient } from 'better-iam/client';
import type { iam } from './iam';

export const client = createIamClient<typeof iam>();
HelperWhat it does
startRegistrationAsks the authenticator to create a passkey from server-issued registration options.
startAuthenticationAsks the authenticator to sign server-issued options with an existing passkey; useBrowserAutofill: true offers passkeys in the username field.
browserSupportsWebAuthnWhether the browser supports passkeys at all, so you can hide the button when it does not.
browserSupportsWebAuthnAutofillWhether the browser can offer passkeys through autofill.
platformAuthenticatorIsAvailableWhether the device has a built-in authenticator (such as a fingerprint reader).
WebAuthnAbortServiceCancels a waiting ceremony, for example an autofill request you want to restart.

Usage

Register a passkey

People add passkeys from their account page, while signed in with a recently authenticated session. Recent authentication stops someone who finds an unlocked laptop from adding their own passkey to the account.

Get registration options

auth.beginPasskeyRegistration creates a one-time challenge and the WebAuthn options that describe the passkey to create.

import { client } from './lib/iam-client';

const { challengeId, options } = await client.auth.beginPasskeyRegistration();

Route: POST /api/iam/auth/beginPasskeyRegistration

The options require a discoverable credential (resident key) and user verification, request no attestation, and exclude the passkeys the person already has. The challenge is bound to this session and valid for five minutes.

Run the browser ceremony

The browser shows its passkey dialog, the person confirms with their fingerprint, face, or PIN, and the authenticator creates the key pair.

import { startRegistration } from 'better-iam/client/passkeys';

const response = await startRegistration({ optionsJSON: options });

Finish and name it

auth.finishPasskeyRegistration sends the authenticator's response back, with an optional name the person will recognize later in their list.

const { id, name } = await client.auth.finishPasskeyRegistration({
  challengeId,
  response,
  name: 'Work laptop', // optional, at most 64 characters
});

Route: POST /api/iam/auth/finishPasskeyRegistration

The server verifies the challenge, origin, RP ID, and user verification, then stores the public key. Without a name, the passkey is labeled from what the authenticator reports about itself: "This device" for a built-in authenticator, "Phone" for a cross-device (hybrid) one, "Security key" for USB, NFC, or Bluetooth keys, and "Passkey" otherwise. Registration is audited as auth:passkey:create with the name.

A credential is unique for the relying party across the whole installation, including across tenant accounts: registering the same credential twice fails with PASSKEY_EXISTS.

Sign in with a passkey

Signing in with a passkey takes two calls around the browser ceremony. auth.beginPasskeyAuthentication issues a challenge and the options for the browser, and auth.finishPasskeyAuthentication verifies the signed response and issues a session that has already passed MFA (method: 'passkey'). Through the HTTP handler the session is set as the session cookie (__Host-better-iam.session on HTTPS, better-iam.session on loopback HTTP).

Without an email, the options name no credential. The authenticator offers whatever discoverable passkey it holds for your relying party, through the browser's passkey picker or autofill, and the server finds the account from the credential itself.

// <input name="email" autoComplete="username webauthn" /> on the login form enables autofill.
import { browserSupportsWebAuthnAutofill, startAuthentication } from 'better-iam/client/passkeys';
import { client } from './lib/iam-client';

if (await browserSupportsWebAuthnAutofill()) {
  const { challengeId, options } = await client.auth.beginPasskeyAuthentication({ tenantId });
  const response = await startAuthentication({ optionsJSON: options, useBrowserAutofill: true });
  await client.auth.finishPasskeyAuthentication({ tenantId, challengeId, response });
}

Routes: POST /api/iam/auth/beginPasskeyAuthentication, POST /api/iam/auth/finishPasskeyAuthentication

  • The sign-in challenge is valid for five minutes. An autofill request can wait longer than that, so start a fresh one before it expires (the console refreshes after four minutes).
  • Discovery names nobody, so it is rate limited per client address, with ten times the ordinary allowance, because login pages start one on every visit.
  • A discovered passkey that is not registered in this organization, a user handle that does not match the account, or a failed verification is refused with INVALID_PASSKEY.
  • The server checks the challenge, origin, RP ID, user verification, the signature, and the signature counter, and records the passkey's lastUsedAt.

Use a passkey as a second factor

People who still sign in with a password can use their passkey as the second step, which is faster than typing a code and cannot be phished. When a password or passwordless sign-in returns mfaRequired with passkeyAvailable: true (the person has at least one passkey), auth.beginPasskeyMfa issues WebAuthn options bound to that sign-in challenge, and auth.finishPasskeyMfa verifies the response and issues the session:

import { startAuthentication } from 'better-iam/client/passkeys';
import { client } from './lib/iam-client';

const { challengeId, options } = await client.auth.beginPasskeyMfa({ tenantId, challenge: result.challenge });
const response = await startAuthentication({ optionsJSON: options });
const session = await client.auth.finishPasskeyMfa({
  tenantId,
  challengeId,
  response,
  rememberDevice: true, // optional; see "remember this device"
});

Routes: POST /api/iam/auth/beginPasskeyMfa, POST /api/iam/auth/finishPasskeyMfa

The assertion is verified like a passkey sign-in (challenge, origin, RP ID, user verification, signature, counter) and bound to the pending sign-in challenge, which must still be open and belong to the same person (INVALID_CHALLENGE otherwise). Both challenges are consumed together. The session keeps the method of the first factor, such as password. Without a registered passkey, beginPasskeyMfa fails with FEATURE_DISABLED. With rememberDevice: true, the response can carry a deviceToken; see remember this device.

List, rename, and delete passkeys

People collect passkeys on several devices over time, and need to recognize and remove old ones, such as the passkey on a phone they sold. Three calls back an account page:

  • auth.listPasskeys returns the caller's passkeys, newest first, without key material (fields below).
  • auth.renamePasskey gives a passkey a label the person recognizes, such as "YubiKey 5C". Audited as auth:passkey:rename.
  • auth.deletePasskey removes one. It needs recent authentication, ends every session of the person, and is audited as auth:passkey:delete.
import { client } from './lib/iam-client';

const passkeys = await client.auth.listPasskeys();
await client.auth.renamePasskey({ id: passkeys[0].id, name: 'YubiKey 5C' });
await client.auth.deletePasskey({ id: passkeys[1].id });

Routes: POST /api/iam/auth/listPasskeys, POST /api/iam/auth/renamePasskey, POST /api/iam/auth/deletePasskey

Each listed passkey carries:

FieldMeaning
id, nameThe passkey's ID and label (at most 64 characters).
credentialIdThe WebAuthn credential ID.
createdAt, lastUsedAtWhen it was registered, and last used to sign in or answer MFA.
deviceTypesingleDevice (bound to one authenticator) or multiDevice (synced).
backedUpWhether the credential is backed up, as reported at registration.
transportsHow the browser can reach the authenticator (internal, hybrid, usb, nfc, ble).
aaguidThe authenticator model identifier, when the authenticator reports one.

Removing the last passkey is refused with LAST_AUTHENTICATOR unless the person has another way to sign in: a password, or a verified email or phone number with passwordless sign-in enabled. That keeps people from locking themselves out. A passkey ID that is not the caller's fails with NOT_FOUND.

auth.mfaStatus() reports how many passkeys a person has, and tenants.usage counts people with an authenticator or a passkey as MFA-enrolled.

Require passkeys

To make an organization phishing-resistant, set its policy's allowedMethods to ['passkey']. Password, passwordless, and federated sign-in are then refused with METHOD_NOT_ALLOWED before any credential is examined. Policies can also demand a passkey session for one action only, with a condition on principal.authMethod:

A statement that requires a passkey session
{
  effect: 'allow',
  actions: ['billing:refund'],
  resources: ['invoice/*'],
  conditions: { StringEquals: { 'principal.authMethod': 'passkey' } },
}

Options

OptionTypeDefaultDescription
authentication.passkeys.rpIDstringnone (passkeys off)The relying-party domain. Every baseURL and trustedOrigins hostname must equal it or be a subdomain.
authentication.passkeys.rpNamestringauthentication.appNameThe name authenticators show.
authentication.appNamestring'Better IAM'Product name; the default rpName.
baseURL, trustedOriginsstring, string[]required, []The origins allowed to run passkey ceremonies.
authentication.recentAuthenticationMsnumber5 minutesHow recent a session must be to register or delete a passkey.
authentication.rateLimits.attemptsnumber10Attempts per window for passkey sign-in with an email; discovery gets ten times this per client address.
Tenant allowedMethodsAuthMethod[]every enabled methodMust include passkey for passkey sign-in in that tenant.

Schema

Passkeys and their ceremonies are stored in two collections of the document store:

CollectionKey fieldsNotes
authPasskeysid, tenantId, identityId, credentialId, publicKey, counter, transports, name, createdAt, lastUsedAt, deviceType, backedUp, aaguidOne record per registered passkey. publicKey and counter are never returned by the API.
authChallengesid, tenantId, identityId, purpose, tokenHash, expiresAt, payload, sessionIdSingle-use registration and sign-in challenges, valid for five minutes, stored hashed.

See Database schema for every collection.

Errors

CodeWhen
FEATURE_DISABLEDauthentication.passkeys is not set, the request's hostname is outside the RP ID, or beginPasskeyMfa is called for a person without a passkey.
INVALID_CONFIGrpID does not match baseURL or a trusted origin (at construction).
PASSKEY_EXISTSThe credential is already registered.
INVALID_PASSKEYRegistration or authentication verification failed, or the passkey does not belong to this account or organization.
INVALID_CHALLENGEThe passkey challenge, or the pending sign-in challenge it answers, is invalid or expired; also a finish after decoy options for an unknown email.
METHOD_NOT_ALLOWEDThe tenant's allowedMethods excludes passkey.
RECENT_AUTH_REQUIREDRegistering or deleting without a recently authenticated session.
LAST_AUTHENTICATORDeleting the only passkey of a person with no other way to sign in.
RATE_LIMITEDToo many attempts in the window.
NOT_FOUNDRenaming or deleting a passkey that is not the caller's.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page