A modular, reusable security package for Vapor 4 applications. Provides RBAC (Role-Based Access Control), authentication, authorization, and token management — all accessible through app.security.* and req.security.*.
- 🔐 RBAC — Users, roles, permissions with N–M relationships
- 🔑 Authentication — Email/password login with bcrypt (Argon2 pluggable)
- 🎫 Token management — Opaque tokens with SHA-256 storage, expiration, revocation, and rotation
- 🛡️ Authorization — Composable policies with
&&,||,!operators - 📡 Event bus — Subscribe to login, logout, role changes, and more
- 🪪 Optional JWT — Stateless tokens via
SecurityJWTfor microservices - 📦 Modular — Use only what you need
- 🗄️ Versioned migrations — Safe upgrades across package versions
- ⚡ Vapor-native — Integrates via
app.security.*andreq.security.*
- Swift 6.0+
- Vapor 4.92+
- Fluent 4.9+ (for the Fluent backend)
- macOS 13+ / Linux
- A Fluent-compatible database driver (PostgreSQL recommended for production)
Add to your Package.swift:
dependencies: [
.package(url: "https://github.com/devswiftzone/Security.git", from: "0.1.0")
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "SecurityKit", package: "Security"),
]
)
]import Vapor
import Fluent
import FluentPostgresDriver
import SecurityKit
public func configure(_ app: Application) async throws {
// Database
app.databases.use(.postgres(/* ... */), as: .psql)
// Security setup — sensible defaults, customize via configuration
app.security.configuration = .init(
tokenLifetimes: .init(
access: 30 * 60, // 30 min
refresh: 60 * 60 * 24 * 14 // 14 days
),
passwordPolicy: .init(minLength: 14)
)
app.security.useFluent()
try await app.autoMigrate()
}import Vapor
import SecurityKit
func routes(_ app: Application) throws {
// Public
app.post("auth", "register") { req async throws -> TokenResponse in
let dto = try req.content.decode(RegisterRequest.self)
return try await req.application.security.auth.register(dto, on: req.db)
}
app.post("auth", "login") { req async throws -> TokenResponse in
let dto = try req.content.decode(LoginRequest.self)
return try await req.application.security.auth.login(dto, on: req.db)
}
app.post("auth", "refresh") { req async throws -> TokenResponse in
let dto = try req.content.decode(RefreshRequest.self)
return try await req.application.security.auth.refresh(dto, on: req.db)
}
// Protected (any authenticated user)
let authed = app.grouped(BearerTokenMiddleware())
authed.get("me") { req async throws -> User in
try req.security.require(User.self)
}
authed.post("auth", "logout") { req async throws -> HTTPStatus in
let user = try req.security.require(User.self)
try await req.application.security.auth.logout(user, on: req.db)
return .noContent
}
// Permission-gated
let admin = authed.grouped(PermissionMiddleware("users.delete"))
admin.delete("users", ":id") { req async throws -> HTTPStatus in
guard let id = req.parameters.get("id", as: UUID.self) else {
throw Abort(.badRequest)
}
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
}
}let admin = try await app.security.roles.create(
name: "admin",
description: "Full access",
on: app.db
)
try await app.security.permissions.register([
"users.read",
"users.write",
"users.delete",
], on: app.db)
try await app.security.roles.grant("users.delete", to: admin, on: app.db)
// Attach the role to a user
let user = try await app.security.users.require(email: "asiel@example.com", on: app.db)
try await app.security.roles.attach(admin, to: user, on: app.db)| Module | Description |
|---|---|
SecurityKit |
Umbrella — re-exports all modules below |
SecurityCore |
Protocols, DTOs, errors, policies, event bus (storage-agnostic) |
SecurityFluent |
Fluent backend: models, migrations, services, middleware |
SecurityJWT |
Optional JWT-based auth (stateless tokens) |
Import the umbrella for everything, or pick individual modules to keep binaries lean.
app.get("posts", ":id") { req async throws -> Post in
try await req.security.require(permission: "posts.read")
// ...
}
// Non-throwing branching
app.get("dashboard") { req async throws -> View in
if await req.security.can("admin.access") {
return try await renderAdminDashboard(req)
}
return try await renderUserDashboard(req)
}let policy = (RequireRole("admin") || RequirePermission("users.edit"))
&& !RequireRole("suspended")
app.grouped(policy.middleware())
.patch("users", ":id") { req in /* ... */ }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))
// ...
}Subscribe to security events for auditing, metrics, or notifications:
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)")
}
}
// Or subscribe to a whole namespace
await app.security.events.on(prefix: "token.*") { event in
app.logger.info("\(event.name)")
}
// All events
await app.security.events.onAny { event in
auditWriter.write(event)
}Available events: user.registered, user.activated, user.deactivated, user.deleted, auth.login.succeeded, auth.login.failed, auth.logout.all, password.changed, password.reset.requested, token.issued, token.revoked, token.reuse_detected, role.assigned, role.revoked, permission.granted, permission.revoked.
All options are tunable via SecurityConfiguration:
app.security.configuration = .init(
tokenLifetimes: .init(
access: 30 * 60,
refresh: 60 * 60 * 24 * 14,
api: 60 * 60 * 24 * 365,
oneTime: 60 * 15
),
passwordPolicy: .init(
minLength: 14,
maxLength: 128,
requireMixedCase: false,
requireDigit: false,
requireSymbol: false
),
refreshRotation: .init(
enabled: true,
detectReuse: true
),
loginThrottle: .init(
enabled: true,
maxAttempts: 5,
window: 60 * 15,
lockoutDuration: 60 * 15
),
bcryptCost: 12
)Defaults follow current OWASP and NIST SP 800-63B guidance (length over composition for passwords; refresh token rotation with reuse detection).
struct Argon2Hasher: SecurityPasswordHasher {
let algorithm = "argon2id"
func hash(_ password: String) throws -> String { /* ... */ }
func verify(_ password: String, against hash: String) throws -> Bool { /* ... */ }
func needsRehash(_ hash: String) -> Bool { /* ... */ }
}
app.security.useFluent(passwordHasher: Argon2Hasher())For microservices or stateless validation, use SecurityJWT alongside SecurityFluent:
import SecurityKit // already includes SecurityJWT
await app.security.useJWT(hmacSecret: "your-very-long-secret-at-least-32-bytes")
// Issue a JWT after password auth
app.post("auth", "jwt-login") { req async throws -> [String: String] in
let dto = try req.content.decode(LoginRequest.self)
let user = try await req.application.security.users.require(email: dto.email, on: req.db)
let matches = try await user.verifyPassword(
dto.password,
using: req.application.security.passwordHasher,
on: req.db
)
guard matches else { throw SecurityError.invalidCredentials }
let roles = try await user.roleNames(on: req.db)
let jwt = req.application.security.jwt(issuer: "api.example.com")
let token = try await jwt.issue(
userID: user.id!,
email: user.email,
kind: .access,
roles: Array(roles)
)
return ["token": token]
}JWT vs. opaque tokens trade-off: JWTs validate without DB lookups (faster, statelessly verifiable across services) but cannot be revoked until expiry. Use short TTLs (15–30 min) for access tokens. Combine with opaque refresh tokens from SecurityFluent for the best of both worlds.
All tables are prefixed security_ to avoid collisions with consumer schemas:
security_userssecurity_user_passwordssecurity_rolessecurity_permissionssecurity_user_rolessecurity_role_permissionssecurity_tokens
Migrations are versioned (Security_V1_CreateUsers, etc.) and applied via:
app.security.migrations.add(to: app.migrations)
// or
app.security.useFluent() // registers them automatically- Passwords are hashed with bcrypt (cost 12 by default). The
algorithmis stored alongside the hash to enable transparent migration to newer algorithms (Argon2id) on the next successful login. - Tokens are 256-bit CSPRNG values, stored as SHA-256 hashes in the DB. The plaintext is returned to the client only at issuance. A stolen DB dump yields no usable tokens.
- Refresh tokens rotate on use. Replaying a consumed refresh token triggers reuse detection: all of the user's tokens are revoked and a
token.reuse_detectedevent is emitted. - Login failures report a single
invalidCredentialserror to clients regardless of cause (unknown email vs. wrong password) to prevent user enumeration. Audit-level reasons go to the event bus. - Password changes require the current password (defense against stolen session tokens) and revoke all of the user's active tokens.
git clone https://github.com/devswiftzone/Security.git
cd Security
swift testThe test suite runs in-memory with SQLite (no setup required) and uses bcrypt cost 4 to stay fast.
MIT — see LICENSE.
Built by @asielcabrera at devswiftzone.