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
| Requirement | Details |
|---|---|
| Adapter | Must implement goauth.AuthenticatorStore |
| Tables | goauth_authenticators (auto-migrated with postgres/mysql/memory) |
| HTTPS | WebAuthn requires a secure context in browsers (localhost is OK) |
| Relying Party ID | Must 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:
| Column | Purpose |
|---|---|
credential_id | Base64url-encoded credential ID (primary key) |
user_id | Owner |
provider_account_id | Same as credential ID for linking |
credential_public_key | COSE public key bytes |
counter | Signature counter (clone detection) |
credential_device_type | single-device or multi-device |
credential_backed_up | Passkey synced to cloud |
transports | Comma-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 pathwebauthn.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:
- OAuth sign-in → session
POST /auth/signin/passkey?action=register- Complete callback →
CreateAuthenticator+LinkAccountwithtype: passkey
goauth/webauthn internals
Verification steps (stdlib):
- Decode clientDataJSON — check
type,challenge,origin - Parse authenticatorData — RP ID hash, flags, sign count
- Parse COSE public key (EC2, P-256, ES256)
- Verify ECDSA signature over
authenticatorData || SHA256(clientDataJSON)
Deep dive: WebAuthn internals.
Troubleshooting
| Symptom | Likely cause |
|---|---|
origin mismatch | Config.URL / actual page origin differs from RP.Origin |
rpId hash mismatch | RelyingPartyID does not match site domain |
unknown passkey | Credential not in DB or wrong base64url encoding |
invalid or expired challenge | Slow client, or challenge already consumed |
adapter must implement AuthenticatorStore | Using 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).