Stich
Captain's Log
Stardate 2026.117

Passkeys, Not Passwords: WebAuthn with Stalwart

Passwords are a design failure

Let's be honest: passwords are a broken authentication mechanism. Users reuse them, they get phished, they leak in breaches, and every "password policy" just makes them harder to remember without making them more secure.

WebAuthn fixes this. Instead of a shared secret (password), you have a public key credential bound to a device. The server never sees a secret — it only verifies cryptographic signatures. The user authenticates with biometrics (fingerprint, face) or a device PIN. It's phishing-resistant by design.

How Stich does passkeys

Stich uses WebAuthn with platform authenticators and resident keys. Here's what that means:

  • Platform authenticator — the credential lives in the device's secure enclave (Touch ID, Windows Hello, Android fingerprint)
  • Resident key — the credential is discoverable without providing a username first, enabling true passwordless login
  • User verification required — biometric or PIN confirmation every time

Registration flow

User enters email + server URL
        ↓
POST /auth/passkey/register-begin  →  { challenge, userId, excludeCredentials }
        ↓
navigator.credentials.create({ publicKey })  →  user authenticates
        ↓
POST /auth/passkey/register-finish  →  { credentialId, attestationObject, clientDataJSON }
        ↓
Server validates attestation, stores public key
        ↓
Returns { accessToken }  →  user is logged in

Authentication flow

User clicks "Sign in with Passkey"
        ↓
POST /auth/passkey/auth-begin  →  { challenge, allowCredentials }
        ↓
navigator.credentials.get({ publicKey })  →  user authenticates
        ↓
POST /auth/passkey/auth-finish  →  { credentialId, authenticatorData, clientDataJSON, signature }
        ↓
Server verifies signature against stored public key
        ↓
Returns { accessToken, email }  →  user is logged in

Stalwart integration

Stalwart's OIDC provider handles the token lifecycle. The passkey endpoints are a custom extension that bridges WebAuthn with Stalwart's existing OAuth2 token flow. The access token returned by the passkey endpoints is a standard OAuth2 bearer token — it works with all JMAP endpoints.

This means you can combine passkeys with traditional OAuth2 in the same application. If a device doesn't support WebAuthn, the user can still authenticate via OAuth2/OIDC redirect.

The code

The client-side implementation lives in @stich/api's AuthClient class. The key methods:

  • AuthClient.registerPasskey(email, serverUrl) — full registration flow
  • AuthClient.authenticateWithPasskey(serverUrl, email?) — full authentication flow
  • AuthClient.isPasskeySupported() — check if the browser supports WebAuthn
  • AuthClient.isPasskeyAvailable() — check if a platform authenticator exists

The base64url encoding/decoding utilities handle the conversion between WebAuthn's ArrayBuffer format and the JSON format used in API requests.

Why this matters

Passwordless authentication isn't a nice-to-have anymore — it's the baseline. Every major platform supports WebAuthn. Every modern browser implements it. The only thing missing was the server-side integration, and Stalwart makes that straightforward with its extensible authentication framework.

Stich doesn't have a password field anywhere. Not in the login form, not in the settings, not in the database. And it never will.