kavachOS
Authentication

Passkey

WebAuthn/FIDO2 biometric and hardware key authentication.

Passkeys use the WebAuthn standard (FIDO2) to authenticate users with device biometrics (Touch ID, Face ID, Windows Hello) or hardware security keys. No password is ever created or stored.

Setup

Install

pnpm add kavachos

Add the plugin

lib/kavach.ts
import { createKavach } from 'kavachos';
import { passkey } from 'kavachos/plugins/passkey'; 

const kavach = await createKavach({
  database: { provider: 'postgres', url: process.env.DATABASE_URL! },
  secret: process.env.KAVACH_SECRET!,
  baseUrl: 'https://auth.example.com',
  plugins: [
    passkey({ 
      rpName: 'My App',           // Shown in the browser prompt
      rpId: 'example.com',        // Must match your domain, no protocol
    }), 
  ],
});

rpId must be a registrable domain suffix of the origin. For https://app.example.com, valid values are app.example.com or example.com. Localhost works during development.

Registration ceremony

A passkey is tied to a specific device. Users register once per device they want to use.

Get registration options

POST /auth/passkey/register/options

Returns a WebAuthn challenge from the server. Requires an active session (the user must already be signed in to register a passkey).

const res = await fetch('/auth/passkey/register/options', {
  method: 'POST',
  credentials: 'include',
});

const options = await res.json();

Create the credential

Pass the options to the browser's WebAuthn API:

import { startRegistration } from '@simplewebauthn/browser';

const credential = await startRegistration(options);

Verify and store

POST /auth/passkey/register/verify

await fetch('/auth/passkey/register/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(credential),
});

On success, the credential is stored and the passkey is active.

Authentication ceremony

Get authentication options

POST /auth/passkey/authenticate/options

Does not require a session — this is the start of sign-in.

const res = await fetch('/auth/passkey/authenticate/options', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'user@example.com' }), // optional
});

const options = await res.json();

Omitting email returns options for any registered passkey on the device (useful for conditional UI).

Get the assertion

import { startAuthentication } from '@simplewebauthn/browser';

const assertion = await startAuthentication(options);

Verify and start session

POST /auth/passkey/authenticate/verify

const res = await fetch('/auth/passkey/authenticate/verify', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify(assertion), 
});

const { userId, sessionId } = await res.json(); 

On success, a session cookie is set.

Managing credentials

Users can register multiple passkeys across different devices.

List credentials

GET /auth/passkey/credentials

const res = await fetch('/auth/passkey/credentials', {
  credentials: 'include',
});

const { credentials } = await res.json();
// [{ id, name, createdAt, lastUsedAt, deviceType }]

Delete a credential

DELETE /auth/passkey/credentials/:id

await fetch(`/auth/passkey/credentials/${credentialId}`, {
  method: 'DELETE',
  credentials: 'include',
});

If a user deletes their last passkey and has no other sign-in method, they will be locked out. Check the credential count before allowing deletion, or prompt the user to set a password first.

Endpoints

EndpointAuth requiredDescription
POST /auth/passkey/register/optionsYesGet challenge to start registration
POST /auth/passkey/register/verifyYesStore the new credential
POST /auth/passkey/authenticate/optionsNoGet challenge to start sign-in
POST /auth/passkey/authenticate/verifyNoVerify assertion, set session
GET /auth/passkey/credentialsYesList registered credentials
DELETE /auth/passkey/credentials/:idYesRemove a credential

Options

OptionTypeDefaultDescription
rpNamestringrequiredApp name shown in the browser passkey prompt
rpIdstringrequiredRelying party ID, must match your domain
timeoutnumber60000WebAuthn operation timeout in milliseconds
attestationstring'none'Attestation preference: 'none', 'indirect', 'direct'
userVerificationstring'preferred'Whether biometric check is required: 'required', 'preferred', 'discouraged'

On this page