Skip to main content
Ave uses WebAuthn (Web Authentication) to provide passwordless authentication through passkeys. Passkeys are cryptographic credentials stored securely on your devices, protected by biometrics or device PINs.

How Passkeys Work

1

Registration

During account creation, your device generates a public/private key pair.
  • Private key stays on your device (in Secure Enclave, TPM, or security key)
  • Public key is sent to the server for verification
  • Keys are bound to ave.id origin (prevents phishing)
2

Authentication

When you log in, the server sends a challenge.
  • Your device signs the challenge with the private key
  • Server verifies the signature using the stored public key
  • No secrets are transmitted over the network
3

User Verification

Your device requires biometric or PIN verification.
  • Touch ID, Face ID, Windows Hello, or Android fingerprint
  • Ensures you’re present and authorized
  • Hardware-backed security
Passkeys are phishing-resistant because they’re cryptographically bound to the domain. Even if you’re tricked into visiting a fake site, the passkey won’t work.

Registration Flow

Here’s how Ave creates a passkey during account registration:
// Client requests registration options
POST /api/register/start
{
  "handle": "alice"
}

// Server generates WebAuthn challenge
Response:
{
  "options": {
    "challenge": "randomBase64Challenge",
    "rp": { "name": "Ave", "id": "ave.id" },
    "user": { "id": "tempUserId", "name": "alice" },
    "authenticatorSelection": {
      "residentKey": "required",
      "userVerification": "required"
    }
  },
  "tempUserId": "uuid"
}
See ave-server/src/routes/register.ts:29 for implementation. The client then uses the browser’s WebAuthn API:
// Client creates passkey
const credential = await navigator.credentials.create({
  publicKey: options
});

// Send credential to server for verification
POST /api/register/complete
{
  "tempUserId": "uuid",
  "credential": credential,
  "identity": { ... },
  "device": { ... }
}
The server verifies the credential using @simplewebauthn/server:
// Verify the registration response
const verification = await verifyRegistrationResponse({
  response: credential,
  expectedChallenge: storedChallenge,
  expectedOrigin: "https://ave.id",
  expectedRPID: "ave.id"
});

if (verification.verified) {
  // Store public key and create user account
  const publicKey = registrationInfo.credential.publicKey;
  const credentialId = registrationInfo.credential.id;
  // ...
}
See ave-server/src/routes/register.ts:124 for verification logic.

Login Flow

Authentication works similarly:
// Client starts login
POST /api/login/start
{
  "handle": "alice"
}

// Server returns challenge
Response:
{
  "userId": "uuid",
  "authOptions": {
    "challenge": "randomBase64Challenge",
    "rpId": "ave.id",
    "allowCredentials": []  // Allows discoverable credentials
  },
  "authSessionId": "uuid"
}
Ave sets allowCredentials: [] to support discoverable credentials (passkeys stored in password managers like 1Password). This allows users to select from all available passkeys, not just those registered on the current device.
The client authenticates:
// Get passkey assertion
const credential = await navigator.credentials.get({
  publicKey: authOptions
});

// Send to server
POST /api/login/passkey
{
  "authSessionId": "uuid",
  "credential": credential,
  "device": {
    "name": "Chrome on macOS",
    "type": "computer",
    "fingerprint": "device-fingerprint-hash"
  }
}
Server verification:
// Find the passkey by credential ID
const passkey = await db.query.passkeys.findFirst({
  where: eq(passkeys.id, credential.id)
});

// Verify the authentication response
const verification = await verifyAuthenticationResponse({
  response: credential,
  expectedChallenge: storedChallenge,
  expectedOrigin: "https://ave.id",
  expectedRPID: "ave.id",
  credential: {
    id: passkey.id,
    publicKey: Buffer.from(passkey.publicKey, "base64"),
    counter: passkey.counter,
    transports: passkey.transports
  }
});

if (verification.verified) {
  // Update counter (prevents replay attacks)
  await db.update(passkeys)
    .set({ counter: verification.authenticationInfo.newCounter })
    .where(eq(passkeys.id, passkey.id));
  
  // Create session
  // ...
}
See ave-server/src/routes/login.ts:226 for verification.

Passkey Storage

Passkeys are stored in the database with this schema:
export const passkeys = sqliteTable("passkeys", {
  id: text("id").primaryKey(), // Credential ID from WebAuthn
  userId: text("user_id").notNull(),
  
  // WebAuthn credential data
  publicKey: text("public_key").notNull(), // Base64 encoded
  counter: integer("counter").notNull().default(0), // Prevents replay
  deviceType: text("device_type"), // "platform" or "cross-platform"
  backedUp: integer("backed_up", { mode: "boolean" }),
  transports: text("transports", { mode: "json" }),
  
  // User-friendly metadata
  name: text("name"), // "MacBook Pro Touch ID"
  lastUsedAt: integer("last_used_at", { mode: "timestamp_ms" }),
  
  // PRF extension support (see below)
  prfEncryptedMasterKey: text("prf_encrypted_master_key"),
  prfSalt: text("prf_salt")
});
See ave-server/src/db/schema.ts:39.
The counter field is critical for security. It increments with each authentication and prevents replay attacks. Always verify that newCounter > oldCounter.

PRF Extension (Advanced)

The PRF (Pseudo-Random Function) extension allows passkeys to derive deterministic secrets. Ave uses this to store an encrypted master key that only unlocks when you use the specific passkey.

How PRF Works

1

During Registration

When creating a passkey with PRF support:
const prfOutput = credential.getClientExtensionResults().prf?.results.first;

if (prfOutput) {
  // Derive encryption key from PRF output
  const prfKey = await deriveKeyFromPrf(prfOutput);
  
  // Encrypt master key with PRF key
  const encrypted = await encryptMasterKeyWithPrf(masterKey, prfOutput);
  
  // Store encrypted master key with passkey
  await updatePasskey({
    prfEncryptedMasterKey: encrypted
  });
}
2

During Login

The same PRF output is available during authentication:
const prfOutput = credential.getClientExtensionResults().prf?.results.first;

// Decrypt master key using PRF output
const masterKey = await decryptMasterKeyWithPrf(
  passkey.prfEncryptedMasterKey,
  prfOutput
);

// Store master key locally
await storeMasterKey(masterKey);
With PRF, users don’t need to enter trust codes after logging in on a new device. The passkey itself unlocks the master key.
See ave-web/src/lib/crypto.ts:326 for PRF implementation.

Authenticator Types

Ave supports different types of authenticators:

Platform Authenticators

Built into the device:
  • Touch ID / Face ID (macOS, iOS)
  • Windows Hello (Windows)
  • Android fingerprint / face unlock
Characteristics:
  • Fast and convenient
  • Cannot be moved to other devices
  • Backed by hardware security (Secure Enclave, TPM)

Cross-Platform Authenticators

Portable security keys:
  • YubiKey
  • Google Titan Key
  • Any FIDO2-compatible key
Characteristics:
  • Can be used across devices
  • Physical device required
  • Resistant to malware

Synced Passkeys

Passkeys synced via password managers:
  • iCloud Keychain (Apple devices)
  • Google Password Manager (Chrome, Android)
  • 1Password, Bitwarden, etc.
Characteristics:
  • Available across synced devices
  • Managed by password manager
  • Convenient for multi-device users

Adding Additional Passkeys

Users can register multiple passkeys for redundancy:
// Start passkey registration (authenticated endpoint)
POST /api/security/passkeys/register
Authorization: Bearer session-token

Response:
{
  "options": {
    "challenge": "...",
    "excludeCredentials": [/* existing passkey IDs */]
  }
}
The excludeCredentials list prevents re-registering the same passkey. See ave-server/src/routes/security.ts:56.

Passkey Management

Users can:
  • Rename passkeys: PATCH /api/security/passkeys/:id { "name": "New Name" }
  • Delete passkeys: DELETE /api/security/passkeys/:id (cannot delete last passkey)
  • View usage: lastUsedAt timestamp shows when passkey was last used
Users cannot delete their only passkey. This prevents account lockout. Always ensure at least one authentication method remains.

Security Considerations

Challenge Storage

Challenges are stored temporarily (5-15 minutes) in Redis or memory:
await setChallenge(
  "registration",
  tempUserId,
  { challenge: options.challenge },
  15 * 60 * 1000 // 15 minutes
);
See ave-server/src/lib/challenge-store.ts.

Origin Validation

Ave validates WebAuthn origins to prevent domain attacks:
// Extract origin from credential
const clientDataJSON = JSON.parse(
  Buffer.from(credential.response.clientDataJSON, "base64").toString()
);
const clientOrigin = clientDataJSON.origin;

// In development, allow any localhost port
const expectedOrigin = clientOrigin.match(/^http:\/\/localhost(:\d+)?$/)
  ? clientOrigin
  : process.env.RP_ORIGIN;

await verifyRegistrationResponse({
  expectedOrigin,
  expectedRPID: "ave.id"
});
See ave-server/src/routes/register.ts:118.

Replay Prevention

The counter field prevents replay attacks. Each authentication increments the counter:
if (verification.authenticationInfo.newCounter <= passkey.counter) {
  // Possible replay attack!
  throw new Error("Counter did not increment");
}

await db.update(passkeys)
  .set({ counter: verification.authenticationInfo.newCounter })
  .where(eq(passkeys.id, passkey.id));

Browser Compatibility

WebAuthn is supported in: ✅ Chrome/Edge 67+ (desktop and mobile) ✅ Safari 13+ (macOS, iOS) ✅ Firefox 60+ ✅ Opera 54+
For best experience, use a modern browser with platform authenticator support (Touch ID, Windows Hello, etc.).

Testing

To test passkey flows:
  1. Registration: Navigate to /register and create an account
  2. Login: Log out and sign back in with your passkey
  3. Multiple Passkeys: Add a second passkey in Security settings
  4. Recovery: Test trust code recovery when master key isn’t available

Next Steps

Encryption Model

Learn how master keys work with passkeys

Key Management

Master key recovery and trust codes

Build docs developers (and LLMs) love