vapor-community/passage
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.
Status: Alpha
Use with caution. The library is functional, but the API is subject to change before the stable release.
Features
- π 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
- π¨ Fully Customizable - Configure routes, tokens, templates, and behavior
Standards Compliance
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.
Getting Started
Installation
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"),Basic Setup
- 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
)
)
)
)Example Usage
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.
Customization
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
Services to Implement
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](./DEVELOPER_NOTES.md) for protocol signatures, sub-protocol breakdowns, invariants, and integration recipes.
<details>
<summary><h3>ποΈ Store</h3> (Required) β persists users, tokens, verification codes, magic links, and passkey records.</summary>
#### Recommended implementation:
[passage-fluent](https://github.com/rozd/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.
#### Implementation guide:
See [DEVELOPER_NOTES.md#store](./DEVELOPER_NOTES.md#store) for the full sub-store list, hashing invariants, and the refresh-token rotation chain.
</details>
<details>
<summary><h3>π§ EmailDelivery</h3> (Optional) β sends verification codes, welcome emails, magic links, and password-reset emails.</summary>
#### Recommended implementation:
[passage-mailgun](https://github.com/rozd/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.
#### Implementation guide:
See [DEVELOPER_NOTES.md#email-delivery](./DEVELOPER_NOTES.md#email-delivery) for the method-by-method surface and the Mailgun integration example.
</details>
<details>
<summary><h3>π± PhoneDelivery</h3> (Optional) β sends SMS verification codes and password-reset messages.</summary>
#### Recommended implementation:
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.
#### Implementation guide:
See [DEVELOPER_NOTES.md#phone-delivery](./DEVELOPER_NOTES.md#phone-delivery) for the three methods and a Twilio-shaped example.
</details>
<details>
<summary><h3>π FederatedLoginService</h3> (Optional) β registers OAuth provider routes and resolves federated identities on callback.</summary>
#### Recommended implementation:
[passage-imperial](https://github.com/rozd/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`](./Sources/Passage/Features/FederatedLogin/README.md) for the on-the-wire route shape.
#### Implementation guide:
See [DEVELOPER_NOTES.md#federated-login-service](./DEVELOPER_NOTES.md#federated-login-service) for the protocol signature and the Imperial wiring example.
</details>
<details>
<summary><h3>π PasskeyService</h3> (Optional) β library-agnostic WebAuthn seam that drives all four passkey ceremony boundaries.</summary>
#### Recommended implementation:
[passage-webauthn](https://github.com/rozd/passage-webauthn) β wraps [swift-webauthn](https://github.com/swift-server/webauthn-swift). 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](./Sources/Passage/Features/Passkey/README.md) for the full route + DTO reference, trust models, and flow diagrams.
#### Implementation guide:
See [DEVELOPER_NOTES.md#passkey-service](./DEVELOPER_NOTES.md#passkey-service) for the four-method protocol surface, challenge-lookup invariants, and the `WebAuthnPasskeyService` integration example.
</details>
<details>
<summary><h3>π² RandomGenerator</h3> (Optional) β produces secure random tokens, verification codes, and SHA-256 hashes.</summary>
#### Recommended implementation:
`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.
#### Implementation guide:
See [DEVELOPER_NOTES.md#random-generator](./DEVELOPER_NOTES.md#random-generator) for the protocol surface and a numeric-only override example.
</details>
<details>
<summary><h3>π¦ Throttle</h3> (Optional) β rate-limits failed authentication attempts per account and per source, per NIST SP 800-63B Β§5.2.2.</summary>
#### Recommended implementation:
`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).
#### Implementation guide:
See [`Sources/Passage/Services/Passage+Throttle.swift`](./Sources/Passage/Services/Passage+Throttle.swift) for the protocol surface and [`Sources/Passage/LoginThrottleMiddleware.swift`](./Sources/Passage/LoginThrottleMiddleware.swift) for how it's wired into the login route.
</details>Feature Discovery
Each feature maps to a directory under [`Sources/Passage/Features/`](./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.
<details>
<summary><h3>π€ Account</h3> β user registration, login, logout, and current-user retrieval.</summary>
#### Configuration
```swift
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
)
)
```
#### Feature guide
See [`Sources/Passage/Features/Account/README.md`](./Sources/Passage/Features/Account/README.md) for the full route reference, error table, and flow diagrams.
#### Example
See [PassageExample in passage-example](https://github.com/rozd/passage-example#passageexample-1).
</details>
<details>
<summary><h3>π¦ Throttling</h3> β rate-limits failed login attempts per account and per source, per NIST SP 800-63B Β§5.2.2.</summary>
#### Service setup
Uses `Passage.Throttle.Service`. The default `Passage.Throttle.InMemoryService` is provided automatically β no setup required for single-instance deployments. See the [Services chapter](#services-to-implement) for how to override with a shared backend (Redis, DB) when running multiple app instances.
#### Configuration
```swift
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.
#### Feature guide
Configuration: [`Sources/Passage/Configuration/Configuration+Throttle.swift`](./Sources/Passage/Configuration/Configuration+Throttle.swift). Middleware: [`Sources/Passage/LoginThrottleMiddleware.swift`](./Sources/Passage/LoginThrottleMiddleware.swift). Per-identifier enforcement lives in the login service at [`Sources/Passage/Features/Account/Passage+Account.swift`](./Sources/Passage/Features/Account/Passage+Account.swift).
#### Example
_No dedicated example yet β see [rozd/passage-example](https://github.com/rozd/passage-example) for the canonical walkthrough._
</details>
<details>
<summary><h3>β
Verification</h3> β email and phone verification codes that confirm identifier ownership after registration.</summary>
#### Service setup
Requires `EmailDelivery` for email verification, `PhoneDelivery` for phone verification β supply either or both. See the [Services chapter](#services-to-implement) for integration options.
#### Configuration
```swift
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.
#### Feature guide
See [`Sources/Passage/Features/Verification/README.md`](./Sources/Passage/Features/Verification/README.md) for the verification flow, route list, and error reference.
#### Example
_No dedicated example yet β see [rozd/passage-example](https://github.com/rozd/passage-example) for the canonical walkthrough._
</details>
<details>
<summary><h3>πͺ Passwordless</h3> β magic-link authentication over email for password-free sign-in.</summary>
#### Service setup
Requires `EmailDelivery`. See the [Services chapter](#services-to-implement) for integration options.
#### Configuration
```swift
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
)
)
)
```
#### Feature guide
See [`Sources/Passage/Features/Passwordless/README.md`](./Sources/Passage/Features/Passwordless/README.md) for the request/verify flow, same-browser enforcement, and token-security details.
#### Example
_No dedicated example yet β see [rozd/passage-example](https://github.com/rozd/passage-example) for the canonical walkthrough._
</details>
<details>
<summary><h3>ποΈ Tokens</h3> β JWT access tokens, opaque refresh tokens with rotation, and one-time exchange codes.</summary>
#### Configuration
```swift
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:
```bash
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.
#### Feature guide
See [`Sources/Passage/Features/Tokens/README.md`](./Sources/Passage/Features/Tokens/README.md) for claim definitions, rotation semantics, and exchange-code usage.
#### Example
_No dedicated example yet β see [rozd/passage-example](https://github.com/rozd/passage-example) for the canonical walkthrough._
</details>
<details>
<summary><h3>π Restoration</h3> β password reset via email or SMS code, with automatic refresh-token revocation on success.</summary>
#### Service setup
Requires `EmailDelivery` for email reset, `PhoneDelivery` for phone reset β supply either or both. See the [Services chapter](#services-to-implement) for integration options.
#### Configuration
```swift
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.
#### Feature guide
See [`Sources/Passage/Features/Restoration/README.md`](./Sources/Passage/Features/Restoration/README.md) for the request/verify flow, token-revocation behavior, and error reference.
#### Example
_No dedicated example yet β see [rozd/passage-example](https://github.com/rozd/passage-example) for the canonical walkthrough._
</details>
<details>
<summary><h3>π Federated Login</h3> β OAuth/OpenID Connect sign-in via Google, GitHub, Apple, or custom providers.</summary>
#### Service setup
Requires `FederatedLoginService`. Use [passage-imperial](https://github.com/rozd/passage-imperial) for a ready-made Imperial-based implementation:
```swift
import PassageImperial
let federatedLogin = ImperialFederatedLoginService(
services: [
.github : GitHub.self,
.named("google") : Google.self,
]
)
```
#### Configuration
```swift
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`.
#### Feature guide
See [`Sources/Passage/Features/FederatedLogin/README.md`](./Sources/Passage/Features/FederatedLogin/README.md) for the OAuth flow, callback processing, and `FederatedIdentity` shape.
#### Example
See [PassageFederatedLoginExample in passage-example](https://github.com/rozd/passage-example#passagefederatedloginexample).
</details>
<details>
<summary><h3>π Passkey</h3> β WebAuthn / FIDO2 passkeys with public signup, authenticated "add passkey", and discoverable sign-in flows.</summary>
#### Service setup
Requires `PasskeyService` plus the two passkey sub-stores on your `Store` (`passkeyCredentials`, `passkeyChallenges`). Use [passage-webauthn](https://github.com/rozd/passage-webauthn) for a ready-made `swift-webauthn`-backed implementation:
```swift
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`.
#### Configuration
```swift
Passage.Configuration(
// ... other config ...
passkey: .init(
policy: .init(
timeout: .seconds(60),
attestation: .none,
userVerification: .preferred,
supportedAlgorithms: [.ES256, .RS256],
allowDiscoverableLogin: true // Required for the sign-in ceremony
),
challengeTTL: 300 // 5 minutes
)
)
```
#### Feature guide
See [`Sources/Passage/Features/Passkey/README.md`](./Sources/Passage/Features/Passkey/README.md) for the three ceremony flows, route reference, DTOs, and flow diagrams.
#### Example
See [PassagePasskeyExample in passage-example](https://github.com/rozd/passage-example#passagepasskeyexample).
</details>
<details>
<summary><h3>π Linking</h3> β links OAuth logins to existing user accounts by matching verified email or phone.</summary>
#### Service setup
Requires `FederatedLoginService` (linking is triggered from the OAuth callback). See the Federated Login section above for the service wiring.
#### Configuration
```swift
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.
#### Feature guide
See [`Sources/Passage/Features/Linking/README.md`](./Sources/Passage/Features/Linking/README.md) for the automatic vs. manual flows, candidate-matching rules, and state storage.
#### Example
_No dedicated example yet β see [rozd/passage-example](https://github.com/rozd/passage-example) for the canonical walkthrough._
</details>
<details>
<summary><h3>π Views</h3> β server-rendered Leaf templates for login, registration, password reset, magic link, account linking, and passkeys.</summary>
#### Configuration
```swift
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.
#### Feature guide
See [`Sources/Passage/Features/Views/README.md`](./Sources/Passage/Features/Views/README.md) for the full view list, theme options, and custom-color recipes.
#### Example
_No dedicated example yet β see [rozd/passage-example](https://github.com/rozd/passage-example) for the canonical walkthrough._
</details>Package Metadata
Repository: vapor-community/passage
Default branch: main
README: README.md