Skip to main content

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

FeatureDescription
Passwordless loginDiscoverable credentials — no email or password needed
MFA replacementPasskey as second factor instead of TOTP codes
Multiple passkeysRegister phone, laptop, YubiKey, etc.
Passwordless accountsInvite acceptance via passkey — no password ever set
Clone detectionSign count verification detects credential cloning

Configuration

Passkey auth is enabled when the WebAuthn Relying Party (RP) ID is configured:

Environment VariableDefaultDescription
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_ORIGINSPROXIMA_FRONTEND_URLComma-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

MetricLabelsDescription
proxima_auth_passkey_registration_totalresultPasskey registrations
proxima_auth_passkey_login_totalresultPasswordless logins
proxima_auth_passkey_mfa_totalresultPasskey MFA verifications
proxima_auth_passkey_clone_warning_totalSign count regression warnings

OTel Spans

SpanKey Attributes
auth.passkey.register.beginuser_id
auth.passkey.register.finishuser_id, credential_name, aaguid, transport
auth.passkey.login.begin
auth.passkey.login.finishuser_id, credential_id_prefix, sign_count
auth.passkey.mfa.beginuser_id
auth.passkey.mfa.finishuser_id, credential_id_prefix, sign_count
auth.passkey.deleteuser_id, credential_id
auth.passkey.listuser_id, count

Audit Events

ActionDetails
passkey_registeredcredential name, transport, aaguid
passkey_loginpasskey_id
passkey_mfapasskey_id
passkey_deletedpasskey_id
passkey_clone_warningexpected 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.