Passage – an identity and authentication framework for Vapor 4

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 1https://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 a will* 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 :white_check_mark: 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:

  1. API shape before 1.0. The services / configuration / hooks split, and whether Configuration has grown past the point where nested initialisers are pleasant to write.
  2. The AAL1 claim. Under-tested clauses, over-generous readings, or clauses I've missed.
  3. The Store protocol. It's a composite of eight sub-stores. Is that the right granularity for someone implementing a non-Fluent backend?
  4. 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.

7 Likes