A comprehensive identity management and authentication framework for Vapor applications built with Swift. Passage provides secure authentication with minimal configuration while remaining highly extensible through protocol-based architecture.
Use with caution. The library is functional, but the API is subject to change before the stable release.
- π User Registration & Login - Complete authentication flow with secure password hashing
- π¦ Rate Limiting - Throttling of failed login attempts per account and per source
- π§ Email Authentication - Email-based identifier with verification codes
- π± Phone Authentication - Phone number identifier with SMS verification (requires custom implementation of
PhoneDeliveryservice) - π€ Username & Password - Traditional username/password authentication
- β¨ Passwordless Magic Links - Email-based passwordless authentication with one-click login
- π« JWT Access Tokens - Stateless authentication with JWKS support
- π Refresh Token Rotation - Secure token refresh with family-based revocation
- π Password Reset Flow - Email and phone-based password recovery
- π OAuth Integration - Federated login (Google, GitHub, custom providers)
- π Passkeys (WebAuthn) - Phishing-resistant public-key credentials with signup, sign-in, and "add passkey" flows β pluggable backend via
PasskeyService - π Account Linking - Link multiple identifiers to a single user account (automatic or manual)
- π Web Forms - Built-in Leaf templates for registration, login, and password reset
- β‘ Async Queue Support - Optional background job processing via Vapor Queues
- π§ Protocol-Based Services - Pluggable storage, email, phone, and OAuth providers
- πͺ Lifecycle Hooks - Async will/did callbacks for custom policy and audit
- π¨ Fully Customizable - Configure routes, tokens, templates, and behavior
Passage targets NIST SP 800-63B Authenticator Assurance Level 1 (AAL1), and ships an executable compliance suite at Tests/PassageTests/AAL1/ that pins behavior to specific spec clauses. Coverage spans memorized secrets (Β§5.1.1), session management (Β§7.1), reauthentication (Β§4.1.3 / Β§7.2), and online-guessing throttling (Β§5.2.2) β every test name cites the clause it asserts, so the suite doubles as a machine-checked compliance ledger.
Add Passage to your Package.swift:
dependencies: [
// π Authentication and user management for Vapor.
.package(url: "https://github.com/vapor-community/passage.git", from: "0.3.0"),
]Then add "Passage" to your target dependencies:
.product(name: "Passage", package: "passage"),Add PassageOnlyForTest only if you want to use the in-memory store for testing:
.product(name: "PassageOnlyForTest", package: "passage"),- Set a custom working directory in your scheme and point it to your project folder.
- Create a JWKS file
keypair.jwksand place it in the root of your project. - Configure Passage in your
configure.swift:
// enable Leaf templating to use Passage's built-in views
app.views.use(.leaf)
// enable sessions middleware
app.middleware.use(app.sessions.middleware)
// Configure Passage with in-memory store for testing
try await app.passage.configure(
services: .init(
store: Passage.OnlyForTest.InMemoryStore(),
emailDelivery: nil,
phoneDelivery: nil,
),
configuration: .init(
origin: URL(string: "http://localhost:8080")!,
sessions: .init(enabled: true),
jwt: .init(
jwks: .file(path: "\(app.directory.workingDirectory)keypair.jwks")
),
views: .init(
register: .init(
style: .minimalism,
theme: .init(
colors: .mintDark
),
identifier: .username
),
login: .init(
style: .minimalism,
theme: .init(
colors: .mintDark
),
identifier: .username
)
)
)
)In your routes.swift file, protect routes using Passage's authenticators and guards:
app
.grouped(PassageSessionAuthenticator())
.grouped(PassageBearerAuthenticator())
.grouped(PassageGuard())
.get("protected") { req async throws -> String in
let user = try req.passage.user
return "Hello, \(String(describing: user.id))!"
}This adds two view endpoints at http://localhost:8080/auth/register and http://localhost:8080/auth/login for user registration and login, as well as a protected route at http://localhost:8080/protected that requires authentication.
Passage is designed for flexibility through:
- Comprehensive Configuration - Customize routes, token TTLs, JWT settings, verification flows, OAuth providers, and web forms
- Protocol-Based Services - Implement your own storage, email delivery, phone delivery, or OAuth providers
- Extensible Forms - Default form types can be replaced with custom implementations via contracts
- Stylable Default Views - Default Leaf views with different styles and themes
- Lifecycle Hooks - Inject async pre/post callbacks around authentication flows; throw from
will*hooks to gate flows on custom policy
Passage exposes six service protocols for pluggable backends. Only Store is required; every other service is optional and unlocks a related feature when provided. Each section below links to DEVELOPER_NOTES.md for protocol signatures, sub-protocol breakdowns, invariants, and integration recipes.
(Required) β persists users, tokens, verification codes, magic links, and passkey records.
passage-fluent β a Fluent-backed DatabaseStore with ready-made migrations for PostgreSQL, MySQL, and SQLite. For tests, use Passage.OnlyForTest.InMemoryStore, which ships in this repo.
Store is a composite that exposes eight sub-stores β one per persistence concern (users, refresh tokens, verification codes, restoration codes, magic-link tokens, exchange tokens, and the two optional passkey stores). It is the one required service because every Passage feature ultimately reads or writes through it.
See DEVELOPER_NOTES.md#store for the full sub-store list, hashing invariants, and the refresh-token rotation chain.
(Optional) β sends verification codes, welcome emails, magic links, and password-reset emails.
passage-mailgun β Mailgun-backed delivery configured with an API key, default domain, and sender identity. For SES, Postmark, Sendgrid, or other providers, conform to Passage.EmailDelivery against the provider SDK directly.
Supplying this service enables the email-side of every feature that sends mail: email verification, email-based password reset, magic-link passwordless login, and welcome emails on registration. Passage hands your implementation fully-constructed URLs, so there's no path construction on your side β template selection and HTML rendering are the only responsibilities.
See DEVELOPER_NOTES.md#email-delivery for the method-by-method surface and the Mailgun integration example.
(Optional) β sends SMS verification codes and password-reset messages.
no companion package ships yet β implement against Twilio, AWS SNS, Vonage, or your SMS gateway of choice.
Supplying this service enables phone-based verification and phone-based password reset. SMS messages carry raw codes rather than URLs, since users on mobile shouldn't need to click links. Message formatting β brand prefix, language, length β is entirely your implementation's choice.
See DEVELOPER_NOTES.md#phone-delivery for the three methods and a Twilio-shaped example.
(Optional) β registers OAuth provider routes and resolves federated identities on callback.
passage-imperial β integrates with the Imperial OAuth library to support GitHub, Google, and custom providers.
Unlike the other services, this one is a "bring a whole subsystem" contract: a single register(...) method that attaches provider routes onto Passage's router group and fires an onSignIn closure when a callback resolves. Passage uses that closure to reconcile against UserStore (linking, account-matching, creating) and to mint the exchange code the client swaps for an access token. See Sources/Passage/Features/FederatedLogin/README.md for the on-the-wire route shape.
See DEVELOPER_NOTES.md#federated-login-service for the protocol signature and the Imperial wiring example.
(Optional) β library-agnostic WebAuthn seam that drives all four passkey ceremony boundaries.
passage-webauthn β wraps swift-webauthn. Relying-party identity and origins are configured on WebAuthnManager.Configuration, not on Passage.Configuration.Passkey.
PasskeyService is the single seam between Passage core and a concrete WebAuthn library β core has zero WebAuthn-library dependencies and talks only through this protocol. Providing a PasskeyService is the one gate that enables every passkey route; Passage additionally needs Store.passkeyCredentials and Store.passkeyChallenges sub-stores to be non-nil. PassageFluent.DatabaseStore will gain these alongside the upcoming passage-fluent model work; Passage.OnlyForTest.InMemoryStore already includes them for tests.
Passage exposes three distinct passkey ceremony flows (public signup, authenticated "add passkey", discoverable sign-in), with one-shot challenge storage, sign-count tracking, and opt-in Leaf templates for signup and sign-in. See the Passkey feature guide for the full route + DTO reference, trust models, and flow diagrams.
See DEVELOPER_NOTES.md#passkey-service for the four-method protocol surface, challenge-lookup invariants, and the WebAuthnPasskeyService integration example.
(Optional) β produces secure random tokens, verification codes, and SHA-256 hashes.
DefaultRandomGenerator ships with Passage and is used unless you override it. Override only if you need a different code format (e.g. numeric-only codes for IVR flows) or stricter cryptographic guarantees.
The default generator emits 32-byte base64 opaque tokens, hex-encoded SHA-256 hashes, and verification codes drawn from a readability-tuned alphabet (ABCDEFGHJKLMNPQRSTUVWXYZ23456789) that eliminates the visual ambiguity of 0/O and 1/I/L. Most apps should leave this service alone.
See DEVELOPER_NOTES.md#random-generator for the protocol surface and a numeric-only override example.
(Optional) β rate-limits failed authentication attempts per account and per source, per NIST SP 800-63B Β§5.2.2.
Passage.Throttle.InMemoryService ships with Passage and is used unless you override it. It's a sliding-window counter implemented as a Swift actor β safe under concurrent login traffic on a single instance. For deployments that run multiple app instances behind a load balancer, supply a shared backend (e.g. Redis-backed) so counters aren't fragmented across nodes; otherwise an attacker can spread attempts across replicas to sidestep per-node caps.
Passage.Throttle.Service has three methods: check(bucket:against:at:) decides allowed vs. throttled, penalize(bucket:at:) records a failed attempt, and reset(bucket:) clears a bucket on successful authentication. Buckets key by (scope, dimension, enabled) where dimension is either .identifier(kind:value:) (per-account) or .source(String) (per IP / forwarded address).
See Sources/Passage/Services/Passage+Throttle.swift for the protocol surface and Sources/Passage/LoginThrottleMiddleware.swift for how it's wired into the login route.
Each feature maps to a directory under Sources/Passage/Features/ and is activated independently by supplying the relevant service (where required) and configuration. Expand a section to see what to wire and where to find a working example.
β user registration, login, logout, and current-user retrieval.
Passage.Configuration(
// ... other config ...
routes: .init(
group: "auth", // Base path for auth routes
register: .init(path: "register"), // POST /auth/register
login: .init(path: "login"), // POST /auth/login
logout: .init(path: "logout"), // POST /auth/logout
currentUser: .init(path: "me", shouldBypassGroup: true) // GET /me
)
)See Sources/Passage/Features/Account/README.md for the full route reference, error table, and flow diagrams.
β rate-limits failed login attempts per account and per source, per NIST SP 800-63B Β§5.2.2.
Uses Passage.Throttle.Service. The default Passage.Throttle.InMemoryService is provided automatically β no setup required for single-instance deployments. See the Services chapter for how to override with a shared backend (Redis, DB) when running multiple app instances.
Passage.Configuration(
// ... other config ...
throttle: .init(
login: .init(
perIdentifier: .init(maxFailures: 10, window: 15 * 60), // 10 failures / 15 min per account
perSource: .init(maxFailures: 20, window: 15 * 60), // 20 failures / 15 min per IP
enabled: true
)
)
)Over-limit requests return 429 Too Many Requests with a Retry-After header (seconds until the oldest in-window attempt ages out). A successful login clears both counters for that account and source (Β§5.2.2-c). Form-validation failures (e.g. missing password) count toward the per-source bucket β the throttle middleware runs ahead of form decoding so malformed requests can't sidestep the counter.
Per-identifier (account) protects against sustained guessing on one account. Per-source protects against credential-stuffing sprays across many accounts. The defaults trip well before Β§5.2.2-b's 100-attempt ceiling while leaving honest users plenty of room for typos.
Configuration: Sources/Passage/Configuration/Configuration+Throttle.swift. Middleware: Sources/Passage/LoginThrottleMiddleware.swift. Per-identifier enforcement lives in the login service at Sources/Passage/Features/Account/Passage+Account.swift.
No dedicated example yet β see rozd/passage-example for the canonical walkthrough.
β email and phone verification codes that confirm identifier ownership after registration.
Requires EmailDelivery for email verification, PhoneDelivery for phone verification β supply either or both. See the Services chapter for integration options.
Passage.Configuration(
// ... other config ...
verification: .init(
email: .init(
codeLength: 6,
codeExpiration: 15 * 60, // 15 minutes
maxAttempts: 3
),
phone: .init(
codeLength: 6,
codeExpiration: 5 * 60, // 5 minutes (shorter for SMS)
maxAttempts: 3
),
useQueues: true // Dispatch send as Vapor Queue jobs
)
)Routes for each channel register only when the corresponding delivery service is provided. useQueues: true dispatches SendEmailCodeJob / SendPhoneCodeJob onto your Vapor Queues setup.
See Sources/Passage/Features/Verification/README.md for the verification flow, route list, and error reference.
No dedicated example yet β see rozd/passage-example for the canonical walkthrough.
β magic-link authentication over email for password-free sign-in.
Requires EmailDelivery. See the Services chapter for integration options.
Passage.Configuration(
// ... other config ...
passwordless: .init(
revokeExistingTokens: true,
emailMagicLink: .email(
useQueues: true,
linkExpiration: 15 * 60, // 15 minutes
maxAttempts: 5,
autoCreateUser: true, // Create user on first successful link
requireSameBrowser: false // Gate verification to the requesting browser
)
)
)See Sources/Passage/Features/Passwordless/README.md for the request/verify flow, same-browser enforcement, and token-security details.
No dedicated example yet β see rozd/passage-example for the canonical walkthrough.
β JWT access tokens, opaque refresh tokens with rotation, and one-time exchange codes.
Passage.Configuration(
// ... other config ...
tokens: .init(
issuer: "https://api.example.com", // JWT `iss` claim
accessToken: .init(timeToLive: 15 * 60), // 15 minutes
refreshToken: .init(timeToLive: 7 * 24 * 3600) // 7 days
),
jwt: .init(
jwks: try .fileFromEnvironment() // JWKS file path from `JWKS_FILE_PATH`
),
routes: .init(
refreshToken: .init(path: "refresh-token"), // POST /auth/refresh-token
exchangeCode: .init(path: "exchange") // POST /auth/exchange
)
)JWKS loading β JWKS.fileFromEnvironment() reads the JWKS payload from the file path in JWKS_FILE_PATH. If you want to load the JWKS payload directly from the JWKS environment variable, use JWKS.environment() instead:
export JWKS_FILE_PATH="/path/to/jwks.json"
# or
export JWKS='{"keys":[...]}'Refresh tokens rotate on each refresh and revoke the entire token family on reuse detection β see the feature guide for the rotation chain.
See Sources/Passage/Features/Tokens/README.md for claim definitions, rotation semantics, and exchange-code usage.
No dedicated example yet β see rozd/passage-example for the canonical walkthrough.
β password reset via email or SMS code, with automatic refresh-token revocation on success.
Requires EmailDelivery for email reset, PhoneDelivery for phone reset β supply either or both. See the Services chapter for integration options.
Passage.Configuration(
// ... other config ...
restoration: .init(
preferredDelivery: .email, // Fallback channel for username / federated logins
email: .init(
codeLength: 6,
codeExpiration: 15 * 60, // 15 minutes
maxAttempts: 3
),
phone: .init(
codeLength: 6,
codeExpiration: 5 * 60, // 5 minutes
maxAttempts: 3
),
useQueues: true // Dispatch send as Vapor Queue jobs
)
)A successful reset hashes the new password with BCrypt and revokes all refresh tokens for the user, forcing re-authentication on every device.
See Sources/Passage/Features/Restoration/README.md for the request/verify flow, token-revocation behavior, and error reference.
No dedicated example yet β see rozd/passage-example for the canonical walkthrough.
β OAuth/OpenID Connect sign-in via Google, GitHub, Apple, or custom providers.
Requires FederatedLoginService. Use passage-imperial for a ready-made Imperial-based implementation:
import PassageImperial
let federatedLogin = ImperialFederatedLoginService(
services: [
.github : GitHub.self,
.named("google") : Google.self,
]
)Passage.Configuration(
// ... other config ...
federatedLogin: .init(
routes: .init(group: "connect"), // Base path: /auth/connect
providers: [
.init(provider: .google), // /auth/connect/google
.init(provider: .github) // /auth/connect/github
],
redirectLocation: "/dashboard" // Post-login redirect target
)
)OAuth callbacks redirect to redirectLocation with an exchange ?code=β¦ that the client swaps for JWT tokens via POST /auth/exchange.
See Sources/Passage/Features/FederatedLogin/README.md for the OAuth flow, callback processing, and FederatedIdentity shape.
β WebAuthn / FIDO2 passkeys with public signup, authenticated "add passkey", and discoverable sign-in flows.
Requires PasskeyService plus the two passkey sub-stores on your Store (passkeyCredentials, passkeyChallenges). Use passage-webauthn for a ready-made swift-webauthn-backed implementation:
import PassageWebAuthn
import WebAuthn
let passkeyService = WebAuthnPasskeyService(
configuration: WebAuthnManager.Configuration(
relyingPartyID: "example.com",
relyingPartyName: "My App",
relyingPartyOrigin: "https://example.com"
)
)Relying-party identity and allowed origin are configured on the service's underlying WebAuthnManager.Configuration, not on Configuration.Passkey.
Passage.Configuration(
// ... other config ...
passkey: .init(
routes: .init(
guestRegistrationBegin: .default, // opt-in: enables public guest signup
guestRegistrationFinish: .default
),
policy: .init(
timeout: .seconds(60),
attestation: .none,
userVerification: .preferred,
supportedAlgorithms: [.ES256, .RS256],
allowDiscoverableLogin: true // Required for the sign-in ceremony
),
challengeTTL: 300 // 5 minutes
)
)The two guestRegistration* routes are opt-in β pass .default to enable public passkey signup, omit them to require all enrollments to go through the authenticated /registration/* flow. The two registration* routes (authenticated enrollment) are always registered behind PassageGuard.
See Sources/Passage/Features/Passkey/README.md for the three ceremony flows, route reference, DTOs, and flow diagrams.
β links OAuth logins to existing user accounts by matching verified email or phone.
Requires FederatedLoginService (linking is triggered from the OAuth callback). See the Federated Login section above for the service wiring.
Passage.Configuration(
// ... other config ...
federatedLogin: .init(
providers: [.google, .github],
accountLinking: .init(
resolution: .automatic(
matchBy: [.email, .phone], // Match only verified identifiers
onAmbiguity: .requestManualSelection // Fall back to manual on multiple matches
),
stateExpiration: 600 // Manual linking flow TTL (seconds)
)
)
)Only verified emails and phones are considered for automatic linking β this prevents account takeover through unverified claims.
See Sources/Passage/Features/Linking/README.md for the automatic vs. manual flows, candidate-matching rules, and state storage.
No dedicated example yet β see rozd/passage-example for the canonical walkthrough.
β server-rendered Leaf templates for login, registration, password reset, magic link, account linking, and passkeys.
Passage.Configuration(
// ... other config ...
views: .init(
login: .init(
style: .material,
theme: .init(colors: .oceanLight),
redirect: .init(onSuccess: "/dashboard"),
identifier: .email
),
register: .init(
style: .material,
theme: .init(colors: .oceanLight),
identifier: .email
),
passwordResetRequest: .init(style: .material, theme: .init(colors: .oceanLight)),
passwordResetConfirm: .init(style: .material, theme: .init(colors: .oceanLight)),
magicLinkRequest: .init(style: .material, theme: .init(colors: .oceanLight)),
magicLinkVerify: .init(
style: .material,
theme: .init(colors: .oceanLight),
redirect: .init(onSuccess: "/dashboard")
),
linkAccountSelect: .init(style: .material, theme: .init(colors: .oceanLight)),
linkAccountVerify: .init(style: .material, theme: .init(colors: .oceanLight))
)
)Four styles ship (.neobrutalism, .neomorphism, .minimalism, .material) and 17 color palettes with light/dark variants. Views are registered only when configured β omit a view key to skip the corresponding GET route.
See Sources/Passage/Features/Views/README.md for the full view list, theme options, and custom-color recipes.
No dedicated example yet β see rozd/passage-example for the canonical walkthrough.
β async lifecycle callbacks fired around authentication flows.
Hooks are an optional fourth parameter on app.passage.configure(...), alongside services, contracts, and configuration:
try await app.passage.configure(
services: .init(/* ... */),
configuration: .init(/* ... */),
hooks: .init(
account: .hook(
didRegisterUser: { user, request in
request.logger.info("registered \(user.id ?? "?")")
},
willLoginUser: { user, request in
guard !user.isSuspended else {
throw Abort(.forbidden, reason: "Account suspended")
}
},
didLoginUser: { user, request in
try? await analytics.track(.login, user: user)
}
)
)
)Hook protocols expose will* / did* pairs around each flow. will* methods are async throws and abort the flow when they throw; did* methods are non-throwing observers that fire after the underlying step has succeeded. All methods have empty default implementations, so a custom hook type only overrides the events it cares about. The .hook(...) factory shown above is convenient for ad-hoc wiring; for richer behavior conform a type to the protocol directly.
Passage.Hooks.Account β Account flows (register / login / logout):
| Hook | Fires⦠|
|---|---|
willRegister |
Before password validation and user creation |
didRegister |
After user creation, before the verification code is dispatched |
willLogin |
After credentials succeed, before the session is established |
didLogin |
After the session is established, before access tokens are issued |
willLogout |
Before the session is cleared |
didLogout |
After the session is cleared, before the refresh token is revoked |
willLogin fires after the password check passes β it is the gate where you enforce post-authentication policy (account suspension, license expiry, forced MFA), not credential validation.
Passage.Hooks.Passkey β Passkey ceremonies (guest registration, authenticated registration, authentication). will* hooks for the finish path fire after the binding / TOCTOU / signature checks, so handlers see only ceremonies that will succeed and audit logs are not attributed to victims on hijack attempts.
| Hook | Fires⦠|
|---|---|
willBeginGuestRegistration |
After identifier-already-registered check, before the WebAuthn service |
didBeginGuestRegistration |
After the challenge is persisted (guest flow) |
willBeginRegistration |
Before the WebAuthn service runs (authenticated flow) |
didBeginRegistration |
After the challenge is persisted (authenticated flow) |
willFinishGuestRegistration |
After the TOCTOU identifier check, before user creation |
willFinishRegistration |
After the bound-user equality check passes (authenticated flow) |
didFinishGuestRegistration |
After the credential is persisted and challenge consumed (guest flow) |
didFinishRegistration |
After the credential is persisted and challenge consumed (authenticated) |
willBeginAuthentication |
After the discoverable-login policy check, before the WebAuthn service |
didBeginAuthentication |
After the authentication challenge is persisted |
willFinishAuthentication |
After signature verification + user resolution, before sign-count update / challenge consumption / login (auth-equivalent of Account.willLogin β gate suspended accounts, MFA step-up, etc.) |
didFinishAuthentication |
After the session is established and the exchange code minted |
Passage.Hooks covers Account and Passkey today and is shaped to grow additional domains over time.
See Sources/Passage/Hooks/Passage+Hooks.swift for the container struct, Sources/Passage/Hooks/Hooks+Account.swift for the Account protocol, and Sources/Passage/Hooks/Hooks+Passkey.swift for the Passkey protocol β each ships default implementations and a .hook(...) factory.
No dedicated example yet β see rozd/passage-example for the canonical walkthrough.