Passkeys (WebAuthn)
FIDO2 WebAuthn implementation for passwordless authentication.
Overview
Passkeys allow users to authenticate using:
- Platform authenticators: Touch ID, Face ID, Windows Hello
- Roaming authenticators: Security keys (YubiKey, etc.)
The implementation uses @simplewebauthn/server for WebAuthn operations.
Architecture
Database Schema
PasskeyCredential
Stores registered WebAuthn credentials.
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key |
userId | UUID | Owner user ID |
credentialId | Bytes | WebAuthn credential ID (unique) |
credentialPublicKey | Bytes | COSE public key |
counter | BigInt | Signature counter (replay protection) |
credentialDeviceType | String | singleDevice or multiDevice |
credentialBackedUp | Boolean | Synced to cloud (iCloud Keychain, etc.) |
transports | String[] | internal, usb, ble, nfc, hybrid |
deviceName | String? | User-provided label |
aaguid | String? | Authenticator AAGUID |
revokedAt | DateTime? | Revocation timestamp |
PasskeyChallenge
Temporary challenge storage for registration/authentication.
| Column | Type | Description |
|---|---|---|
id | UUID | Primary key (challengeId) |
type | Enum | REGISTRATION or AUTHENTICATION |
challenge | String | Base64URL challenge value |
userId | UUID? | Associated user (null for discoverable) |
expiresAt | DateTime | Challenge expiry (default 5 min) |
usedAt | DateTime? | When consumed |
API Endpoints
Registration Flow (Authenticated)
POST /auth/passkeys/register/options
Authorization: Bearer <token>
→ { challengeId, options }
POST /auth/passkeys/register/verify
Authorization: Bearer <token>
{ challengeId, response, deviceName? }
→ { id, deviceName, credentialDeviceType, ... }
Authentication Flow (Public)
POST /auth/passkeys/authenticate/options
{ email? }
→ { challengeId, options }
POST /auth/passkeys/authenticate/verify
X-Client-Audience: teetime-admin
X-Token-Delivery: cookie (optional)
{ challengeId, response }
→ { accessToken, refreshToken, jti } | { ok: true }
Management (Authenticated)
GET /auth/passkeys
→ { items: [...] }
PATCH /auth/passkeys/:id
{ deviceName }
→ { ... }
DELETE /auth/passkeys/:id
{ reason? }
→ 204 No Content
Configuration
| Variable | Default | Description |
|---|---|---|
WEBAUTHN_RP_ID | localhost | Relying party ID (domain) |
WEBAUTHN_RP_NAME | DigiWedge | Display name |
WEBAUTHN_ORIGIN | http://localhost:3000,http://localhost:4200 | Allowed origins (CSV) |
IDP_PASSKEYS_ENABLED | 1 | Enable passkey endpoints (0 disables) |
WEBAUTHN_ATTESTATION_TYPE | none | none, direct, indirect |
WEBAUTHN_USER_VERIFICATION | preferred | required, preferred, discouraged |
WEBAUTHN_CHALLENGE_TIMEOUT_MS | 300000 | Challenge validity (5 min) |
WEBAUTHN_RESIDENT_KEY | preferred | required, preferred, discouraged |
Feature flags
IDP_PASSKEYS_ENABLED=0disables all passkey endpoints (registration, authentication, management).VITE_PASSKEYS_ENABLED=0hides passkey UI in Access Control Admin.
Production Configuration
WEBAUTHN_RP_ID=digiwedge.com
WEBAUTHN_RP_NAME=DigiWedge
WEBAUTHN_ORIGIN=https://admin.digiwedge.com,https://access-control-admin.digiwedge.com
WEBAUTHN_ATTESTATION_TYPE=none
WEBAUTHN_USER_VERIFICATION=preferred
Attestation Policy
See ADR-0001: Passkey Attestation Policy.
Default: none — No attestation verification. Maximizes device compatibility and user privacy.
Attestation objects and AAGUIDs are stored for audit purposes, enabling future policy upgrades.
Audit Events
| Event | Description |
|---|---|
PASSKEY_REGISTERED | New passkey registered |
PASSKEY_AUTHENTICATION_SUCCESS | Successful passkey login |
PASSKEY_AUTHENTICATION_FAILURE | Failed passkey login attempt |
PASSKEY_UPDATED | Passkey renamed |
PASSKEY_REVOKED | Passkey revoked |
Metrics
Prometheus counters are emitted via the default registry:
| Metric | Labels | Description |
|---|---|---|
idp_passkey_registrations_total | status | Passkey registration outcomes |
idp_passkey_authentications_total | status, reason | Passkey authentication outcomes |
idp_passkey_management_total | action | Passkey update/revoke actions |
Security Considerations
Challenge Handling
- Challenges are single-use and expire after 5 minutes
- Challenge ID is used for lookup (not extracted from clientDataJSON)
- Challenge mismatch between stored and client-provided triggers rejection
Counter Verification
- Signature counter is validated on each authentication
- Counter must be greater than stored value (replay protection)
- Counter is updated after successful verification
Credential Revocation
- Revoked credentials cannot authenticate
- Revocation is soft-delete (
revokedAttimestamp) - Revoked passkeys remain visible in management UI
Troubleshooting
"Unknown credential"
- Credential not found in database
- User may be using a passkey registered on a different RP ID
"Credential revoked"
- Passkey has been revoked by user or admin
- User should register a new passkey
"Challenge mismatch"
- Challenge in clientDataJSON doesn't match stored challenge
- May indicate replay attack or corrupted request
"User is inactive"
- User account is deactivated
- Contact administrator to reactivate
Registration failures
- Check browser console for WebAuthn errors
- Ensure RP ID matches the domain
- Verify HTTPS is used in production
Rollout Checklist
Pre-Production
-
WEBAUTHN_RP_IDset to production domain -
WEBAUTHN_ORIGINincludes all client origins -
IDP_PASSKEYS_ENABLED=1in environment - Grafana dashboard imported (
grafana/dashboards/idp-passkeys.json) - Prometheus scraping IDP metrics endpoint
- Database migrations applied (
PasskeyCredential,PasskeyChallenge)
UAT Validation
- Registration flow works in Chrome, Safari, Firefox
- Authentication with registered passkey succeeds
- Management UI lists, renames, and revokes passkeys
- Passkey disabled state shows appropriate message
- Health endpoint includes passkey status
- Challenge cleanup cron is running (check logs hourly)
Production Go-Live
- Feature flag enabled:
IDP_PASSKEYS_ENABLED=1 - Frontend flag enabled:
VITE_PASSKEYS_ENABLED=1 - Monitor
idp_passkey_registrations_totalfor activity - Monitor
idp_passkey_authentications_total{status="failure"}for issues - Support team briefed on passkey troubleshooting
Post-Launch
- Review Grafana dashboard for anomalies
- Check challenge cleanup logs for expired challenge volume
- Collect user feedback on passkey onboarding UX
Related
- IDP Overview — IDP architecture
- ADR-0001 — Attestation policy
- @simplewebauthn/server — Library documentation
grafana/dashboards/idp-passkeys.json— Passkey metrics visualization (import into Grafana)