Passkey Authentication
Proxima Console supports WebAuthn passkeys for passwordless login and as an MFA replacement. Users can register multiple passkeys (biometrics, security keys) and optionally go fully passwordless.
Overview
| Feature | Description |
|---|---|
| Passwordless login | Discoverable credentials — no email or password needed |
| MFA replacement | Passkey as second factor instead of TOTP codes |
| Multiple passkeys | Register phone, laptop, YubiKey, etc. |
| Passwordless accounts | Invite acceptance via passkey — no password ever set |
| Clone detection | Sign count verification detects credential cloning |
Configuration
Passkey auth is enabled when the WebAuthn Relying Party (RP) ID is configured:
| Environment Variable | Default | Description |
|---|---|---|
PROXIMA_WEBAUTHN_RP_NAME | "Proxima Console" | Display name in browser/OS passkey dialogs |
PROXIMA_WEBAUTHN_RP_ID | (extracted from PROXIMA_FRONTEND_URL) | Domain the passkeys are bound to. Must match the domain users access. |
PROXIMA_WEBAUTHN_RP_ORIGINS | PROXIMA_FRONTEND_URL | Comma-separated list of allowed origins |
Important: The RP ID must match the production domain exactly. Passkeys registered on console.example.com won't work on localhost or a different domain.
Example
PROXIMA_FRONTEND_URL=https://console.example.com
PROXIMA_WEBAUTHN_RP_ID=console.example.com
PROXIMA_WEBAUTHN_RP_ORIGINS=https://console.example.com
API Reference
Registration (JWT required)
POST /api/v1/auth/passkey/register/begin
POST /api/v1/auth/passkey/register/finish?name=MyLaptop
Begin returns WebAuthn creation options (challenge, RP info, user info). The challenge is stored in Valkey with a 5-minute TTL.
Finish validates the browser's attestation response and stores the credential. The name query parameter is the user-assigned label.
Passwordless Login (no auth)
POST /api/v1/auth/passkey/login/begin
POST /api/v1/auth/passkey/login/finish?session_id=<id>
Begin returns assertion options with empty allowCredentials (discoverable credential flow). Returns a session_id for the finish step.
Finish verifies the assertion, resolves the user from the credential, checks sign count, and issues JWT + refresh token + session.
MFA (after password login)
POST /api/v1/auth/passkey/mfa/begin { "mfa_token": "..." }
POST /api/v1/auth/passkey/mfa/finish?session_id=<id>
Used when the login endpoint returns HTTP 202 with available_methods containing "passkey".
Invite Acceptance (no auth)
POST /api/v1/auth/passkey/accept-invite/begin { "token": "..." }
POST /api/v1/auth/passkey/accept-invite/finish?session_id=<id>&name=MyPasskey
Alternative to password-based invite acceptance. Activates the user with passkey_only=true.
Credential Management (JWT required)
GET /api/v1/auth/passkey/credentials
PUT /api/v1/auth/passkey/credentials/{id} { "name": "New Name" }
DELETE /api/v1/auth/passkey/credentials/{id}
Delete returns 409 if it's the last passkey on a passwordless account.
Modified Endpoints
GET /auth/config — now includes passkey_enabled: true/false
POST /auth/login — 202 response now includes available_methods: ["totp", "passkey"]
GET /auth/me — now includes passkey_count and passkey_only fields
Security Model
- Attestation:
"none"— no hardware attestation verification - Resident keys:
"preferred"— discoverable credentials preferred but not required - User verification:
"preferred"— biometric/PIN preferred but not required - Challenge TTL: 5 minutes, one-time use, stored in Valkey
- Clone detection: Sign count must monotonically increase; regression triggers rejection + audit log
- Deletion guard: Cannot remove last passkey on a passwordless account
Observability
Prometheus Metrics
| Metric | Labels | Description |
|---|---|---|
proxima_auth_passkey_registration_total | result | Passkey registrations |
proxima_auth_passkey_login_total | result | Passwordless logins |
proxima_auth_passkey_mfa_total | result | Passkey MFA verifications |
proxima_auth_passkey_clone_warning_total | — | Sign count regression warnings |
OTel Spans
| Span | Key Attributes |
|---|---|
auth.passkey.register.begin | user_id |
auth.passkey.register.finish | user_id, credential_name, aaguid, transport |
auth.passkey.login.begin | — |
auth.passkey.login.finish | user_id, credential_id_prefix, sign_count |
auth.passkey.mfa.begin | user_id |
auth.passkey.mfa.finish | user_id, credential_id_prefix, sign_count |
auth.passkey.delete | user_id, credential_id |
auth.passkey.list | user_id, count |
Audit Events
| Action | Details |
|---|---|
passkey_registered | credential name, transport, aaguid |
passkey_login | passkey_id |
passkey_mfa | passkey_id |
passkey_deleted | passkey_id |
passkey_clone_warning | expected vs actual sign_count |
Database
The user_passkeys table stores WebAuthn credentials:
CREATE TABLE user_passkeys (
id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
credential_id BYTEA NOT NULL UNIQUE,
public_key BYTEA NOT NULL,
attestation_type VARCHAR(32) DEFAULT 'none',
transport TEXT[],
aaguid BYTEA,
sign_count INTEGER DEFAULT 0,
name VARCHAR(255) NOT NULL,
last_used_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ
);
The users table has an additional passkey_only BOOLEAN DEFAULT FALSE column.