This document describes how to implement each of Passage's service protocols yourself.
The main README gives a high-level overview of which services exist and which are optional. The per-feature guides under Sources/Passage/Features/*/README.md describe HTTP routes, DTOs, and ceremony flows. This file is the missing middle: the protocol surface and invariants a custom backend has to satisfy.
All service types live under the Passage namespace (e.g. Passage.Store, Passage.EmailDelivery) and are wired in via app.passage.configure(services:configuration:).
Protocol: Sources/Passage/Services/Passage+Store.swift
Passage.Store is a composite that exposes eight sub-stores — one per persistence concern. The last two (passkeyCredentials, passkeyChallenges) are optional and default to nil; supply them only when you enable passkeys.
public protocol Store: Sendable {
var users: any UserStore { get }
var tokens: any TokenStore { get }
var verificationCodes: any VerificationCodeStore { get }
var restorationCodes: any RestorationCodeStore { get }
var magicLinkTokens: any MagicLinkTokenStore { get }
var exchangeTokens: any ExchangeTokenStore { get }
var passkeyCredentials: (any PasskeyCredentialStore)? { get } // default nil
var passkeyChallenges: (any PasskeyChallengeStore)? { get } // default nil
}| Sub-store | Responsibility |
|---|---|
UserStore |
User CRUD, identifier lookup, password rotation, account linking (addIdentifier), passwordless-only creators (createWithEmail, createWithPhone). |
TokenStore |
Refresh-token rows with rotation chain (createRefreshToken(..., replacing:)), family revocation (revoke(refreshTokenFamilyStartingFrom:)). |
VerificationCodeStore |
Email + phone verification codes (create/find/invalidate/increment-failed). |
RestorationCodeStore |
Email + phone password-reset codes (same shape as verification). |
MagicLinkTokenStore |
Passwordless email magic-link tokens with optional sessionTokenHash for same-browser enforcement. |
ExchangeTokenStore |
Short-TTL (≈60 s) one-shot codes used by federated login + passkey authentication handoff. |
PasskeyCredentialStore |
W3C credential records (credentialID, COSE public key, sign count, transports, backup state, AAGUID, attestation format). |
PasskeyChallengeStore |
One-shot WebAuthn challenges — store the SHA-256 hash of the raw bytes, never the bytes themselves. |
- Hash, don't store plaintext. Every token and code is persisted as a SHA-256 hash. Refresh tokens, verification codes, reset codes, magic-link tokens, exchange tokens, and passkey challenges all follow this rule. The caller hashes before handing bytes to most sub-stores;
PasskeyChallengeStoreis the one exception — it receives rawPasskeyChallenge.bytesand hashes internally (e.g. via aData.sha256Hexhelper) so that plain challenge bytes never reach the database layer. - One-shot consumption.
ExchangeTokens andStoredPasskeyChallenges must set aconsumedAttimestamp on use and reject subsequent reads. Cleanup methods (cleanupExpiredTokens(before:),cleanupExpiredPasskeyChallenges(before:)) let you run periodic sweeps. - Refresh-token family revocation. Token rotation links the old token's
replacedByfield to the new row. On reuse (i.e. a rotated-away hash is presented again), follow the chain and revoke the entire family viarevoke(refreshTokenFamilyStartingFrom:). - Eager loading. When
UserStore.find(byIdentifier:)is called, load the identifier's user along with its other identifiers if you need account-linking semantics — otherwise downstream features (federated login, linking, passkeys) will issue extra queries per request.
- passage-fluent —
DatabaseStorebacks all eight sub-stores with Fluent models and ships migrations for PostgreSQL, MySQL, and SQLite. The recommended production choice. Passage.OnlyForTest.InMemoryStore— Ships in this repo under thePassageOnlyForTestproduct. Full in-memory implementation of every sub-store; use for tests only.
Protocol: Sources/Passage/Services/Passage+EmailDelivery.swift
public protocol EmailDelivery: Sendable {
func sendEmailVerification(
to email: String,
user: any User,
verificationURL: URL,
verificationCode: String
) async throws
func sendEmailVerificationConfirmation(
to email: String,
user: any User
) async throws
func sendPasswordResetEmail(
to email: String,
user: any User,
passwordResetURL: URL,
passwordResetCode: String
) async throws
func sendWelcomeEmail(
to email: String,
user: any User
) async throws
func sendMagicLinkEmail(
to email: String,
user: (any User)?, // nil for new users on passwordless signup
magicLinkURL: URL
) async throws
}- Passage hands you fully-constructed URLs for verification, reset, and magic-link flows — no path construction on your side.
sendMagicLinkEmailreceives a niluserwhen a brand-new user signs up via magic link with auto-create enabled; template accordingly.- Template selection and HTML rendering are entirely your responsibility. The default HTML templates Passage ships with (under
Resources/EmailTemplates/) are consumed by the built-in Mailgun integration — you can reuse them or replace them. - Delivery is typically dispatched through a Vapor Queue (
SendEmailCodeJob, etc.) whenuseQueues: trueis set in the verification/restoration configuration. Your implementation just needs to send; the job wrapping is handled by Passage.
passage-mailgun — Mailgun-backed implementation. Configure with API key, default domain, and sender identity:
import PassageMailgun
let emailDelivery = MailgunEmailDelivery(
app: app,
configuration: .init(
mailgun: .init(
apiKey: "your-mailgun-api-key",
defaultDomain: .init("mg.example.com", .us)
),
sender: .init(
email: "noreply@mg.example.com",
name: "No Reply"
)
)
)For other providers (SES, Postmark, Sendgrid), implement Passage.EmailDelivery directly against the provider SDK.
Protocol: Sources/Passage/Services/Passage+PhoneDelivery.swift
public protocol PhoneDelivery: Sendable {
func sendPhoneVerification(
to phone: String,
code: String,
user: any User
) async throws
func sendVerificationConfirmation(
to phone: String,
user: any User
) async throws
func sendPasswordResetSMS(
to phone: String,
code: String,
user: any User
) async throws
}- SMS messages carry a raw code, not a URL — authenticators typed on mobile shouldn't require clicking links.
- Message formatting (brand prefix, language, length) is entirely your implementation's job.
- Queue dispatch works the same way as email delivery: set
useQueues: truein the relevant verification/restoration config.
No companion package ships yet. Implement against Twilio, AWS SNS, Vonage, or your SMS gateway of choice:
struct TwilioPhoneDelivery: Passage.PhoneDelivery {
let client: TwilioClient
func sendPhoneVerification(to phone: String, code: String, user: any User) async throws {
try await client.send(to: phone, body: "Your code: \(code)")
}
func sendVerificationConfirmation(to phone: String, user: any User) async throws {
// optional
}
func sendPasswordResetSMS(to phone: String, code: String, user: any User) async throws {
try await client.send(to: phone, body: "Reset code: \(code)")
}
}Protocol: Sources/Passage/Services/Passage+FederatedLoginService.swift
public protocol FederatedLoginService: Sendable {
func register(
router: any RoutesBuilder,
origin: URL,
group: [PathComponent],
config: Passage.Configuration.FederatedLogin,
onSignIn: @escaping @Sendable (
_ request: Request,
_ identity: FederatedIdentity
) async throws -> some AsyncResponseEncodable
) throws
}- The single
register(router:origin:group:config:onSignIn:)method is unusual among the service protocols: it gives the implementation full control to attach provider routes onto Passage's router group. That's why OAuth integration is a "bring a whole subsystem" service rather than a collection of method hooks. - Your implementation is responsible for:
- Registering routes like
/auth/login/:providerand/auth/login/:provider/callback. - Constructing redirect URIs from
origin+group. - Negotiating the OAuth dance (auth code, token exchange, userinfo).
- Normalizing each provider's userinfo payload into
FederatedIdentity(provider, providerUserID, email, optional name).
- Registering routes like
- The
onSignInclosure fires when the callback has resolved the identity. Passage uses this callback to reconcile againstUserStore(linking, account-matching, creating) and to mint the exchange code that the client swaps for an access token.
passage-imperial — Uses the Imperial OAuth library. GitHub, Google, and custom providers are all supported via Imperial's FederatedServiceTokens:
import PassageImperial
try await app.passage.configure(
services: .init(
store: store,
federatedLogin: ImperialFederatedLoginService(
services: [
.github : GitHub.self,
.named("google") : Google.self,
]
)
),
configuration: .init(
origin: URL(string: "https://api.example.com")!,
federatedLogin: .init(
routes: .init(),
providers: [
.github(credentials: .conventional),
.google(credentials: .conventional, scope: ["profile", "email"])
]
)
)
)See Sources/Passage/Features/FederatedLogin/README.md for the on-the-wire route shape and exchange-code handshake.
Protocol: Sources/Passage/Services/Passage+PasskeyService.swift
PasskeyService is the single seam between Passage core and a concrete WebAuthn library. Passage core has zero dependencies on any WebAuthn implementation — it talks only to this protocol.
public protocol PasskeyService: Sendable {
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
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
}- Relying Party identity lives on the service.
relyingPartyID,relyingPartyName, andrelyingPartyOriginare configured on the underlying WebAuthn backend (e.g.WebAuthnManager.Configuration) — not onPassage.Configuration.Passkey.Passage.Configuration.Passkeycontrols policy (timeout, attestation, userVerification, algorithms, discoverable-login toggle), challenge TTL, and route paths only. - Opaque response bodies.
PasskeyBeginResult.bodyis typed asany AsyncResponseEncodable & Sendable. Core encodes it directly into the HTTP response without inspecting it, which is what keeps the WebAuthn types out of core. - Challenge lookup is caller-provided.
finishRegistrationandfinishAuthenticationboth take alookupChallengeclosure the service must invoke with raw bytes extracted fromclientDataJSON. The closure is wired toPasskeyChallengeStore.find(passkeyChallengeMatching:), which hashes the bytes before querying — so the service never sees the hash and the store never sees the plaintext. confirmUnusedfor registration. OnfinishRegistration, the service callsconfirmUnused(credentialID)to enforce that the credential ID hasn't been registered before. This closure forwards directly toswift-webauthn'sconfirmCredentialIDNotRegisteredYet:API when using the reference implementation.- Post-authentication bookkeeping.
PasskeyFinishAuthenticationResult.newSignCountand.credentialBackedUpshould be written back viaPasskeyCredentialStore.updatePasskeyCredentialAfterAuthentication(...). Sign-count regression is an anti-cloning heuristic — log or reject at your discretion.
passage-webauthn wraps swift-webauthn. Configure the Relying Party on WebAuthnManager.Configuration:
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, // must supply passkeyCredentials + passkeyChallenges
passkey: passkeyService
),
configuration: .init(
origin: URL(string: "https://example.com")!,
passkey: .init(
policy: .init(
timeout: .seconds(60),
attestation: .none,
userVerification: .preferred,
supportedAlgorithms: [.ES256, .RS256],
allowDiscoverableLogin: true // required for the sign-in ceremony
)
)
)
)See Sources/Passage/Features/Passkey/README.md for the three ceremony flows (guest registration / authenticated registration / authentication), full route + DTO reference, and flow diagrams.
Protocol: Sources/Passage/Services/Passage+Random.swift
public protocol RandomGenerator: Sendable {
func generateRandomString(count: Int) -> String
func generateOpaqueToken() -> String
func hashOpaqueToken(token: String) -> String
func generateVerificationCode(length: Int) -> String
}DefaultRandomGeneratorships with Passage and is used unless you override. It usesSHA256fromCryptoKitfor hashing and[UInt8].randomfor entropy.- Verification codes use a restricted alphabet —
ABCDEFGHJKLMNPQRSTUVWXYZ23456789— to eliminate the visual ambiguity of0/Oand1/I/L. Keep this alphabet if users will ever type the code manually. - Opaque tokens returned by
generateOpaqueTokenare 32-byte base64 strings — long enough that guessing is not a practical attack.hashOpaqueTokenproduces a lowercase hex SHA-256, which is what the variousTokenStore.find(...Hash:)methods expect. - Override this service only if you need different code formats (e.g. numeric-only codes for IVR flows) or stricter cryptographic guarantees. Most apps should leave the default in place.
struct NumericVerificationCodeGenerator: Passage.RandomGenerator {
func generateRandomString(count: Int) -> String { /* … */ }
func generateOpaqueToken() -> String { /* … */ }
func hashOpaqueToken(token: String) -> String { /* … */ }
func generateVerificationCode(length: Int) -> String {
String((0..<length).map { _ in "0123456789".randomElement()! })
}
}