SecurityKit: Production-Ready RBAC & Token Management, Now Open Source

SecurityKit: Production-Ready RBAC & Token Management, Now Open Source

A modular Swift package that gives Vapor 4 applications a complete authentication and authorization stack β€” one that you'd actually want to ship.


If you've built more than one Vapor application, you've likely written the same authentication code at least twice. A User model. A Token model. A password hashing helper. Middleware to extract the bearer token. The login route. The registration route. The refresh route. A way to attach roles. A way to check permissions.

By the third project, you start copy-pasting. By the fifth, you're maintaining a private fork of your own auth layer that has subtle differences in each codebase. By the time you find a security bug, you have to fix it in seven places β€” and you'll miss two.

This is the problem SecurityKit solves. It's a modular Swift package for Vapor 4 that packages the authentication, authorization, RBAC, and token management concerns into a single, well-tested, extensible library. You install it once, configure it in three lines, and your application gets a security layer that follows current OWASP and NIST guidance out of the box.

This article walks through what SecurityKit is, why it was built the way it was, and what makes it different from rolling your own.

The shape of the API

Before diving into architecture, here's what using SecurityKit looks like end-to-end. This is what motivated every design decision:

// configure.swift
import Vapor
import Fluent
import FluentPostgresDriver
import SecurityKit

public func configure(_ app: Application) async throws {
    app.databases.use(.postgres(/* ... */), as: .psql)

    app.security.configuration = .init(
        tokenLifetimes: .init(access: 30 * 60, refresh: 60 * 60 * 24 * 14),
        passwordPolicy: .init(minLength: 14)
    )
    app.security.useFluent()

    try await app.autoMigrate()
}

Three lines of actual security setup. The configuration, the wiring, the migrations. That's the entire bootstrap.

Routes look like this:

app.post("auth", "register") { req async throws -> TokenResponse in
    let request = try req.content.decode(RegisterRequest.self)
    return try await req.application.security.auth.register(request, on: req.db)
}

app.post("auth", "login") { req async throws -> TokenResponse in
    let request = try req.content.decode(LoginRequest.self)
    return try await req.application.security.auth.login(request, on: req.db)
}

let admin = app.grouped(BearerTokenMiddleware())
              .grouped(PermissionMiddleware("users.delete"))

admin.delete("users", ":id") { req async throws -> HTTPStatus in
    let id = try req.parameters.require("id", as: UUID.self)
    let user = try await req.application.security.users.require(id: id, on: req.db)
    try await req.application.security.users.delete(user, on: req.db)
    return .noContent
}

That's it. No custom middleware to write. No token validation to debug. No password hashing to get subtly wrong. Just routes that express your business intent, with security as a configured cross-cutting concern.

Why another auth package?

The Swift server ecosystem has solid building blocks. Vapor itself provides Authenticatable, ModelTokenAuthenticatable, Bcrypt, and the JWT package. Fluent gives you the data layer. You can absolutely roll your own auth on top of these.

But there's a gap between "primitives exist" and "production-ready system you can drop in." Specifically, no existing package addresses these problems together:

Token storage best practices. Most tutorials store tokens in plaintext. SecurityKit stores SHA-256 hashes; if your database leaks, the active tokens are not usable by an attacker.

Refresh token rotation with reuse detection. When a refresh token is replayed after being consumed, that's a strong signal of theft. SecurityKit revokes all of the user's tokens and emits a token.reuse_detected event. This is the property that makes refresh-token rotation a meaningful defense, not just a ritual.

No user enumeration on login. "Unknown email" and "wrong password" both surface as invalidCredentials to the client. Audit-level distinction stays in the event bus. Many homemade systems leak which one it is via response timing or error messages, enabling attackers to enumerate valid accounts.

Transparent password rehashing. When you bump bcrypt cost from 12 to 14, SecurityKit rehashes each user's password on their next successful login, automatically. No password reset email blast required. Security improves over time without friction.

Versioned, namespaced migrations. Migration files are named Security_V1_CreateUsers, Security_V2_CreateUserPasswords, and so on. They coexist with your application's own migrations without name collisions, and the version prefix makes upgrade paths auditable.

Composable authorization policies. Beyond "this route requires role X," SecurityKit lets you express compound rules: (RequireRole("admin") || RequirePermission("posts.edit.any")) && !RequireRole("suspended"). The operators short-circuit, the policies are testable in isolation, and you can wrap any of them as middleware.

An event bus for auditing. Login successes and failures, role assignments, token revocations, password changes β€” all published on app.security.events for subscribers to consume without coupling to the package internals. Wire it to your logger, your audit table, your SIEM, your Slack β€” all without modifying SecurityKit.

These aren't features you'd think about on day one of building auth. They're features you wish you had on day 800, when something goes wrong in production.

Architecture: four modules, one purpose

SecurityKit is split into four targets, each with a single responsibility. This matters because the right module boundary lets you reuse the right pieces in different contexts.

SecurityCore holds the protocols, DTOs, errors, policies, and the event bus. It has no opinion about storage β€” no Fluent, no database. Just contracts. If you wanted to back SecurityKit with MongoDB, Redis, or an in-memory store for tests, you'd implement the protocols here and you wouldn't have to touch any other module.

SecurityFluent is the concrete implementation on top of Vapor's Fluent ORM. It provides the Fluent models (User, UserPassword, Role, PermissionModel, Token, plus the two pivots), the seven versioned migrations, the services that implement SecurityCore's protocols, the bcrypt hasher, the default token generator, and the bearer token middleware.

SecurityJWT is optional and provides JWT-based authentication as an alternative to opaque tokens. It exists because JWTs and opaque tokens have a genuine trade-off: JWTs are statelessly verifiable (no DB lookup per request) but cannot be revoked until expiration. For microservices or ultra-low-latency reads, JWT wins; for sessions where instant logout matters, opaque tokens win. Real systems often use both β€” a JWT access token combined with an opaque refresh token.

SecurityKit is the umbrella module. It uses @_exported import to re-export everything from the three modules above. Consumers who don't want to think about which symbol lives where just write import SecurityKit and get the whole API. Consumers who want explicit dependency management can import individual modules.

This separation lets the package serve different audiences without bloat: a microservice that just needs to verify JWTs imports SecurityJWT. A monolithic API that uses everything imports SecurityKit. A custom backend on a non-Fluent store imports SecurityCore and implements the protocols themselves.

The security model in detail

A few of SecurityKit's design choices are worth unpacking because they reflect real production lessons.

Passwords live in their own table

User doesn't have a passwordHash column. Instead, there's a separate UserPassword table with a one-to-one relationship to User. Three reasons:

  1. Passwordless accounts. Users authenticated via OAuth, magic links, or passkeys don't have a password. A separate table represents this naturally (no row) instead of forcing a nullable column with semantic ambiguity.

  2. Future password history. When you eventually add "you can't reuse your last 5 passwords" or "passwords expire every 90 days," you need historical records. A separate table is ready for that without schema migrations on User.

  3. Accidental leak prevention. User conforms to Content so it can be returned from handlers. UserPassword deliberately does NOT conform to Content. If a developer ever tries to return UserPassword from a route, the type system blocks it. The hash literally cannot be serialized to a response by accident.

Tokens are opaque, stored as hashes

When you call issue(), the service generates 32 cryptographically random bytes (256 bits of entropy) using SystemRandomNumberGenerator. It base64url-encodes that to a 43-character URL-safe string. That plaintext is returned to the caller β€” once. Then it computes SHA-256 of the plaintext and stores only the hash in the value column.

When a client presents a bearer token on a future request, the middleware hashes the candidate with SHA-256 and does an indexed lookup on the hash column. This is O(log n) and constant-time in the application layer (the comparison happens in the database engine on equal-length hashes).

Why SHA-256 instead of bcrypt for token storage? Because tokens have 256 bits of entropy β€” they're not user-chosen passwords. Bcrypt's slowness exists to make brute-forcing low-entropy inputs infeasible. For high-entropy random bytes, SHA-256 is both secure and fast. Critically, SHA-256 is deterministic; bcrypt is not (each hash has its own salt), which would make O(1) lookup by hash impossible.

The practical result: if your tokens table is stolen β€” a SQL injection, a backup leak, an unauthorized engineer query β€” the attacker gets a list of hashes that don't reverse to anything. No active session is compromised.

Refresh token rotation with reuse detection

When a client uses their refresh token to get new tokens, SecurityKit revokes the consumed refresh token and issues both a fresh access token and a fresh refresh token. The old refresh token is now invalid forever.

If that old refresh token shows up again β€” replayed in another request β€” SecurityKit interprets it as theft. Either the original user reused an old request (rare), or an attacker captured the refresh token (concerning). The conservative response is to revoke every token belonging to that user and emit a token.reuse_detected event. The legitimate user has to log in again, but the attacker is locked out of the rest of the session.

This is the property that makes rotation valuable. Without reuse detection, rotation is theater; you've just made tokens annoyingly short-lived without raising the cost of theft.

Login leaks nothing

If you log in with an email that doesn't exist, SecurityKit throws SecurityError.invalidCredentials. If you log in with a valid email but the wrong password, same error. If your account is deactivated, same error. The HTTP response is identical in all three cases.

Internally, the AuthService knows the difference β€” it publishes the distinct reason on the event bus (unknownEmail, wrongPassword, userInactive) for audit logs. But the network response gives no signal that lets an attacker enumerate which emails are registered.

This isn't paranoia. Account enumeration is the precursor to targeted phishing and password spraying. Bigger sites get scanned for valid accounts constantly; the small ones just get scanned less often. Defaulting to safe leaves you out of the trivial-to-exploit bucket.

Password changes revoke all sessions

When a user changes their password, every active token for that user β€” across every device β€” is revoked. This is the right default for two reasons. If the password change was triggered by a suspected compromise, you want to evict the attacker from any sessions they may have established. If it was just routine hygiene, the user logs back in once per device, which is a small price for the security guarantee.

Some systems make this configurable. SecurityKit treats it as non-negotiable in the default path. If you genuinely need granular control, you can call the lower-level TokenService.revokeAll(for:kind:) with specific kinds.

Composable authorization

A common shortcoming in homemade auth layers is that policies don't compose. You end up with middleware like requireAdmin, requireAdminOrEditor, requireAdminAndPaying, and so on β€” one middleware per combination.

SecurityKit instead defines an AuthorizationPolicy protocol:

public protocol AuthorizationPolicy: Sendable {
    func evaluate(_ req: Request) async throws -> Bool
    var name: String { get }
}

The single requirement returns a Bool and throws only on infrastructure errors. The clean semantics enable composition with operators:

let policy = (RequireRole("admin") || RequirePermission("users.delete"))
          && !RequireRole("suspended")

app.grouped(policy.middleware())
   .delete("users", ":id") { req in /* ... */ }

The && and || operators short-circuit. The ! operator inverts. The composed policy's name renders the structure for debugging: ((RequireRole(admin) || RequirePermission(users.delete)) && !(RequireRole(suspended))).

Custom policies plug in seamlessly:

struct CanEditPost: AuthorizationPolicy {
    let postID: UUID

    func evaluate(_ req: Request) async throws -> Bool {
        let user = try req.auth.require(User.self)
        let post = try await Post.find(postID, on: req.db)
        return post?.authorID == user.id
            || (try await user.permissions(on: req.db))
                .contains(Permission("posts.edit.any"))
    }
}

app.patch("posts", ":id") { req async throws -> Post in
    let postID = try req.parameters.require("id", as: UUID.self)
    try await req.security.require(CanEditPost(postID: postID) || RequireRole("admin"))
    // ...
}

The same CanEditPost policy works as middleware (.middleware()), inline in a handler (req.security.require(...)), or composed with built-in policies. There's no need to learn a separate API for each context.

Events without coupling

SecurityKit publishes events at every meaningful security action: registration, login (success and failure with distinct reasons), logout, role assignment, permission grants, token issuance and revocation, password change, refresh token reuse detection.

Subscribers register against the event bus:

await app.security.events.on("auth.login.failed") { event in
    if case .loginFailed(let email, let ip, let reason) = event {
        app.logger.warning("Failed login: \(email) from \(ip ?? "?") β€” \(reason)")
    }
}

await app.security.events.on(prefix: "token.*") { event in
    metrics.increment("security.\(event.name)")
}

await app.security.events.onAny { event in
    auditDatabase.append(event)
}

The bus is an actor β€” thread-safe by construction. Publishing is fire-and-forget by default (publishDetached) so slow subscribers don't add latency to security-critical paths. Event handlers cannot throw, by contract: an audit failure must never prevent a login from succeeding.

This pattern keeps the package decoupled from your specific logging, metrics, audit, and alerting stack. You can wire SecurityKit into anything that consumes events, including a message queue if you want durable delivery.

What's deliberately not in v0.1.0

Some features are common in auth packages but are intentionally out of scope for the first release, either because they belong to a different layer or because they need design work that's better done with real-world feedback.

OAuth2 / OpenID Connect provider. SecurityKit handles password and token auth, not federated identity. Consumers who need OAuth integration can pair SecurityKit with packages like vapor/oauth or implement the provider layer separately.

WebAuthn / passkeys. Passwordless authentication is on the roadmap but requires careful design around the credential storage model and ceremony APIs. Adding it in v0.2 will likely involve a new Credential model alongside UserPassword.

MFA / TOTP. Same story β€” designed but not yet implemented. The architectural hook is already there in the form of TokenKind.oneTime, which exists in the type system from day one but isn't yet wired into a TOTP flow.

Login throttling implementation. The configuration exists (SecurityConfiguration.LoginThrottle) but the service that enforces rate limits isn't built. The reason is honest: rate limiting belongs in front of the application (a CDN, an API gateway, an explicit middleware) and SecurityKit's role is to expose the policy clearly. A future commit will add an in-process implementation as a convenience.

Audit log target. SecurityKit emits events on the bus; persistence is the consumer's choice. A reference AuditLogger that writes to a Fluent table is plausible for v0.2.

HIBP breach checking. The configuration flag exists but the actual check is a consumer concern, because it requires either an HTTP call to Have I Been Pwned or a local copy of the password hash database. Both are reasonable choices; SecurityKit shouldn't pick for you.

Being explicit about what's not yet built matters. A README that promises everything and delivers half of it generates issues from confused users. The current SecurityKit README is honest about scope; the v0.1.0 surface is exactly what works.

Getting started

The package is on GitHub at GitHub - devswiftzone/Security Β· GitHub and tagged at v0.1.0. Installation is a single dependency line in your Package.swift:

.package(url: "https://github.com/devswiftzone/Security.git", from: "0.1.0")

Then add SecurityKit to your target's dependencies, call app.security.useFluent() in configure(_:), run the migrations, and you have a complete auth and authorization stack.

The full quick-start, including routes, RBAC seeding, custom policies, event subscriptions, and the optional JWT integration, lives in the README at the repository root.

Contributions and feedback

SecurityKit is open source under the MIT license. Issues and pull requests are welcome at Issues Β· devswiftzone/Security Β· GitHub. For substantial changes, opening a discussion first is encouraged so the design conversation happens before the implementation.

Security vulnerabilities should be reported privately rather than as public issues. The repository's security policy outlines the disclosure process.

Feedback from real production use is the most valuable input. The roadmap β€” login throttling, OAuth2 integration, passkeys, MFA, audit log targets β€” will be prioritized based on what consumers actually need first.

Closing

There's a quiet relief in not having to write authentication for the seventh time. SecurityKit exists so that the security layer of your Vapor application can be a configured concern rather than a hand-built one β€” and so that the security properties you ship can match what current best practices actually recommend, not what was reasonable in 2014.

If you build something with it, I'd love to hear about it. And if you find a bug or a rough edge, opening an issue is the fastest path to a fix.


SecurityKit is built by Asiel Cabrera at devswiftzone. The package is available at GitHub - devswiftzone/Security Β· GitHub under the MIT license.

6 Likes

Are you considering adding support for Hummingbird?

I don't use Hummingbird, but yes, I can work on giving support, Adelas of the other stacks swift-server-side.

That's a great question. We'd definitely be open to it. The SDK is already modular enough to support it β€” similar to how SecurtiyVapor provides a VaporHTTPClient adapter wrapping Vapor's client.
To add Hummingbird support, we'd create a new module (e.g., SecurityHummingbird) with:

  • A HummingbirdHTTPClient conforming to HTTPClientProtocol from SecurityCore
  • An extension on Hummingbird's Application for easy setup (like Application.resend)
    The heavy lifting is already done, so the adapter layer is minimal β€” likely ~50 lines.
    Would you be interested in contributing this? Happy to provide guidance on the implementation.
1 Like

Thank you for considering Hummingbird support! I don’t have time to contribute right now, but I’ll definitely consider doing so once I’m free.

Funnily enough Hummingbird released its own composable Authorization Policy middleware just yesterday if that is what you are looking for.

2 Likes