WebAuthn / FIDO2 passwordless authentication with phishing-resistant public-key credentials.
The Passkey feature lets users register a public-key credential (passkey) bound to their device or sync fabric (iCloud Keychain, Google Password Manager, 1Password, etc.) and use it to prove identity to the server without a shared secret. Passage defines the storage protocols, orchestration, HTTP routes, and configuration surface; cryptographic verification is delegated to a pluggable PasskeyService implementation — see passage-webauthn for the default swift-webauthn backend.
Key capabilities:
- W3C WebAuthn Level 3 registration ceremony (begin → finish)
- W3C WebAuthn Level 3 authentication ceremony (begin → finish) — discoverable / usernameless flow
- Challenge storage with SHA-256 hashing + TTL + one-shot consumption (per-ceremony kind)
- Credential storage (public key, sign count, backup flags, transports) + sign-count updates on every successful authentication
- Session cookie + exchange code issued on successful authentication (same pattern as OAuth)
- Dual-mode response: JSON for API clients, HTML redirect for Leaf-backed form submissions
- Core package has zero dependency on any WebAuthn library — swap backends freely
| Capability | Status |
|---|---|
| Guest registration ceremony (begin + finish) — public, form-driven | ✅ Implemented |
| Registration ceremony (begin + finish) — authenticated user adds a passkey | ✅ Implemented |
| Authentication ceremony (begin) | ✅ Implemented (discoverable-only) |
| Authentication ceremony (finish) | ✅ Implemented — sign-count update + session + exchange code |
Lifecycle hooks (will* / did* for begin & finish, both flows) |
✅ Implemented in Passage.Hooks.Passkey |
WebAuthnPasskeyService backend |
✅ Implemented in passage-webauthn (both ceremonies) |
PasskeyCredentialStore / PasskeyChallengeStore protocols |
✅ Defined |
| In-memory store impls (for tests) | ✅ In PassageOnlyForTest |
| Fluent-backed store impls | ❌ Not yet in passage-fluent |
| Leaf view for guest registration | ✅ passkey-guest-registration-minimalism template |
| Leaf view for authentication | ✅ passkey-authentication-minimalism template |
allowDiscoverableLogin policy flag |
✅ Enforced at beginAuthentication — when false, begin returns 400 discoverableLoginDisabled |
| Hinted (username-first) authentication | allowCredentials: [PasskeyCredentialDescriptor]?) but orchestration is discoverable-only — no HTTP endpoint accepts a user hint |
allowAutoRegistration linking flag |
401 unknownPasskey; auto-create-user-from-userHandle not implemented |
| AAGUID / attestation-format capture | nil — swift-webauthn doesn't expose these through its public surface |
uvInitialized flag |
policy.userVerification == .required, not read from authenticator data |
Passkey support is split across three packages to keep the cryptographic backend swappable:
passage/ — abstraction
- PasskeyService protocol (4 ceremony methods)
- PasskeyCredential, PasskeyChallenge, PasskeyCredentialDescriptor (DTOs)
- PasskeyCredentialStore, PasskeyChallengeStore (storage protocols)
- Passage.Passkey feature (orchestration for both ceremonies)
- Passkey.RouteCollection (4 HTTP routes)
- Configuration.Passkey (config surface)
passage-webauthn/ — implementation
- WebAuthnPasskeyService (wraps swift-webauthn)
- Retroactive `AsyncResponseEncodable` for WebAuthn request + creation options
- Mapping functions: WebAuthn.Credential → Passage.PasskeyCredential,
WebAuthn.VerifiedAuthentication → Passage.PasskeyFinishAuthenticationResult
passage-fluent/ — storage (planned)
- PasskeyCredentialModel, PasskeyChallengeModel (not yet implemented)
passage core imports only Foundation and Vapor. passage-webauthn is the only package that imports WebAuthn (from swift-server/webauthn-swift).
Passage.Configuration(
// ... other config ...
passkey: .init(
routes: .init(), // Default /auth/passkey/...
policy: .init(
timeout: .seconds(60),
attestation: .none,
userVerification: .preferred,
supportedAlgorithms: [.ES256, .RS256]
),
linking: .init(
allowAutoRegistration: true // Reserved for auth ceremony
),
challengeTTL: 300 // 5 minutes
)
)Relying-party identity and allowed origins are configured on the PasskeyService implementation (e.g. WebAuthnManager.Configuration) at service init, not on Configuration.Passkey.
Configuration.Passkey:
| Option | Type | Default | Description |
|---|---|---|---|
routes |
Routes |
.init() |
Route path customization (see below). |
policy |
Policy |
.init() |
Per-ceremony WebAuthn knobs (see below). |
linking |
Linking |
.init() |
Post-authentication linking policy. |
challengeTTL |
TimeInterval |
300 |
Challenge validity window, handed to the service per-call. |
Configuration.Passkey.Policy:
| Option | Type | Default | Used | Notes |
|---|---|---|---|---|
userVerification |
UserVerificationRequirement |
.preferred |
✅ | Forwarded to requireUserVerification. Also derives uvInitialized on the stored credential. |
supportedAlgorithms |
[COSEAlgorithmIdentifier] |
[.ES256, .RS256] |
✅ | Forwarded as publicKeyCredentialParameters. |
attestation |
AttestationConveyancePreference |
.none |
✅ | Forwarded to attestation parameter. |
timeout |
Duration? |
nil |
✅ | Client-side ceremony timeout hint. |
allowDiscoverableLogin |
Bool |
true |
✅ | Gate on POST /authenticate/begin. When false, the endpoint returns 400 discoverableLoginDisabled — hinted/username-first auth is not exposed via any HTTP endpoint today. |
Configuration.Passkey.Routes:
| Option | Type | Default | Description |
|---|---|---|---|
group |
[PathComponent] |
["passkey"] |
Subpath under Configuration.Routes.group |
guestRegistrationBegin.path |
[PathComponent] |
["guest", "registration", "begin"] |
Begin guest registration (public). Opt-in: pass .default (or a custom .init(path:)) — nil skips the route. |
guestRegistrationFinish.path |
[PathComponent] |
["guest", "registration", "finish"] |
Finish guest registration (public). Opt-in alongside guestRegistrationBegin. |
registrationBegin.path |
[PathComponent] |
["registration", "begin"] |
Begin authenticated registration |
registrationFinish.path |
[PathComponent] |
["registration", "finish"] |
Finish authenticated registration |
authenticationBegin.path |
[PathComponent] |
["authentication", "begin"] |
Begin authentication endpoint |
authenticationFinish.path |
[PathComponent] |
["authentication", "finish"] |
Finish authentication endpoint |
The two guestRegistration* routes are opt-in — they register only when both sides are non-nil on Routes. The two registration* routes are always registered behind PassageSessionAuthenticator + PassageBearerAuthenticator + PassageGuard; unauthenticated requests return 401.
Passage exposes two registration flows with distinct trust models. Both ultimately call the same finishRegistration orchestration method — the begin paths and route gating differ, but the finish path is shared and dispatches on the challenge's stored subject.
Public, form-driven flow for new users creating an account with a passkey as their primary credential. Identity is self-asserted via a form identifier (email / phone / username).
- Client POSTs a
PasskeyGuestRegistrationFormwith one ofemail/phone/usernameplusdisplayName. - Orchestration calls
UserStore.find(byIdentifier:). If a user already exists, the begin rejects with409 identifierAlreadyRegistered— guest registration is for new accounts; returning users must authenticate first and use the/registration/beginflow. PasskeyService.beginRegistration(with:policy:challengeTTL:)produces a raw challenge plus an opaque creation-options body. The user entity is derived from the identifier (PublicKeyCredentialUserEntity.init(for: identifier, displayName:)).- Core persists the challenge bound to the identifier (
(user: nil, identifier: x)); no user record is created at begin time. ThePasskeyChallengeStoreSHA-256-hashes the raw bytes internally — plaintext never reaches the DB. - Response on
/begin:PublicKeyCredentialCreationOptionsJSON (Accept: application/json) or a redirect back to the configuredviews.passkeyGuestRegistrationLeaf view (form submission withAccept: text/html). - Browser calls
navigator.credentials.create()and POSTs the result to/guest/registration/finish. - Service verifies the attestation via
lookupChallenge+confirmUnused; core re-checksUserStore.find(byIdentifier:)(TOCTOU) — if a user with that identifier was created between begin and finish, the ceremony is rejected with401 invalidPasskeyChallenge. - Otherwise core creates the user via
UserStore.create(identifier:with: nil), persists the credential, and consumes the challenge. - Response on
/finish:201 CreatedwithPasskeyRegistrationResponse { credentialID }.
Authenticated flow for users who already have an account (created via password, OAuth, magic-link, or a prior passkey) and want to add a new passkey. This is the FIDO / Apple / Google recommended default. Both endpoints sit behind PassageSessionAuthenticator + PassageBearerAuthenticator + PassageGuard — unauthenticated requests return 401 at the middleware layer.
- Client POSTs an optional
PasskeyRegisterRequestbody to/registration/beginwith{ "displayName": "…" }(or no body); the authenticated user is resolved viarequest.passage.user. Display name defaults touser.username ?? user.email ?? user.phone ?? "Passkey". - Core persists the challenge bound to the user (
(user: x, identifier: nil)). - Response on
/begin:PublicKeyCredentialCreationOptionsJSON. JSON only; no Leaf view. - Browser calls
navigator.credentials.create()and POSTs to/registration/finishwith the same bearer/session. - The shared finish handler recovers the user from the matched challenge and asserts it equals
request.passage.user— a mismatch (or no authenticated user at all) is rejected with401 invalidPasskeyChallengeas defense-in-depth against cross-session challenge swapping. - Core persists the credential and consumes the challenge.
- Response on
/finish:201 CreatedwithPasskeyRegistrationResponse { credentialID }.
Both flows route to one orchestration method — Passage.Passkey.finishRegistration(rawBody:) — which dispatches on the challenge's stored subject:
matchedChallenge.user != nil(auth flow): requirerequest.passage.user.equals(to: bound); reject otherwise.matchedChallenge.identifier != nil(guest flow): requirefind(byIdentifier:) == nil; create the user; bind the credential.
The two routes that mount this method differ only in their middleware chain — /registration/finish sits behind PassageGuard (so the auth-flow branch always sees an authenticated session); /guest/registration/finish is public (so the guest-flow branch sees no session, but the auth-flow branch's request.passage.user throws 401 if a stolen user-bound challenge is submitted there). The PasskeyService protocol sees a single registration ceremony — the guest-vs-authenticated distinction is an HTTP/orchestration concern, not a cryptographic one.
Discoverable-only. The endpoint takes no user-supplied identifier — the authenticator reveals which credential the user picked, and the server recovers the user from that credential's stored record. A typed form would defeat the UX win of passkeys. When policy.allowDiscoverableLogin is false the begin endpoint returns 400 discoverableLoginDisabled; hinted/username-first authentication is not exposed over HTTP today.
- Browser POSTs an empty body (
{}is fine; content-type is ignored). - Orchestration calls
PasskeyService.beginAuthentication(allowCredentials: nil, policy:, challengeTTL:). - The service returns raw challenge bytes plus an opaque
PublicKeyCredentialRequestOptionsbody. - Core persists the challenge via
PasskeyChallengeStore.createPasskeyChallenge(from:)— neitherusernoridentifieris set because the picker hasn't chosen yet; the kind is.authentication. - Response: the WebAuthn
PublicKeyCredentialRequestOptionsJSON (challenge,rpId,timeout,allowCredentials: null,userVerification).
- Browser POSTs the raw WebAuthn JSON (the result of
navigator.credentials.get()). - Route handler reads the raw body and hands it to
PasskeyService.finishAuthentication(rawBody:policy:lookupChallenge:lookupCredential:). - Service decodes into the WebAuthn library's native
AuthenticationCredential, extracts the challenge fromclientDataJSON, and invokeslookupChallenge(which validateskind == .authentication, not consumed, not expired). - Service invokes
lookupCredentialto fetch the stored credential's public key + current sign count; throwsunknownPasskeyif no stored credential matches. - Service runs signature + sign-count verification via
swift-webauthnand returnsPasskeyFinishAuthenticationResult { matchedCredential, matchedChallenge, newSignCount, credentialBackedUp, userHandle? }. - Core calls
PasskeyCredentialStore.updatePasskeyCredentialAfterAuthentication(forCredentialID:newSignCount:isBackedUp:), consumes the challenge, and resolvesuserfrommatchedCredential.user. - Core calls
request.passage.login(user)(sets session cookie if sessions are enabled) and mints an exchange code viarequest.tokens.createExchangeCode(for: user). - Response:
200 OKwithPasskeyAuthenticationResponse { code }. The browser-side JS typically redirects to the view'sredirect.onSuccesswith the code appended as?code=..., matching the OAuth exchange-code pattern.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /auth/passkey/guest/registration/begin |
public | Renders the Leaf form (only when views.passkeyGuestRegistration is configured) |
| POST | /auth/passkey/guest/registration/begin |
public | Begins guest registration (form-driven). Rejects existing identifiers with 409. |
| POST | /auth/passkey/guest/registration/finish |
public | Finalizes guest registration. Creates the user from the challenge identifier. |
| POST | /auth/passkey/registration/begin |
session/bearer + guard | Begins authenticated registration (existing user adds a passkey) |
| POST | /auth/passkey/registration/finish |
session/bearer + guard | Finalizes authenticated registration — asserts session user matches challenge user |
| GET | /auth/passkey/authentication/begin |
public | Renders the Leaf form (only when views.passkeyAuthentication is configured) |
| POST | /auth/passkey/authentication/begin |
public | Begins authentication ceremony (discoverable) |
| POST | /auth/passkey/authentication/finish |
public | Finalizes authentication ceremony, issues session + exchange code |
All paths honor Configuration.Passkey.Routes overrides. The two guest/registration/* routes are opt-in (omit guestRegistrationBegin / guestRegistrationFinish from Routes to skip them).
sequenceDiagram
participant Browser
participant Routes as Passkey.RouteCollection
participant Orch as Passage.Passkey (orchestration)
participant Svc as PasskeyService
participant Store as Store (users / credentials / challenges)
Note over Browser,Store: Begin path differs by flow
alt Guest registration
Browser->>Routes: POST /guest/registration/begin {identifier, displayName}
Routes->>Orch: beginGuestRegistration(form:)
Orch->>Store: users.find(byIdentifier:)
alt user exists
Orch-->>Routes: throw identifierAlreadyRegistered
Routes-->>Browser: 409 Conflict
else user does not exist
Orch->>Svc: beginRegistration(userEntity from identifier, policy, ttl)
Svc-->>Orch: PasskeyBeginResult {challenge, body}
Orch->>Store: challenges.createPasskeyChallenge(for: identifier, from:)
Orch-->>Routes: body
Routes-->>Browser: 200 OK {rp, user, challenge, ...}
end
else Authenticated registration
Browser->>Routes: POST /registration/begin {displayName?} (with session/bearer)
Routes->>Orch: beginRegistration(request:)
Orch->>Orch: request.passage.user
Orch->>Svc: beginRegistration(userEntity from user, policy, ttl)
Svc-->>Orch: PasskeyBeginResult {challenge, body}
Orch->>Store: challenges.createPasskeyChallenge(for: user, from:)
Orch-->>Routes: body
Routes-->>Browser: 200 OK {rp, user, challenge, ...}
end
Browser->>Browser: navigator.credentials.create()
Note over Browser,Store: Finish path is shared
Browser->>Routes: POST /(guest/)registration/finish {credential}
Routes->>Orch: finishRegistration(rawBody:)
Orch->>Svc: finishRegistration(rawBody, policy, lookupChallenge, confirmUnused)
Svc->>Orch: lookupChallenge(bytes)
Orch->>Store: challenges.find(passkeyChallengeMatching:)
Store-->>Orch: any StoredPasskeyChallenge?
Orch-->>Svc: any StoredPasskeyChallenge
Svc->>Orch: confirmUnused(credentialID)
Orch->>Store: credentials.find(byCredentialID:)
Store-->>Orch: (any StoredPasskeyCredential)?
Orch-->>Svc: Bool
Svc->>Svc: verify attestation (swift-webauthn)
Svc-->>Orch: PasskeyFinishRegistrationResult {credential, matchedChallenge}
alt matchedChallenge.user != nil (auth flow)
Orch->>Orch: request.passage.user.equals(to: bound)?
Orch-->>Routes: throw invalidPasskeyChallenge if mismatch (or no auth → 401)
else matchedChallenge.identifier != nil (guest flow)
Orch->>Store: users.find(byIdentifier:)
alt identifier was claimed mid-flight
Orch-->>Routes: throw invalidPasskeyChallenge
else identifier still free
Orch->>Store: users.create(identifier: identifier, with: nil)
end
end
Orch->>Store: credentials.createPasskeyCredential(for: user, from:)
Orch->>Store: challenges.consume(passkeyChallenge:)
Orch-->>Routes: any StoredPasskeyCredential
Routes-->>Browser: 201 Created {credentialID}
sequenceDiagram
participant Browser
participant Routes as Passkey.RouteCollection
participant Orch as Passage.Passkey (orchestration)
participant Svc as PasskeyService
participant Store as Store (credentials / challenges / tokens)
participant Session as Passage.login + tokens
Browser->>Routes: POST /authentication/begin {} (empty)
Routes->>Orch: beginAuthentication()
Orch->>Svc: beginAuthentication(allowCredentials: nil, policy, challengeTTL)
Svc-->>Orch: PasskeyBeginResult {challenge, body}
Orch->>Store: challenges.createPasskeyChallenge(from:)
Orch-->>Routes: any AsyncResponseEncodable
Routes-->>Browser: 200 OK {challenge, rpId, timeout, userVerification}
Browser->>Browser: navigator.credentials.get()
Browser->>Routes: POST /authentication/finish {assertion}
Routes->>Orch: finishAuthentication(rawBody:)
Orch->>Svc: finishAuthentication(rawBody, policy, lookupChallenge, lookupCredential)
Svc->>Svc: Decode AuthenticationCredential + CollectedClientData
Svc->>Orch: lookupChallenge(bytes)
Orch->>Store: challenges.find(passkeyChallengeMatching:) (kind == .authentication)
Store-->>Orch: any StoredPasskeyChallenge?
Orch-->>Svc: any StoredPasskeyChallenge
Svc->>Orch: lookupCredential(credentialID)
Orch->>Store: credentials.find(byCredentialID:)
Store-->>Orch: any StoredPasskeyCredential?
Orch-->>Svc: any StoredPasskeyCredential
Svc->>Svc: verify signature + sign-count (swift-webauthn)
Svc-->>Orch: PasskeyFinishAuthenticationResult {matchedCredential, newSignCount, credentialBackedUp, userHandle?}
Orch->>Store: credentials.updatePasskeyCredentialAfterAuthentication(credentialID, newSignCount, isBackedUp)
Orch->>Store: challenges.consume(passkeyChallenge:)
Orch->>Session: request.passage.login(user)
Orch->>Session: request.tokens.createExchangeCode(for: user)
Session-->>Orch: String (opaque exchange code)
Orch-->>Routes: (user, code)
Routes-->>Browser: 200 OK {code} + Set-Cookie session
Cryptographic boundary. Implementations wrap a WebAuthn library (e.g. swift-webauthn):
protocol PasskeyService: Sendable {
// Registration
func beginRegistration(
with user: PublicKeyCredentialUserEntity,
policy: Passage.Configuration.Passkey.Policy,
challengeTTL: TimeInterval
) async throws -> PasskeyBeginResult
func finishRegistration(
rawBody: Data,
policy: Passage.Configuration.Passkey.Policy,
lookupChallenge: @Sendable (_ challengeBytes: Data) async throws -> (any StoredPasskeyChallenge)?,
confirmUnused: @Sendable (_ credentialID: String) async throws -> Bool
) async throws -> PasskeyFinishRegistrationResult
// Authentication
func beginAuthentication(
allowCredentials: [PasskeyCredentialDescriptor]?,
policy: Passage.Configuration.Passkey.Policy,
challengeTTL: TimeInterval
) async throws -> PasskeyBeginResult
func finishAuthentication(
rawBody: Data,
policy: Passage.Configuration.Passkey.Policy,
lookupChallenge: @Sendable (_ challengeBytes: Data) async throws -> (any StoredPasskeyChallenge)?,
lookupCredential: @Sendable (_ credentialID: String) async throws -> (any StoredPasskeyCredential)?
) async throws -> PasskeyFinishAuthenticationResult
}Design notes:
PasskeyBeginResult.bodyisany AsyncResponseEncodable & Sendable— the runtime value is whatever the WebAuthn library produces (e.g.WebAuthn.PublicKeyCredentialCreationOptionsfor registration,WebAuthn.PublicKeyCredentialRequestOptionsfor authentication). Core never names or inspects it.- Both
finish*methods takerawBody: Datarather than a typed DTO so core never has to model the WebAuthn ceremony JSON. The service owns decoding. lookupChallenge/lookupCredential/confirmUnusedare closures rather than store references so core retains ownership of challenge + credential semantics (kind, expiry, consumed state, cross-ceremony rejection) while the service only decides whether the verification challenge matches something the server issued.allowCredentials: [PasskeyCredentialDescriptor]?onbeginAuthenticationis alwaysnilfrom the current HTTP endpoint (discoverable flow). The parameter is preserved for forward-compatibility with a future hinted-flow API.
PasskeyChallenge — service → store boundary for a freshly issued challenge:
struct PasskeyChallenge {
let bytes: Data // raw challenge; store hashes before persisting
let kind: PasskeyChallengeKind // .registration or .authentication
let expiresAt: Date
}PasskeyCredential — service → store boundary for a verified credential:
struct PasskeyCredential {
let credentialID: String // base64url
let publicKey: Data // COSE_Key bytes
let signCount: UInt32
let uvInitialized: Bool
let transports: [AuthenticatorTransport]
let backupEligible: Bool
let isBackedUp: Bool
let aaguid: String? // nil on current swift-webauthn backend
let attestationFormat: String? // nil on current swift-webauthn backend
}PasskeyCredentialDescriptor — core → service hint for allowCredentials (reserved for hinted-flow auth; the HTTP layer always passes nil today):
struct PasskeyCredentialDescriptor {
let credentialID: String // base64url
let transports: [AuthenticatorTransport]
}PasskeyFinishAuthenticationResult — service → core return for the authentication ceremony:
struct PasskeyFinishAuthenticationResult {
let matchedCredential: any StoredPasskeyCredential
let matchedChallenge: any StoredPasskeyChallenge
let newSignCount: UInt32
let credentialBackedUp: Bool
let userHandle: Data? // populated when the authenticator returned one
}PasskeyAuthenticationResponse — response body for POST /authenticate/finish:
struct PasskeyAuthenticationResponse: Content {
let code: String // opaque exchange code
}PasskeyCredentialStore:
| Method | Purpose |
|---|---|
createPasskeyCredential(for:from:) |
Persist a newly-registered credential |
find(byCredentialID:) |
Lookup by W3C id — used during authentication and the uniqueness check |
listPasskeyCredentials(forUser:) |
Device-management UIs |
updatePasskeyCredentialAfterAuthentication(forCredentialID:newSignCount:isBackedUp:) |
Post-auth sign-count / backup updates |
deletePasskeyCredential(byCredentialID:) |
User-initiated device removal |
PasskeyChallengeStore:
| Method | Purpose |
|---|---|
createPasskeyChallenge(from:) |
Persist a challenge with no subject — used by discoverable authentication. Stored record has user == nil && identifier == nil. |
createPasskeyChallenge(for: User, from:) |
Persist a challenge bound to a known user (authenticated registration). Stored record has user == bound && identifier == nil. |
createPasskeyChallenge(for: Identifier, from:) |
Persist a challenge bound to a pending identifier (guest registration). Stored record has user == nil && identifier == x — the user is materialised at finish time. |
find(passkeyChallengeMatching:) |
Look up by the raw bytes the authenticator echoed back; implementation SHA-256-hashes internally |
consume(passkeyChallenge:) |
One-shot consumption |
cleanupExpiredPasskeyChallenges(before:) |
Maintenance |
Implementations SHA-256-hash challenge.bytes before persisting — plaintext never touches the database. The three create* overloads make the issuance subject explicit at the call site; the stored record's user and identifier fields are mutually exclusive for registration challenges (user != nil xor identifier != nil).
Both store protocols are optional on Passage.Store — third-party conformances default them to nil. Passage's orchestration throws PassageError.passkeyNotConfigured if they're unset.
Database backends conform to StoredPasskeyCredential and StoredPasskeyChallenge (generic over the backend's natural Id and User types). Each flattens the DTO fields plus id, user, createdAt / updatedAt. See Sources/Passage/Protocols/StoredPasskeyCredential.swift and StoredPasskeyChallenge.swift.
For the WebAuthn backend, use passage-webauthn, which wraps swift-webauthn 1.0.0-beta.1:
import PassageWebAuthn
import WebAuthn
let passkeyService = WebAuthnPasskeyService(
configuration: WebAuthnManager.Configuration(
relyingPartyID: "example.com",
relyingPartyName: "My App",
relyingPartyOrigin: "https://example.com"
)
)
try await app.passage.configure(
services: .init(
store: store,
passkey: passkeyService,
),
configuration: .init(
// ... other config ...
passkey: .init()
)
)Core never imports WebAuthn. The @retroactive AsyncResponseEncodable conformance lives entirely in passage-webauthn/Passage+WebAuthn.swift.
Leaf-backed UI is opt-in per ceremony (signup and authenticate). The authenticated "register" flow is JSON only — no Leaf view.
views: .init(
passkeyGuestRegistration: .init(
style: .minimalism,
theme: .init(colors: .defaultLight),
identifier: .email
),
passkeyAuthentication: .init(
style: .minimalism,
theme: .init(colors: .defaultLight),
redirect: .init(onSuccess: "/") // navigated to with ?code=... appended on success
)
)Guest Registration view — When passkeyGuestRegistration is configured, GET /auth/passkey/guest/registration/begin renders passkey-guest-registration-minimalism.leaf with inline JavaScript that collects the identifier + display name, calls navigator.credentials.create(), and POSTs to the finish endpoint. HTML form submissions to the begin endpoint redirect back to the view with ?success= or ?error= query parameters.
Authentication view — When passkeyAuthentication is configured, GET /auth/passkey/authentication/begin renders passkey-authentication-minimalism.leaf. The form posts no identifier — the page fires navigator.credentials.get({publicKey: options}) with the options returned by POST /authentication/begin (empty body) and then POSTs the assertion to /authentication/finish. On success, the JS navigates to view.redirect.onSuccess with the returned code appended as ?code=... (matching the OAuth exchange-code handoff pattern). If redirect.onSuccess is not set, the page shows an inline success message.
Without a configured view, the GET route returns 404 while the POST ceremony endpoints keep working for JS-driven SPA clients.
Passkey ceremonies expose will* / did* lifecycle hooks via Passage.Hooks.Passkey. will* hooks fire async throws and abort the flow when they throw; did* hooks are non-throwing observers that fire after the underlying step has succeeded. All have empty default implementations.
| Hook | Fires… |
|---|---|
willBeginGuestRegistration(with: form, as: entity, on: request) |
Before service.beginRegistration runs, after the identifier-already-registered check passes |
didBeginGuestRegistration(with: result, on: request) |
After the challenge is persisted, before responding |
willBeginRegistration(for: user, as: entity, on: request) |
Before service.beginRegistration runs in the authenticated flow |
didBeginRegistration(with: result, for: user, on: request) |
After the challenge is persisted in the authenticated flow |
willFinishGuestRegistration(with: identifier, on: request) |
After the TOCTOU identifier check passes, before user creation |
willFinishRegistration(for: user, on: request) |
After the bound-user equality check passes |
didFinishGuestRegistration(with: credential, for: user, on: request) |
After the credential is persisted and the challenge consumed (guest flow) |
didFinishRegistration(with: credential, for: user, on: request) |
After the credential is persisted and the challenge consumed (auth flow) |
willBeginAuthentication(on: request) |
After the discoverable-login policy check, before service.beginAuthentication runs |
didBeginAuthentication(with: result, on: request) |
After the authentication challenge is persisted, before responding |
willFinishAuthentication(with: credential, for: user, on: request) |
After signature verification + user resolution, before sign-count update / challenge consumption / session login / exchange-code mint. The auth-equivalent of Account.willLogin — gate post-authentication policy here (suspended account, revoked credential, MFA step-up). |
didFinishAuthentication(with: credential, for: user, code: String, on: request) |
After the session is established and the exchange code minted, before responding |
The willFinish* hooks are deliberately ordered after the binding/TOCTOU/signature checks — handlers see only ceremonies that will succeed, so audit logs / notifications are not attributed to victims on hijack attempts. Throwing from any will* hook aborts the ceremony before any state changes (no challenge consumed, no sign-count bumped, no session established).
Wire hooks via the .hook(...) factory on Passage.Hooks.Passkey:
hooks: .init(
passkey: .hook(
didFinishGuestRegistration: { credential, user, request in
request.logger.info("new passkey for \(user.id ?? "?")")
},
willFinishRegistration: { user, request in
// gate authenticated-flow finishes on a custom policy
},
willFinishAuthentication: { credential, user, request in
// post-auth policy: suspended accounts, MFA step-up, etc.
guard !user.isSuspended else {
throw Abort(.forbidden, reason: "Account suspended")
}
},
didFinishAuthentication: { credential, user, code, request in
await analytics.recordPasskeyLogin(
userID: user.id, credentialID: credential.credentialID,
isBackedUp: credential.isBackedUp
)
}
)
)| Error | Trigger |
|---|---|
PassageError.passkeyNotConfigured |
configuration.passkey is nil, or Store.passkeyCredentials / Store.passkeyChallenges returns nil |
PassageError.passkeyServiceNotAvailable |
No PasskeyService registered in services |
AuthenticationError.identifierAlreadyRegistered |
Guest registration begin: the form identifier already maps to a User. Returning users must authenticate first and use /registration/begin → 409 |
AuthenticationError.invalidPasskeyChallenge |
Challenge lookup failed, expired, already consumed, wrong kind; service could not extract challenge bytes from clientDataJSON; auth-flow finish where the session user does not equal the bound user; or guest-flow finish where the identifier was claimed between begin and finish (TOCTOU) → 401 |
Abort(.unauthorized) |
/registration/finish (or /registration/begin) called without a valid session/bearer — raised by PassageGuard middleware before the handler runs → 401 |
AuthenticationError.unknownPasskey |
Authentication finish: the posted credential ID does not match any record in PasskeyCredentialStore → 401 |
AuthenticationError.discoverableLoginDisabled |
POST /authentication/begin when policy.allowDiscoverableLogin == false. No hinted-flow endpoint exists to fall back to → 400 |
- Account — Password-based authentication (alternative primary credential)
- Passwordless — Magic-link authentication (alternative passwordless flow)
- Tokens — Access and refresh tokens issued after authentication
- Views — UI framework the passkey registration view plugs into