Skip to content

Passkey Authentication ​

NpgsqlRest supports WebAuthn/FIDO2 passkey authentication, providing phishing-resistant, passwordless login using device-native biometrics or PINs.

New in 3.5.0

Passkey authentication was added in version 3.5.0.

Overview ​

Passkeys use public-key cryptography tied to user devices. Unlike passwords, passkeys:

  • Cannot be phished (tied to origin)
  • Cannot be reused across sites
  • Cannot be stolen in database breaches (only public keys stored)
  • Require biometric or PIN verification

Minimal configuration to enable passkey authentication:

json
json
{
  "Auth": {
    "PasskeyAuth": {
      "Enabled": false,
      "EnableRegister": false,
      "RateLimiterPolicy": null,
      "ConnectionName": null,
      "CommandRetryStrategy": "default",
      "RelyingPartyId": null,
      "RelyingPartyName": null,
      "RelyingPartyOrigins": [],
      "AddPasskeyOptionsPath": "/api/passkey/add/options",
      "AddPasskeyPath": "/api/passkey/add",
      "RegistrationOptionsPath": "/api/passkey/register/options",
      "RegistrationPath": "/api/passkey/register",
      "LoginOptionsPath": "/api/passkey/login/options",
      "LoginPath": "/api/passkey/login",
      "ChallengeTimeoutMinutes": 5,
      "UserVerificationRequirement": "required",
      "ResidentKeyRequirement": "required",
      "AttestationConveyance": "none",
      "ValidateSignCount": true,
      "ChallengeAddExistingUserCommand": "select * from passkey_challenge_add_existing($1,$2)",
      "ChallengeRegistrationCommand": "select * from passkey_challenge_registration($1)",
      "ChallengeAuthenticationCommand": "select * from passkey_challenge_authentication($1,$2)",
      "VerifyChallengeCommand": "select * from passkey_verify_challenge($1,$2)",
      "AuthenticateDataCommand": "select * from passkey_authenticate_data($1)",
      "CompleteAddExistingUserCommand": "select * from passkey_complete_add_existing($1,$2,$3,$4,$5,$6,$7,$8)",
      "CompleteRegistrationCommand": "select * from passkey_complete_registration($1,$2,$3,$4,$5,$6,$7,$8)",
      "CompleteAuthenticateCommand": "select * from passkey_complete_authenticate($1,$2,$3,$4)",
      "ClientAnalyticsIpKey": "ip",
      "StatusColumnName": "status",
      "MessageColumnName": "message",
      "ChallengeColumnName": "challenge",
      "ChallengeIdColumnName": "challenge_id",
      "UserNameColumnName": "user_name",
      "UserDisplayNameColumnName": "user_display_name",
      "UserHandleColumnName": "user_handle",
      "ExcludeCredentialsColumnName": "exclude_credentials",
      "AllowCredentialsColumnName": "allow_credentials",
      "PublicKeyColumnName": "public_key",
      "PublicKeyAlgorithmColumnName": "public_key_algorithm",
      "SignCountColumnName": "sign_count"
    }
  }
}

How It Works ​

NpgsqlRest handles the WebAuthn protocol (CBOR parsing, signature verification) while your PostgreSQL functions control the business logic:

mermaid
flowchart LR
    A["Browser
    (Client Script)"] <--> B["NpgsqlRest
    (Endpoints + CBOR)"] <--> C["PostgreSQL
    (SQL Functions)"]
  • Browser: A client script that calls the browser's WebAuthn API and communicates with NpgsqlRest endpoints. See the passkey.ts example for a complete TypeScript implementation you can use as a starting point.
  • NpgsqlRest: Provides the HTTP endpoints and handles CBOR parsing/verification.
  • PostgreSQL: Your SQL functions control the entire authentication flow.

Your database stores only public keys - no biometric data ever touches your server.

Three Authentication Flows ​

NpgsqlRest supports three distinct passkey flows. Each endpoint internally executes a configured SQL command (typically a PostgreSQL function) that you define.

1. Registration (New User with Passkey) ​

For new users signing up with a passkey. Creates both the user account and passkey.

EndpointExecutes SQL Command
POST /api/passkey/register/optionsChallengeRegistrationCommand
POST /api/passkey/registerCompleteRegistrationCommand

Registration is Disabled by Default

Standalone registration (EnableRegister: false by default) allows anyone to create an account with just a passkey. In production, you'll typically want additional verification (email confirmation, CAPTCHA, etc.) before creating accounts.

The recommended approach is:

  1. Create user accounts through your existing registration flow
  2. Let users add passkeys to verified accounts using the Add Passkey flow

2. Add Passkey (Existing User) ​

For authenticated users who want to add a passkey to their account. These endpoints require authentication.

Endpoint (requires auth)Executes SQL Command
POST /api/passkey/add/optionsChallengeAddExistingUserCommand
POST /api/passkey/addCompleteAddExistingUserCommand

3. Login ​

For authenticating with an existing passkey.

EndpointExecutes SQL Command
POST /api/passkey/login/optionsChallengeAuthenticationCommand
POST /api/passkey/loginCompleteAuthenticateCommand

Settings Reference ​

General Settings ​

SettingTypeDefaultDescription
EnabledboolfalseEnable passkey authentication.
EnableRegisterboolfalseEnable standalone registration (new users can sign up with passkey only).
RateLimiterPolicystringnullName of a configured rate limiter policy. Recommended for brute-force protection.
ConnectionNamestringnullNamed connection for multi-database setups. Uses default if null.
CommandRetryStrategystring"default"Retry strategy for transient database errors. Set to null to disable.

Relying Party Settings ​

The Relying Party (RP) identifies your application to the authenticator.

SettingTypeDefaultDescription
RelyingPartyIdstringnullDomain name (e.g., "example.com"). Auto-detected if null. Note: IP addresses not permitted - use "localhost" for development.
RelyingPartyNamestringnullHuman-readable name shown during registration. Uses ApplicationName if null.
RelyingPartyOriginsstring[][]Allowed origins (e.g., ["https://example.com"]). ⚠️ When empty, origin validation accepts ANY origin — a startup warning is logged (3.17.0+). Always set explicitly in production.

Endpoint Paths ​

All paths are POST endpoints. Set to null to disable an endpoint.

SettingDefaultDescription
AddPasskeyOptionsPath"/api/passkey/add/options"Get options for adding passkey to existing user (requires auth).
AddPasskeyPath"/api/passkey/add"Complete adding passkey (requires auth).
RegistrationOptionsPath"/api/passkey/register/options"Get options for new user registration.
RegistrationPath"/api/passkey/register"Complete new user registration.
LoginOptionsPath"/api/passkey/login/options"Get login challenge.
LoginPath"/api/passkey/login"Complete authentication.

WebAuthn Settings ​

SettingTypeDefaultDescription
ChallengeTimeoutMinutesint5How long challenges remain valid.
ValidateSignCountbooltrueValidate signature counter to detect cloned authenticators.
UserVerificationRequirementstring"required"See below.
ResidentKeyRequirementstring"required"See below.
AttestationConveyancestring"none"See below.

UserVerificationRequirement ​

Controls whether biometric/PIN verification is required:

ValueBehaviorUse Case
"required"User MUST verify with biometric or PINBanking, healthcare, sensitive data
"preferred"Request verification if availableMost consumer apps
"discouraged"Don't request verification (proves possession only)Low-security scenarios

ResidentKeyRequirement ​

Controls discoverable credentials (true passwordless):

ValueBehaviorUse Case
"required"Credential stored on device; browser shows account pickerTrue passwordless (no username field)
"preferred"Request discoverable if supportedGradual migration to passwordless
"discouraged"Server must provide credential IDUsername-first flows

AttestationConveyance ​

Controls whether to verify authenticator hardware:

ValueBehaviorUse Case
"none"Accept any authenticatorMost apps (recommended)
"indirect"Allow anonymized attestationRarely useful
"direct"Request full attestation chainVerify specific hardware models
"enterprise"Enterprise-managed attestationCorporate device policies

SQL Commands Reference ​

NpgsqlRest calls your SQL functions at specific points in each flow.

ChallengeAddExistingUserCommand ​

When executed: User clicks "Add Passkey" (already authenticated)

Parameters:

  • $1 = claims (json): User claims from authenticated session
  • $2 = body (json): Request body (e.g., { "deviceName": "My Phone" })

Expected return columns:

ColumnTypeDescription
statusintHTTP status code. Return 200 to proceed.
messagetextError message when status ≠ 200.
challengetextBase64-encoded random bytes (32 bytes recommended).
challenge_idbigint/uuid/textServer-side identifier for verification.
user_handletextBase64-encoded random bytes for WebAuthn user.id.
user_nametextUsername shown in authenticator UI.
user_display_nametextDisplay name shown in authenticator UI.
exclude_credentialstextJSON array of existing credential IDs.
user_contextjsonPassed through to completion command.

ChallengeRegistrationCommand ​

When executed: New user starts passkey-only registration

Parameters:

  • $1 = body (json): Request body with user info

Expected return columns: Same as ChallengeAddExistingUserCommand

ChallengeAuthenticationCommand ​

When executed: User initiates passkey login

Parameters:

  • $1 = user_name (text): Username if provided, NULL for discoverable credentials
  • $2 = body (json): Request body

Expected return columns:

ColumnTypeDescription
statusintHTTP status code (200 to proceed).
messagetextError message when status ≠ 200.
challengetextBase64-encoded random challenge.
challenge_idbigint/uuid/textServer-side identifier.
allow_credentialstextJSON array of credential IDs for this user.

VerifyChallengeCommand ​

When executed: After browser returns credential, before cryptographic verification

Used by: ALL flows

Parameters:

  • $1 = challenge_id: The challenge_id from options response
  • $2 = operation (text): Either "registration" or "authentication"

Expected return: Single column challenge (bytea) - original challenge bytes, or NULL if not found/expired

AuthenticateDataCommand ​

When executed: During login, to retrieve stored credential data

Parameters:

  • $1 = credential_id (bytea): The credential ID from browser

Expected return columns:

ColumnTypeDescription
statusintHTTP status code (200 to proceed).
messagetextError message when status ≠ 200.
public_keybyteaStored public key for signature verification.
public_key_algorithmintCOSE algorithm ID (-7 for ES256, -257 for RS256).
sign_countbigintCurrent signature counter.
user_contextjsonPassed to CompleteAuthenticateCommand.

CompleteAddExistingUserCommand / CompleteRegistrationCommand ​

When executed: After successful attestation verification

Parameters:

ParameterTypeDescription
$1byteacredential_id - Unique credential identifier
$2byteauser_handle - WebAuthn user.id
$3byteapublic_key - Public key in COSE format
$4intalgorithm - COSE algorithm (-7 = ES256, -257 = RS256)
$5text[]transports - Transport hints (e.g., ["internal", "hybrid"])
$6booleanbackup_eligible - Whether credential can be synced
$7jsonuser_context - From challenge command
$8jsonanalytics_data - Optional client analytics

Expected return columns:

ColumnTypeDescription
statusintHTTP status code (200 = success).
messagetextError message when status ≠ 200.

CompleteAuthenticateCommand ​

When executed: After successful signature verification during login

Parameters:

ParameterTypeDescription
$1byteacredential_id - The credential used
$2bigintnew_sign_count - Updated signature counter
$3jsonuser_context - From AuthenticateDataCommand
$4jsonanalytics_data - Optional client analytics

Expected return columns: Same as login endpoint - the scheme column determines authentication type, other columns become claims.

Column Name Configuration ​

If your SQL functions use different column names:

json
json
{
  "Auth": {
    "PasskeyAuth": {
      "StatusColumnName": "status",
      "MessageColumnName": "message",
      "ChallengeColumnName": "challenge",
      "ChallengeIdColumnName": "challenge_id",
      "UserNameColumnName": "user_name",
      "UserDisplayNameColumnName": "user_display_name",
      "UserHandleColumnName": "user_handle",
      "ExcludeCredentialsColumnName": "exclude_credentials",
      "AllowCredentialsColumnName": "allow_credentials",
      "PublicKeyColumnName": "public_key",
      "PublicKeyAlgorithmColumnName": "public_key_algorithm",
      "SignCountColumnName": "sign_count"
    }
  }
}

Analytics Data ​

Collect client-side analytics by passing analyticsData in completion requests. NpgsqlRest automatically adds the client's IP address:

json
json
{
  "Auth": {
    "PasskeyAuth": {
      "ClientAnalyticsIpKey": "ip"
    }
  }
}

Set to null or empty string to disable IP collection.

Complete Example ​

Minimal Configuration ​

json
json
{
  "Auth": {
    "CookieAuth": true,
    "PasskeyAuth": {
      "Enabled": true
    }
  }
}

Full Configuration ​

json
json
{
  "Auth": {
    "CookieAuth": true,
    "PasskeyAuth": {
      "Enabled": true,
      "EnableRegister": true,
      "RateLimiterPolicy": "passkey-limit",

      "RelyingPartyId": null,
      "RelyingPartyName": "My Application",
      "RelyingPartyOrigins": [],

      "UserVerificationRequirement": "required",
      "ResidentKeyRequirement": "required",
      "AttestationConveyance": "none",

      "ChallengeTimeoutMinutes": 5,
      "ValidateSignCount": true,

      "ChallengeAddExistingUserCommand": "select * from passkey_challenge_add_existing($1,$2)",
      "ChallengeRegistrationCommand": "select * from passkey_challenge_registration($1)",
      "ChallengeAuthenticationCommand": "select * from passkey_challenge_authentication($1,$2)",
      "VerifyChallengeCommand": "select * from passkey_verify_challenge($1,$2)",
      "AuthenticateDataCommand": "select * from passkey_authenticate_data($1)",
      "CompleteAddExistingUserCommand": "select * from passkey_complete_add_existing($1,$2,$3,$4,$5,$6,$7,$8)",
      "CompleteRegistrationCommand": "select * from passkey_complete_registration($1,$2,$3,$4,$5,$6,$7,$8)",
      "CompleteAuthenticateCommand": "select * from passkey_complete_authenticate($1,$2,$3,$4)"
    }
  },
  "RateLimiterOptions": {
    "Enabled": true,
    "Policies": {
      "passkey-limit": {
        "Type": "SlidingWindow",
        "Enabled": true,
        "PermitLimit": 10,
        "WindowSeconds": 60
      }
    }
  }
}

Next Steps ​

Comments