Skip to content
Written with Claude
IMPORTANT

As you may notice, this page and pretty much the entire website were obviously created with the help of AI. I wonder how you could tell? Was it a big "Written With Claude" badge on every page? I moved it to the top now (with the help of AI of course) to make it even more obvious. There are a few blogposts that were written by me manually, the old-fashioned way, I hope there will be more in the future, and those have a similar "Human Written" badge. This project (not the website), on the other hand, is a very, very different story. It took me more than two years of painstaking and unpaid work in my own free time. A story that, hopefully, I will tell someday. But meanwhile, what would you like me to do? To create a complex documentation website with a bunch of highly technical articles with the help of AI and fake it, to give you an illusion that I also did that manually? Like the half of itnernet is doing at this point? How does that makes any sense? Is that even fair to you? Or maybe to create this website manually, the old-fashioned way, just for you? While working a paid job for a salary, most of you wouldn't even get up in the morning. Would you like me to sing you a song while we're at it? For your personal entertainment? Seriously, get a grip. Do you find this information less valuable because of the way this website was created? I give my best to fix it to keep the information as accurate as possible, and I think it is very accurate at this point. If you find some mistakes, inaccurancies or problems, there is a comment section at the bottom of every page, which I also made with the help of the AI. And I woould very much appreciate if you leave your feedback there. Look, I'm just a guy who likes SQL, that's all. If you don't approve of how this website was constructed and the use of AI tools, I suggest closing this page and never wever coming back. And good riddance. And I would ban your access if I could know how. Thank you for your attention to this matter.

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
{
  "Auth": {
    "PasskeyAuth": {
      "Enabled": true
    }
  }
}

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"]). Auto-detected if empty.

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
{
  "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
{
  "Auth": {
    "PasskeyAuth": {
      "ClientAnalyticsIpKey": "ip"
    }
  }
}

Set to null or empty string to disable IP collection.

Complete Example

Minimal Configuration

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

Full Configuration

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)"
    }
  },
  "RateLimiting": {
    "Policies": {
      "passkey-limit": {
        "Type": "SlidingWindow",
        "PermitLimit": 10,
        "WindowSeconds": 60
      }
    }
  }
}

Next Steps

Comments

Released under the MIT License.