Skip to main content

Passkey (WebAuthn)

The passkey provider implements Auth.js Passkey flows using goauth’s stdlib-only goauth/webauthn package — no @simplewebauthn/server dependency.

:::warning Experimental parity Auth.js marks passkeys as experimental. goauth supports ES256 credentials with attestation: none (typical for passkeys). Full attestation verification (FIDO metadata, TPM, etc.) is not implemented. :::

Requirements

RequirementDetails
AdapterMust implement goauth.AuthenticatorStore
Tablesgoauth_authenticators (auto-migrated with postgres/mysql/memory)
HTTPSWebAuthn requires a secure context in browsers (localhost is OK)
Relying Party IDMust match your site’s registrable domain

Supported adapters today: postgres, mysql, memory. Redis/MongoDB do not yet implement AuthenticatorStore.

Setup (simple)

import (
"database/sql"
_ "github.com/lib/pq"

"github.com/izetmolla/goauth"
"github.com/izetmolla/goauth/adapters/postgres"
"github.com/izetmolla/goauth/providers/passkey"
)

db, _ := sql.Open("postgres", dsn)
adapter := postgres.New(db)

auth, err := goauth.New(goauth.Config{
Secret: []string{secret},
URL: "https://app.example.com",
Adapter: adapter,
Providers: []goauth.Provider{
passkey.New(passkey.Options{
RelyingPartyID: "app.example.com",
RelyingPartyName: "My Application",
}),
},
})

goauth.New validates that the adapter implements AuthenticatorStore when a passkey provider is registered.

Database schema

The goauth_authenticators table mirrors Auth.js Authenticator:

ColumnPurpose
credential_idBase64url-encoded credential ID (primary key)
user_idOwner
provider_account_idSame as credential ID for linking
credential_public_keyCOSE public key bytes
counterSignature counter (clone detection)
credential_device_typesingle-device or multi-device
credential_backed_upPasskey synced to cloud
transportsComma-separated (internal, hybrid, …)

HTTP API

Step 1 — Request options

Sign in (authenticate):

curl -X POST https://app.example.com/auth/signin/passkey \
-H "Content-Type: application/x-www-form-urlencoded"

Optional: email=user@example.com to restrict allowCredentials to that user’s passkeys.

Register a new passkey (user must already have a session):

curl -X POST "https://app.example.com/auth/signin/passkey?action=register" \
-b "goauth.session-token=..."

Response (JSON) — WebAuthn public key request options:

{
"challenge": "xY7...",
"timeout": 120000,
"rpId": "app.example.com",
"userVerification": "preferred",
"allowCredentials": [
{ "type": "public-key", "id": "AQID..." }
]
}

Registration responses include rp, user, and pubKeyCredParams instead.

Challenges are stored as verification tokens with identifier passkey:authenticate:{userId} or passkey:register:{userId} (or * for discoverable sign-in).

Step 2 — Browser ceremony

Use @simplewebauthn/browser or the native Web Authentication API:

// Simple example with fetch + browser API
const optsRes = await fetch("/auth/signin/passkey", { method: "POST" });
const options = await optsRes.json();

// Convert base64url fields to ArrayBuffers (libraries handle this)
const credential = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptionsFromJSON(options),
});

await fetch("/auth/callback/passkey", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: credential.id,
rawId: bufferToBase64URL(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64URL(credential.response.clientDataJSON),
authenticatorData: bufferToBase64URL(credential.response.authenticatorData),
signature: bufferToBase64URL(credential.response.signature),
},
}),
});

Step 3 — Callback verification

POST /auth/callback/passkey accepts:

  • JSON body — recommended for SPAs
  • Form field credential — JSON string (legacy/HTML forms)

goauth detects registration vs authentication from clientDataJSON.type:

  • webauthn.create → registration path
  • webauthn.get → authentication path

On success, completeSignIn runs (cookie redirect or bearer tokens with X-Auth-Flow: token).

Flow diagrams

Authentication

sequenceDiagram
participant B as Browser
participant S as goauth server
participant DB as Adapter

B->>S: POST /auth/signin/passkey
S->>DB: CreateVerificationToken (challenge)
S->>B: PublicKeyCredentialRequestOptions
B->>B: navigator.credentials.get()
B->>S: POST /auth/callback/passkey
S->>DB: UseVerificationToken (consume challenge)
S->>DB: GetAuthenticator(credentialId)
S->>S: webauthn.VerifyLogin (ES256)
S->>DB: UpdateAuthenticator (counter)
S->>B: Session cookie / tokens

Registration

sequenceDiagram
participant B as Browser
participant S as goauth

Note over B,S: User already signed in (session cookie)
B->>S: POST /auth/signin/passkey?action=register
S->>B: PublicKeyCredentialCreationOptions
B->>B: navigator.credentials.create()
B->>S: POST /auth/callback/passkey
S->>S: webauthn.VerifyRegistration
S->>S: CreateAuthenticator + LinkAccount
S->>B: Session refreshed

Advanced configuration

passkey.New(passkey.Options{
ID: "passkey", // route: /auth/signin/passkey
Name: "Sign in with Passkey",
RelyingPartyID: "app.example.com",
RelyingPartyName: "Acme Corp",
})

If RelyingPartyID is empty, goauth derives it from the request origin hostname (URL / TrustHost).

Discoverable credentials (usernameless)

Omit email on POST /auth/signin/passkey. The server returns options with empty allowCredentials. The authenticator may present a resident key; the user handle in the assertion identifies the account.

Token flow (mobile)

curl -X POST https://app.example.com/auth/callback/passkey \
-H "X-Auth-Flow: token" \
-H "Content-Type: application/json" \
-d @credential.json

Response matches other providers: accessToken, refreshToken, sessionId.

Linking passkeys to OAuth users

Users who sign in with GitHub first can register a passkey while authenticated:

  1. OAuth sign-in → session
  2. POST /auth/signin/passkey?action=register
  3. Complete callback → CreateAuthenticator + LinkAccount with type: passkey

goauth/webauthn internals

Verification steps (stdlib):

  1. Decode clientDataJSON — check type, challenge, origin
  2. Parse authenticatorData — RP ID hash, flags, sign count
  3. Parse COSE public key (EC2, P-256, ES256)
  4. Verify ECDSA signature over authenticatorData || SHA256(clientDataJSON)

Deep dive: WebAuthn internals.

Troubleshooting

SymptomLikely cause
origin mismatchConfig.URL / actual page origin differs from RP.Origin
rpId hash mismatchRelyingPartyID does not match site domain
unknown passkeyCredential not in DB or wrong base64url encoding
invalid or expired challengeSlow client, or challenge already consumed
adapter must implement AuthenticatorStoreUsing redis/mongo without passkey support

Simple vs advanced checklist

Simple: postgres adapter + passkey.New + @simplewebauthn/browser on login page.

Advanced: discoverable keys, token flow, custom ResolveUser, multi-tenant RP IDs per hostname (requires custom middleware or multiple auth handlers).