Hi everyone,
I'd like to introduce Passage, an identity management and authentication framework for Vapor applications. It has recently moved under the vapor-community organisation, and I'd love to get feedback from this community before the API settles.
Status: alpha (0.5.x). It's functional and heavily tested, but the API is still subject to change before 1.0. Please don't treat this as "ready, ship it" – treat it as "ready, please tell me what's wrong with it."
The problem it solves
Every server-side project needs the same authentication substrate: registration, login, password hashing, token issuance and refresh, email/phone verification, password reset, OAuth, and – increasingly – passkeys. Every project also gets to reimplement it, and every reimplementation is a fresh opportunity to get token rotation or account linking subtly wrong.
Passage aims to provide that substrate as a Vapor-native library: secure defaults out of the box, and a protocol seam anywhere you need to substitute your own infrastructure.
What it looks like
// configure.swift
app.views.use(.leaf)
app.middleware.use(app.sessions.middleware)
try await app.passage.configure(
services: .init(
store: DatabaseStore(app: app, db: app.db),
emailDelivery: MailgunEmailDelivery(...),
phoneDelivery: nil
),
configuration: .init(
origin: URL(string: "https://example.com")!,
sessions: .init(enabled: true),
jwt: .init(jwks: try .fileFromEnvironment()),
tokens: .init(
issuer: "https://api.example.com",
accessToken: .init(timeToLive: 15 * 60),
refreshToken: .init(timeToLive: 7 * 24 * 3600)
)
)
)
// routes.swift
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))!"
}
That's a working auth backend: routes for register / login / logout / refresh / me, plus server-rendered Leaf pages at /auth/register and /auth/login if you want them. Every route path, TTL, and template is configurable; features that need a service you didn't supply simply don't register their routes.
Standards compliance, not just "secure by default"
This is the part I'd most like scrutiny on.
Passage targets NIST SP 800-63B Authenticator Assurance Level 1 – https://pages.nist.gov/800-63-3-Implementation-Resources/63B/AAL – and ships an executable compliance suite under Tests/PassageTests/AAL1/. Each of those tests cites the specific clause it asserts in its name, so the suite doubles as a machine-checked compliance ledger rather than a prose claim in a README:
- §5.1.1 – memorized secrets (length, composition rules, breach-list behaviour)
- §7.1 – session management
- §4.1.3 / §7.2 – reauthentication
- §5.2.2 – throttling of online guessing attacks
Alongside it, docs/AAL1/ carries the claim statement, a threat model, a conformance matrix, and a revision process for when the interpretation of a clause changes. If you think a clause is being read too generously – or that a passing test doesn't actually demonstrate what it claims – that's exactly the feedback I'm after.
The concrete behaviours that fall out of this: BCrypt password hashing, opaque refresh tokens stored hashed with rotation and family-wide revocation on reuse detection, verification codes hashed before storage, one-shot WebAuthn challenges persisted as SHA-256 rather than plaintext, per-account and per-source login throttling returning 429 with Retry-After, and password reset revoking every refresh token for that user.
Features
- Account – registration, login, logout, current user
- Tokens – JWT access tokens with JWKS, opaque refresh tokens with rotation, one-time exchange codes
- Verification – email and phone codes with configurable length, TTL, and attempt caps
- Restoration – password reset over email or SMS
- Passwordless – email magic links, with optional same-browser enforcement and auto-create-on-first-login
- Passkeys (WebAuthn) – three ceremonies: public guest signup, authenticated "add a passkey", and discoverable sign-in
- Federated login – OAuth/OIDC via Google, GitHub, or custom providers
- Account linking – automatic linking on verified email/phone match, with manual fallback on ambiguity
- Throttling – sliding-window rate limiting per account and per source
- Views – Leaf templates for login, registration, reset, magic link, linking, and passkeys; 4 styles × 17 themes with light/dark variants
- Hooks – async
will*/did*lifecycle callbacks; throw from awill*hook to gate a flow on your own policy (suspended accounts, licence checks, MFA step-up)
Architecture: six protocols, and nothing else
The core Passage target depends only on Vapor, JWT, Leaf, Queues, swift-crypto, swift-log, and NIO. No ORM, no WebAuthn library, no mail SDK. Everything else arrives through six service protocols:
| Protocol | Required? | Companion package |
|---|---|---|
Store |
passage-fluent (Postgres / MySQL / SQLite) | |
EmailDelivery |
optional | passage-mailgun |
PhoneDelivery |
optional | – (bring Twilio / SNS / Vonage) |
FederatedLoginService |
optional | passage-imperial |
PasskeyService |
optional | passage-webauthn (wraps swift-webauthn) |
RandomGenerator / Throttle.Service |
optional | sensible defaults ship in-package |
Only Store is mandatory. Supplying an optional service is what enables the corresponding feature – no separate feature flags to keep in sync.
Two consequences worth calling out:
Passkeys are library-agnostic. PasskeyService is a four-method protocol covering the ceremony boundaries. passage-webauthn implements it over swift-webauthn, but core has no idea that library exists, and swapping it costs one type.
You can keep your existing user table. passage-fluent runs in two modes. Island mode creates its own users/identifiers tables around a built-in model. Overlay mode lets you conform your app's existing Fluent user model to PassageUserModel – any IDValue (UUID, Int, String) – and inject it, so Passage adopts your schema instead of demanding its own. That was the single most-requested capability from early users.
There's also Passage.OnlyForTest.InMemoryStore, shipped as a separate product, so you can integration-test your auth routes without a database.
Testing
~1,500 tests across unit, integration, and the AAL1 conformance suite; CI and Codecov badges are on the README. Swift 6.3, strict concurrency, macOS 13+ / Linux, Vapor 4.121+, MIT licensed.
What I'm looking for
Honest, specific criticism, particularly on:
- API shape before 1.0. The
services/configuration/hookssplit, and whetherConfigurationhas grown past the point where nested initialisers are pleasant to write. - The AAL1 claim. Under-tested clauses, over-generous readings, or clauses I've missed.
- The
Storeprotocol. It's a composite of eight sub-stores. Is that the right granularity for someone implementing a non-Fluent backend? - Missing features. TOTP/MFA and AAL2 are the obvious next frontier – I'd like to know what else is blocking adoption for you.
Links:
- Core:
https://github.com/vapor-community/passage - Examples:
https://github.com/rozd/passage-example - Issues and discussions are open, and PRs are very welcome.
Thanks for reading – and thanks to the Vapor community for taking the project in.