Fluent Authentication and API Tokens

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with fluent 4.13.0 and fluent-sqlite-driver 4.9.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, covered by a VaporTesting suite of eight tests that passed, and the whole service was run on SQLite and driven with curl; the output shown is captured from that run. Nothing was run against PostgreSQL or MySQL, but the code uses only portable Fluent features. Vapor 5 is at 5.0.0-beta.4 and is not released yet; where it matters, that is noted. The page extends the Bookshelf service of the earlier pages of this section (the canonical User model with email, passwordHash and role), the Middleware page explains the onion model that authenticators plug into, and the Actors, Isolation and Sendable page of the Swift Reference covers the Sendable rules mentioned below.

Every password, email and token in the listings and in the captured output is a throwaway test value generated for this page, for a database that existed only while the scratch process ran. Real deployments read secrets from the environment and never log tokens.

Two credentials, two jobs

A typical Bookshelf API has two different kinds of credential, and mixing them up is the root of many weak designs:

  • A password proves who someone is, rarely. It travels once per login, is checked against a slow hash (Bcrypt) and is never stored or logged in plaintext.

  • A token proves that someone logged in recently, on every request. It is random, short lived, revocable, and checked with a fast lookup. Its loss is bounded by its lifetime and by revocation; the loss of a password is not.

Vapor’s Fluent package supplies two protocols that match them: ModelAuthenticatable for a password login (HTTP Basic) and ModelTokenAuthenticatable for bearer tokens. Both build on the Authenticatable and authenticator machinery described on the Authentication page: an authenticator is a middleware that looks at the request and, if the credentials are good, calls req.auth.login(_:). It never rejects anything itself. A separate guard middleware turns "nobody logged in" into a 401. That separation lets several authenticators be stacked on one route group.

Where this sits in the request lifecycle:

  1. The request reaches the router; the middleware registered on the matched route group runs outside in.

  2. The authenticator reads the Authorization header, looks the credential up with Fluent, and on success stores the User (and, for tokens, the token row) in req.auth.

  3. The guard middleware checks req.auth.has(User.self) and throws Abort(.unauthorized) otherwise.

  4. The handler calls req.auth.require(User.self), which throws 401 if no user was stored, and works with a typed value.

  5. The response is encoded. ErrorMiddleware (outermost) turns any thrown Abort into the JSON error body.

sequenceDiagram participant C as Client participant A as Authenticator participant G as Guard middleware participant H as Handler participant DB as Database C->>A: POST /token, Authorization Basic A->>DB: find user by email A->>A: verify Bcrypt hash off the event loop A->>G: req.auth.login(user) G->>H: user is authenticated H->>DB: insert token hash and expiry H-->>C: 200, plaintext token once C->>A: GET /me, Authorization Bearer token A->>DB: find row by SHA-256 of token A->>G: req.auth.login(token and user) G->>H: authenticated H-->>C: 200 profile

Setup

The page needs only Vapor and Fluent with one driver. Crypto (SHA-256) comes with Vapor, which re-exports it.

// Package.swift (dependencies excerpt)
dependencies: [
    .package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
    .package(url: "https://github.com/vapor/fluent.git", from: "4.13.0"),
    .package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.9.0"),
],

Hashing passwords

Never store a password; store a slow, salted hash. Vapor ships Bcrypt (Bcrypt.hash(_:cost:) and Bcrypt.verify(_:created:)), and exposes it through a replaceable service, app.passwords (the Passwords and Crypto page covers hashing in depth):

  • app.passwords.use(.bcrypt) selects Bcrypt with cost 12. That is already the default: Application.init in 4.122.2 calls it, so configuring nothing gives cost 12. Say it explicitly anyway, so the cost is visible in configure.

  • app.passwords.use(.bcrypt(cost: 4)) lowers the cost; 4 is Bcrypt’s minimum and is useful only in tests.

  • req.password.async.hash(_:) and req.password.async.verify(_:created:) run the work on Vapor’s thread pool. Besides the EventLoopFuture versions, Vapor 4.122.2 has async overloads of both (in AsyncPasswordHasher+Concurrency.swift), so an async handler awaits them directly, with no .get(). req.password.hash and req.password.verify are the synchronous versions and block the calling event loop thread.

That last point matters. One Bcrypt operation at cost 12 takes in the order of a quarter of a second of CPU. Each event loop thread serves many connections, so a synchronous verify stalls all of them for that time; a burst of logins is then a cheap denial of service. Use the async variants in anything that runs per request. Note the Bcrypt enum itself has no async API, which is why the listing below calls it only inside verify(password:), a method the protocol requires to be synchronous (see the next section for the consequence).

The stored value (a string starting with $2b$ and the cost) already contains the salt and the cost, so verification needs nothing else, and raising the cost later only affects newly hashed passwords.

ModelAuthenticatable: password login

ModelAuthenticatable is satisfied by a Fluent model with an email-like field, a hash field and a verify method:

public protocol ModelAuthenticatable: Model, Authenticatable {
    static var usernameKey: KeyPath<Self, FieldProperty<Self, String>> { get }
    static var passwordHashKey: KeyPath<Self, FieldProperty<Self, String>> { get }
    func verify(password: String) throws -> Bool
}

The conformance for the canonical Bookshelf User (the email, passwordHash and role fields were defined on the Fluent Models page):

// AuthModels.swift
import Fluent
import Vapor

// 1. Password login: the canonical User already has `email` and `passwordHash`.
extension User: ModelAuthenticatable {
    static let usernameKey: KeyPath<User, FieldProperty<User, String>> = \.$email
    static let passwordHashKey: KeyPath<User, FieldProperty<User, String>> = \.$passwordHash

    func verify(password: String) throws -> Bool {
        try Bcrypt.verify(password, created: self.passwordHash)
    }
}

Two details cost time in practice:

  • The key paths need an explicit type. The docs write static let usernameKey = \User.$email. Under the Swift 6 language mode, a static let initialised from a key path is inferred as any KeyPath<User, FieldProperty<User, String>> & Sendable, which does not match the protocol requirement, and the compiler says that User does not conform (candidate has non-matching type). Writing the type out, as above, fixes it. The same applies to valueKey and userKey below.

  • Case. The built-in lookup compares the username with ==, so Ada@Example.com and ada@example.com are different users in a case-sensitive database. Lowercase the email when you store it (the registration handler below does) and when you look it up.

User.authenticator() returns the middleware. It is a BasicAuthenticator: it reads Authorization: Basic base64(email:password) (parsed into BasicAuthorization, which has username and password), queries the user with .first() and, if verify(password:) returns true, calls req.auth.login(user). If anything fails it simply does nothing, and the guard middleware produces the 401. Because the built-in version is a future-based BasicAuthenticator that calls the synchronous verify(password:), it blocks an event loop for each login, and it answers measurably faster for an unknown email than for a wrong password, which leaks which emails are registered. The page therefore uses its own AsyncBasicAuthenticator, shown below; User.authenticator() is fine for prototypes. The conformance is still needed: it also gives you ModelCredentialsAuthenticatable (form login) for free, as the web section below shows.

AsyncBasicAuthenticator, AsyncBearerAuthenticator and AsyncCredentialsAuthenticator are the async throws counterparts of the future-based protocols; each needs a single method, authenticate(basic:for:), authenticate(bearer:for:) or authenticate(credentials:for:). They are Sendable-friendly structs, which matters for the Swift 6 notes at the end.

ModelTokenAuthenticatable and why the built-in one is not enough

The second protocol is for a token model:

public protocol ModelTokenAuthenticatable: Model, Authenticatable {
    associatedtype User: Model & Authenticatable
    static var valueKey: KeyPath<Self, Field<String>> { get }
    static var userKey: KeyPath<Self, Parent<User>> { get }
    var isValid: Bool { get }
}

UserToken.authenticator() is a BearerAuthenticator. In the 4.13.0 source it does this: read the bearer token, run Token.query(on: db).filter(valueKey == bearer.token).first(), return if there is no row, delete the row and stop if isValid is false, otherwise req.auth.login(token) and load the parent user and req.auth.login(user).

Three consequences follow from reading that code:

  • The value column holds the token itself. The lookup is an equality filter on whatever the token string is, so a token model built from the docs stores plaintext tokens. A database leak, a backup, a read-only SQL injection or a support engineer with a console then hands out working credentials for every logged-in user. Password hashes protect passwords; nothing protects these.

  • Hashing the stored value breaks the built-in authenticator, because it hashes nothing on the way in. You cannot salt a token hash either, since the server must find the row from the presented token alone. The fix is to make the token high-entropy (256 random bits, so brute force is irrelevant, unlike a password), store its SHA-256, and hash the presented value before the lookup. That needs a custom authenticator; the model protocol is still useful for isValid and the typed req.auth.get(UserToken.self).

  • Expiry is only checked when the token is used, and an invalid row is deleted by the request that discovered it. The docs example returns true for isValid, so tokens live forever. Nothing prunes unused rows, nothing revokes a token on demand, and isValid is synchronous, so it cannot consult a database or a cache.

The books written for Vapor 3 and 4 show the plaintext version: a UserToken with a value made from random bytes, returned from a login route and compared with == (Condon ch. 18 and 35 and Patel ch. 10 describe that scheme). It still works, and it is still the version the official documentation teaches, but it is a prototype. The remaining sections build the production version.

A token table that stores only hashes

The page adds one table, user_tokens (a page-local addition; the canonical Bookshelf models are unchanged): a SHA-256 digest, the owner, an expiry, and a revocation timestamp.

// AuthModels.swift (continued)
import Fluent
import Vapor

// 2. A page-local table: one row per issued token, holding only the SHA-256 of the token.
final class UserToken: Model, @unchecked Sendable {
    static let schema = "user_tokens"

    @ID(key: .id) var id: UUID?
    @Field(key: "token_hash") var tokenHash: String
    @Parent(key: "user_id") var user: User
    @Field(key: "expires_at") var expiresAt: Date
    @OptionalField(key: "revoked_at") var revokedAt: Date?
    @Timestamp(key: "created_at", on: .create) var createdAt: Date?

    init() {}
    init(id: UUID? = nil, tokenHash: String, userID: User.IDValue, expiresAt: Date) {
        self.id = id
        self.tokenHash = tokenHash
        self.$user.id = userID
        self.expiresAt = expiresAt
    }
}

extension UserToken: ModelTokenAuthenticatable {
    static let valueKey: KeyPath<UserToken, FieldProperty<UserToken, String>> = \.$tokenHash
    static let userKey: KeyPath<UserToken, ParentProperty<UserToken, User>> = \.$user

    var isValid: Bool { self.revokedAt == nil && self.expiresAt > Date() }
}

struct CreateUserToken: AsyncMigration {
    func prepare(on database: any Database) async throws {
        try await database.schema("user_tokens")
            .id()
            .field("token_hash", .string, .required)
            .field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
            .field("expires_at", .datetime, .required)
            .field("revoked_at", .datetime)
            .field("created_at", .datetime)
            .unique(on: "token_hash")
            .create()
    }

    func revert(on database: any Database) async throws {
        try await database.schema("user_tokens").delete()
    }
}

// 3. What the API returns about a user: never the hash.
extension User {
    struct Public: Content {
        var id: UUID?
        var email: String
        var role: UserRole
    }

    func toPublic() -> Public { Public(id: id, email: email, role: role) }
}

isValid is the single definition of "this token can authenticate": not revoked, not expired. Keeping a revoked_at timestamp rather than deleting rows gives an audit trail ("this token was revoked at 14:02") and makes reuse detection possible. onDelete: .cascade removes a user’s tokens with the user. unique(on: "token_hash") gives the lookup an index and makes an (astronomically unlikely) duplicate fail loudly.

The service that creates and revokes tokens:

// TokenService.swift
import Fluent
import Vapor

struct IssuedToken: Content {
    var token: String
    var expiresAt: Date
}

struct TokenService: Sendable {
    var lifetime: TimeInterval = 60 * 60

    /// SHA-256 of the token, hex encoded. Tokens are 256 random bits, so a fast hash is enough.
    static func hash(_ token: String) -> String {
        SHA256.hash(data: Data(token.utf8)).map { String(format: "%02x", $0) }.joined()
    }

    /// Creates a row and returns the plaintext token, which exists nowhere else.
    func issue(for user: User, on db: any Database) async throws -> IssuedToken {
        let plaintext = "bk_" + [UInt8].random(count: 32).hex
        let expiresAt = Date().addingTimeInterval(lifetime)
        let row = UserToken(tokenHash: Self.hash(plaintext), userID: try user.requireID(), expiresAt: expiresAt)
        try await row.save(on: db)
        return IssuedToken(token: plaintext, expiresAt: expiresAt)
    }

    func revoke(_ token: UserToken, on db: any Database) async throws {
        token.revokedAt = Date()
        try await token.save(on: db)
    }

    /// Revokes every live token of a user. Returns how many were revoked.
    @discardableResult
    func revokeAll(for user: User, on db: any Database) async throws -> Int {
        let live = try await UserToken.query(on: db)
            .filter(\.$user.$id == user.requireID())
            .filter(\.$revokedAt == nil)
            .all()
        for token in live { try await revoke(token, on: db) }
        return live.count
    }

    /// Deletes rows that can no longer authenticate. Run it from a scheduled job.
    func prune(on db: any Database) async throws {
        try await UserToken.query(on: db).filter(\.$expiresAt < Date()).delete()
    }
}

extension Request {
    var tokens: TokenService { TokenService() }
}

Points worth stating:

  • [UInt8].random(count: 32) fills from Swift’s SystemRandomNumberGenerator, which is a cryptographically secure source on every supported platform. 32 bytes is 256 bits; the docs example uses 128 bits, which is also adequate.

  • The bk_ prefix is cosmetic: it makes tokens recognisable to secret scanners and to people reading logs.

  • SHA-256 is the right hash here, not Bcrypt. Bcrypt is deliberately slow to protect low-entropy passwords; a 256-bit random token cannot be guessed, and a fast hash keeps the per-request lookup cheap. Never use this scheme for passwords.

  • The plaintext token is returned once, from issue, and exists nowhere else. A client that loses it logs in again.

  • lifetime defaults to one hour here. Choose it from your threat model; short access tokens plus rotation (below) beat long-lived ones.

  • Request.tokens is a computed property creating a small value, not stored state, so there is nothing to synchronise.

The authenticators

Both authenticators are Sendable structs without stored state, so they compile cleanly under Swift 6:

// Authenticators.swift
import Fluent
import Vapor

/// Basic login that never blocks an event loop and takes about as long for unknown users as for known ones.
struct BookshelfBasicAuthenticator: AsyncBasicAuthenticator {
    // A bcrypt digest at cost 12, the cost of real hashes, of a random value nobody knows. Checking it for an
    // unknown email spends the same time as checking a real password.
    private static let dummyHash = "$2b$12$cwtmiw/PGEeRlFeEtZOLQOyhVsuLH0fbTqlhzH1nD4BVbfc9ALXCK"

    func authenticate(basic: BasicAuthorization, for request: Request) async throws {
        let user = try await User.query(on: request.db)
            .filter(\.$email == basic.username.lowercased())
            .first()
        let digest = user?.passwordHash ?? Self.dummyHash
        let ok = try await request.password.async.verify(basic.password, created: digest)
        if ok, let user { request.auth.login(user) }
    }
}

/// Bearer authentication against stored hashes. Replaces `UserToken.authenticator()`, which compares plaintext.
struct HashedTokenAuthenticator: AsyncBearerAuthenticator {
    func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
        let hash = TokenService.hash(bearer.token)
        guard let token = try await UserToken.query(on: request.db)
            .filter(\.$tokenHash == hash)
            .with(\.$user)
            .first(), token.isValid
        else { return }
        request.auth.login(token)
        request.auth.login(token.user)
    }
}

What each choice buys:

  • try await request.password.async.verify(...) keeps Bcrypt off the event loop.

  • Verifying against a dummy hash when the email is unknown makes the unknown-email and wrong-password paths cost about the same, so response time does not reveal which emails exist. (The dummy is a fixed Bcrypt digest at cost 12, the cost configure uses for real hashes outside tests; if you change that cost, regenerate the dummy at the new cost. Any valid digest works, for example one printed once by Bcrypt.hash(UUID().uuidString). Rate limiting by IP and by account, covered on the Security Hardening page, is still required: this only removes one oracle.)

  • .with(\.$user) loads the owner in the same query flow, so req.auth.login(token.user) does not hit the database again, and the handler gets both the token (to revoke) and the user.

  • If the token is unknown, expired or revoked the authenticator returns without logging anybody in. The guard middleware decides what that means.

  • The bearer authenticator does not extend the token’s life. If you want sliding expiry, update expiresAt here, but each request then writes to the database; rotation (below) achieves the same goal with fewer writes.

Routes, guards and WWW-Authenticate

guardMiddleware(throwing:) takes the error to throw, so the 401 can carry the header RFC 7235 requires, which tells clients which scheme to retry with:

// AuthRoutes.swift
import Fluent
import Vapor

struct RegisterRequest: Content, Validatable {
    var email: String
    var password: String

    static func validations(_ validations: inout Validations) {
        validations.add("email", as: String.self, is: .email)
        validations.add("password", as: String.self, is: .count(12...128))
    }
}

struct AuthController: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let auth = routes.grouped("api", "v1", "auth")
        auth.post("register", use: register)

        // Basic credentials in, a token out.
        let basic = auth.grouped(BookshelfBasicAuthenticator())
            .grouped(User.guardMiddleware(throwing: Abort(
                .unauthorized,
                headers: ["WWW-Authenticate": "Basic realm=\"bookshelf\", charset=\"UTF-8\""],
                reason: "Invalid email or password."
            )))
        basic.post("token", use: issueToken)

        // Everything below needs a live bearer token.
        let bearer = auth.grouped(HashedTokenAuthenticator())
            .grouped(UserToken.guardMiddleware(throwing: Abort(
                .unauthorized,
                headers: ["WWW-Authenticate": "Bearer realm=\"bookshelf\""],
                reason: "Missing, expired or revoked token."
            )))
        bearer.get("me", use: me)
        bearer.post("token", "refresh", use: refresh)
        bearer.post("logout", use: logout)
        bearer.post("logout-all", use: logoutAll)
    }

    @Sendable func register(req: Request) async throws -> Response {
        try RegisterRequest.validate(content: req)
        let input = try req.content.decode(RegisterRequest.self)
        let email = input.email.lowercased()
        let hash = try await req.password.async.hash(input.password)
        let user = User(email: email, passwordHash: hash, role: .reader)   // the role is never client-supplied
        do {
            try await user.save(on: req.db)
        } catch let error as any DatabaseError where error.isConstraintFailure {
            throw Abort(.conflict, reason: "That email is already registered.")
        }
        let response = try await user.toPublic().encodeResponse(status: .created, for: req)
        response.headers.replaceOrAdd(name: .cacheControl, value: "no-store")
        return response
    }

    @Sendable func issueToken(req: Request) async throws -> IssuedToken {
        try await req.tokens.issue(for: req.auth.require(User.self), on: req.db)
    }

    @Sendable func me(req: Request) async throws -> User.Public {
        try req.auth.require(User.self).toPublic()
    }

    /// Rotation: the presented token is revoked and a new one is issued in the same request.
    @Sendable func refresh(req: Request) async throws -> IssuedToken {
        let user = try req.auth.require(User.self)
        let current = try req.auth.require(UserToken.self)
        let next = try await req.tokens.issue(for: user, on: req.db)
        try await req.tokens.revoke(current, on: req.db)
        return next
    }

    @Sendable func logout(req: Request) async throws -> HTTPStatus {
        try await req.tokens.revoke(req.auth.require(UserToken.self), on: req.db)
        return .noContent
    }

    @Sendable func logoutAll(req: Request) async throws -> [String: Int] {
        ["revoked": try await req.tokens.revokeAll(for: req.auth.require(User.self), on: req.db)]
    }
}

Things to notice:

  • Order of registration is order of execution. The authenticator comes before the guard; a guard before its authenticator rejects every request.

  • POST /token uses the Basic authenticator; all other protected routes use the bearer one. A Basic header on /me is ignored, and a bearer header on /token is ignored. Never accept the password on every route.

  • The role is set by the server (.reader), never copied from the request body. Mass assignment of a role field is a classic privilege escalation, so RegisterRequest simply has no such field. The Authorization page covers what to do with roles.

  • A duplicate email is turned into 409, relying on the unique constraint instead of a racy "check then insert". The isConstraintFailure property of DatabaseError is portable across the SQL drivers.

  • Cache-Control: no-store on a response that identifies a user is a cheap safeguard; do the same on the token response.

  • The register response (User.Public) omits passwordHash. The Fluent User model is Codable, so returning it directly would send the hash to the client.

Wiring it up in configure:

import Fluent
import FluentSQLiteDriver
import Vapor

func configure(_ app: Application) async throws {
    app.databases.use(.sqlite(.memory), as: .sqlite)
    app.passwords.use(.bcrypt(cost: app.environment == .testing ? 4 : 12))
    app.migrations.add(CreateBookshelf(), CreateUserToken())
    app.migrations.add(SeedAdmin(environment: app.environment))
    try app.register(collection: AuthController())
    registerWebRoutes(app)
    try await app.autoMigrate()
}

In the testing environment the Bcrypt cost is 4 so a test suite does not spend minutes hashing. The migration list must include CreateUserToken after CreateBookshelf, since the foreign key points at users, and SeedAdmin after both.

Seeding the first administrator

Registration always creates a reader, so the first admin has to come from somewhere else. A seeding migration is the usual answer, but the books' version (Condon ch. 18) seeds an admin with the password password, written in the source. Anyone who reads the repository, or guesses, can then log in as admin on every deployment that forgot to change it. Here the e-mail and password come from the environment, and outside development and testing the migration refuses to run without them:

// SeedAdmin.swift
import Fluent
import Vapor

/// Creates the first administrator from the environment. There is no default password: outside development and
/// testing, missing variables make the migration fail, so the application never starts with a guessable admin.
struct SeedAdmin: AsyncMigration {
    let environment: Environment

    func prepare(on db: any Database) async throws {
        guard let email = Environment.get("BOOKSHELF_ADMIN_EMAIL")?.lowercased(),
              let password = Environment.get("BOOKSHELF_ADMIN_PASSWORD")
        else {
            if environment == .development || environment == .testing { return }
            throw Abort(.internalServerError, reason: "BOOKSHELF_ADMIN_EMAIL and BOOKSHELF_ADMIN_PASSWORD must be set")
        }
        guard password.count >= 12 else {
            throw Abort(.internalServerError, reason: "BOOKSHELF_ADMIN_PASSWORD must have at least 12 characters")
        }
        let hash = try Bcrypt.hash(password)   // cost 12; runs once, while migrating, not per request
        try await User(email: email, passwordHash: hash, role: .admin).save(on: db)
    }

    func revert(on db: any Database) async throws {
        guard let email = Environment.get("BOOKSHELF_ADMIN_EMAIL")?.lowercased() else { return }
        try await User.query(on: db).filter(\.$email == email).filter(\.$role == .admin).delete()
    }
}
  • No default. A missing variable is a migration error, so swift run App migrate (or autoMigrate at boot) fails and the deployment stops with a message naming the variables, instead of starting with a known admin password.

  • Development and tests may skip it. There the migration returns without creating anyone. A migration that has run is recorded and never runs again, so to add the admin later in a development database, revert and re-run it, or create the user by hand.

  • The password is used once. It is hashed with Bcrypt at the default cost and only the hash is stored. Supply it from the secret manager for the first deployment, log in, change it, and remove the variable; the migration never reads it again. Never commit a precomputed Bcrypt hash for seeding either: it is as good as the password against an offline guess.

  • Bcrypt.hash is synchronous, which is acceptable here because it runs once while migrating, not in a request.

Captured from a scratch-package test that boots the application in the production environment with the two variables unset:

error codes.vapor.application: database-id=sqlite error=Abort.500: BOOKSHELF_ADMIN_EMAIL and BOOKSHELF_ADMIN_PASSWORD must be set

Running it

Started with .build/debug/App serve --port 8093 and driven with curl. The registration lowercases the email, validation failures name each field, and a wrong password gets the Basic challenge:

$ curl -si -X POST $B/register -H 'content-type: application/json' \
    -d '{"email":"Ada@Example.com","password":"correct horse battery"}'
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
cache-control: no-store

{"id":"428823DC-92E2-435E-AF30-4C81BDB87C3E","email":"ada@example.com","role":"reader"}

$ curl -si -X POST $B/register -H 'content-type: application/json' -d '{"email":"x","password":"short"}'
HTTP/1.1 400 Bad Request

{"error":true,"reason":"email is not a valid email address, password is less than minimum of 12 character(s)"}

$ curl -si -X POST $B/token -u 'ada@example.com:wrong-password'
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Basic realm="bookshelf", charset="UTF-8"

{"error":true,"reason":"Invalid email or password."}

Logging in, using the token, rotating it and checking that the old one is dead (the token strings are throwaway values from this run):

$ curl -s -X POST $B/token -u 'ada@example.com:correct horse battery'
{"expiresAt":"2026-10-17T17:43:42Z","token":"bk_bbaf688224862310ce3402bae6b52a1bbcc0eb6e413cc7a83847c90534cd251b"}

$ curl -s $B/me -H "Authorization: Bearer $T"
{"role":"reader","id":"428823DC-92E2-435E-AF30-4C81BDB87C3E","email":"ada@example.com"}

$ curl -si $B/me
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="bookshelf"

{"reason":"Missing, expired or revoked token.","error":true}

$ curl -s -X POST $B/token/refresh -H "Authorization: Bearer $T"
{"expiresAt":"2026-10-17T17:43:42Z","token":"bk_f8beaf906a76689dcd25bdf7ae7e42d50ed966cb0e613f6b3b2baf88def7e2ee"}

$ curl -si $B/me -H "Authorization: Bearer $T" | head -1
HTTP/1.1 401 Unauthorized

Expiry, rotation, revocation and logout everywhere

The authenticator enforces expiry on every request, which is the only dependable place. Around it:

  • Rotation (POST /token/refresh): the client trades a still-valid token for a fresh one and the old row is revoked. A stolen token that the thief never rotates dies at its expiry. If the thief rotates it first, the owner’s next request gets 401 and the owner has to log in again, which is the signal that something is wrong. Mobile clients rotate shortly before expiry; browsers use cookies instead (below). For a classic access/refresh pair, keep a longer-lived refresh token in a second table or a kind column and exchange it only at one endpoint. The generic design, and the stateless JWT alternative, are in Access and refresh tokens. The JSON Web Tokens (JWT) page covers signed tokens that need no table lookup, at the cost of harder revocation.

  • Reuse detection: a revoked token presented again is a signal, either a bug or a theft. The page does not implement it, but the schema supports it: look the hash up without the validity filter, and if the row is revoked, call revokeAll(for:) and log the event. Add a family_id column if you want to revoke only the chain a token belongs to.

  • Logout: POST /logout revokes the token that authenticated the request. It does not touch the user’s other devices.

  • Logout everywhere: POST /logout-all revokes every live token of the user (the run above reported {"revoked":3}: the rotated token and two logins). Call revokeAll(for:) from the password-change and password-reset handlers (the reset mail itself is sent as described on the Sending Email page) as well, because a changed password that leaves old tokens alive protects nothing. Do the revoke and the password write in one transaction (see the Transactions, Model Middleware & Soft Delete page).

  • Pruning: expired or revoked rows accumulate. prune(on:) deletes the expired ones; run it from a scheduled job (see Scheduled Jobs and Queues & Background Jobs) or from a LifecycleHandler. Keep revoked-but-unexpired rows if you rely on reuse detection.

  • Server-side secrets: a leaked database does not leak credentials, but anyone who can write to user_tokens can still mint a valid hash of a token they know. Protect write access, and do not log Authorization headers.

One backend for browsers and apps

The Bookshelf serves a web site and iOS/Android apps from one codebase (Patel ch. 10; see also Full-Stack Swift with iOS Clients). The rule is: cookies for browsers, bearer tokens for apps, and never both on the same route group. Browsers send cookies automatically, which makes cookie authentication vulnerable to cross-site request forgery; bearer tokens must be attached by code, so a third-party page cannot make the browser send them. If an API group also accepted the session cookie, it would inherit the CSRF problem.

// WebRoutes.swift
import Fluent
import Vapor

extension User: ModelSessionAuthenticatable {}
extension User: ModelCredentialsAuthenticatable {}

func registerWebRoutes(_ app: Application) {
    // Cookie world: sessions first, then the authenticator that restores the user from the session.
    let web = app.grouped("web")
        .grouped(app.sessions.middleware, User.sessionAuthenticator())

    // The credentials authenticator reads `username` and `password` from a form or JSON body.
    web.grouped(User.credentialsAuthenticator()).post("login") { req async throws -> Response in
        _ = try req.auth.require(User.self)          // 401 when the credentials were wrong
        return req.redirect(to: "/web/profile")
    }

    // Anonymous visitors are sent to the login page instead of receiving a 401.
    web.grouped(User.redirectMiddleware(path: "/web/login")).get("profile") { req async throws -> String in
        "Signed in as \(try req.auth.require(User.self).email)"
    }
}

The session-based pieces here (ModelSessionAuthenticatable, ModelCredentialsAuthenticatable, sessionAuthenticator(), credentialsAuthenticator() and redirectMiddleware(path:)) are the subject of the Session Authentication and Web Login page, which also covers registration forms, logout, rotating the session ID on login (session fixation) and CSRF tokens. Here is what matters for the combination:

  • The web group is app.grouped("web") with the session middleware first and the session authenticator right after it; without the session middleware, req.session does not exist. The API group has neither.

  • One User type is authenticated by either mechanism, so handlers shared by both can use req.auth.require(User.self), and the model code, the validation and the services are reused.

  • The cookie in the run below comes from the default session configuration (vapor-session, not Secure, not HttpOnly); the Sessions and Cookies page shows how to set both flags and the SameSite attribute. Do that before production.

Captured from the same run (a browser-style login, then the cookie against both groups):

$ curl -si -c jar.txt -X POST http://127.0.0.1:8093/web/login -d 'username=ada@example.com&password=correct horse battery'
HTTP/1.1 303 See Other
location: /web/profile
set-cookie: vapor-session=pjW0TQ/0CVi8+1CMeWSelw+BwvsiFwjtqduwRN4Z0Zk=; Expires=Sat, 24 Oct 2026 16:43:51 GMT; Path=/; SameSite=Lax

$ curl -s -b jar.txt http://127.0.0.1:8093/web/profile
Signed in as ada@example.com

$ curl -si -b jar.txt http://127.0.0.1:8093/api/v1/auth/me | head -1
HTTP/1.1 401 Unauthorized

$ curl -si http://127.0.0.1:8093/web/profile
HTTP/1.1 303 See Other
location: /web/login

The session value above is a throwaway from a server that no longer exists. Browser-to-API calls from other origins also need the CORS configuration described on the CORS page, and a bearer-token API consumed by a single-page app should keep the token in memory rather than in localStorage (see the OAuth section’s browser-based apps guidance).

Testing

VaporTesting (see the Testing page) drives the whole stack in-process. These tests (from the suite that passed) cover the happy path, that only the hash is stored, rotation, logout everywhere, expiry and the cookie/token separation:

import Fluent
import Testing
import VaporTesting
@testable import App

@Suite struct AuthTests {
    private func withApp(_ body: (Application) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            try await body(app)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    private func register(_ app: Application, _ email: String, _ password: String) async throws {
        try await app.testing().test(.POST, "api/v1/auth/register", beforeRequest: { req async throws in
            try req.content.encode(RegisterRequest(email: email, password: password))
        }, afterResponse: { res async in
            #expect(res.status == .created)
        })
    }

    private func login(_ app: Application, _ email: String, _ password: String) async throws -> IssuedToken {
        var issued: IssuedToken?
        try await app.testing().test(.POST, "api/v1/auth/token", beforeRequest: { req async in
            req.headers.basicAuthorization = .init(username: email, password: password)
        }, afterResponse: { res async throws in
            #expect(res.status == .ok)
            issued = try res.content.decode(IssuedToken.self)
        })
        return try #require(issued)
    }

    private func status(_ app: Application, _ method: HTTPMethod, _ path: String, token: String?) async throws -> HTTPStatus {
        var result: HTTPStatus = .internalServerError
        try await app.testing().test(method, path, beforeRequest: { req async in
            if let token { req.headers.bearerAuthorization = .init(token: token) }
        }, afterResponse: { res async in result = res.status })
        return result
    }

    @Test func registerLoginMeLogout() async throws {
        try await withApp { app in
            try await register(app, "ada@example.com", "correct horse battery")
            let issued = try await login(app, "ada@example.com", "correct horse battery")
            #expect(issued.token.hasPrefix("bk_"))
            #expect(try await status(app, .GET, "api/v1/auth/me", token: issued.token) == .ok)
            // Only the hash is stored.
            let rows = try await UserToken.query(on: app.db).all()
            #expect(rows.count == 1)
            #expect(rows[0].tokenHash == TokenService.hash(issued.token))
            #expect(rows[0].tokenHash != issued.token)
            #expect(try await status(app, .POST, "api/v1/auth/logout", token: issued.token) == .noContent)
            #expect(try await status(app, .GET, "api/v1/auth/me", token: issued.token) == .unauthorized)
        }
    }

    @Test func badCredentialsAndMissingToken() async throws {
        try await withApp { app in
            try await register(app, "ada@example.com", "correct horse battery")
            try await app.testing().test(.POST, "api/v1/auth/token", beforeRequest: { req async in
                req.headers.basicAuthorization = .init(username: "ada@example.com", password: "wrong")
            }, afterResponse: { res async in
                #expect(res.status == .unauthorized)
                #expect(res.headers.first(name: .wwwAuthenticate)?.hasPrefix("Basic") == true)
            })
            try await app.testing().test(.POST, "api/v1/auth/token", beforeRequest: { req async in
                req.headers.basicAuthorization = .init(username: "nobody@example.com", password: "whatever")
            }, afterResponse: { res async in #expect(res.status == .unauthorized) })
            #expect(try await status(app, .GET, "api/v1/auth/me", token: nil) == .unauthorized)
            #expect(try await status(app, .GET, "api/v1/auth/me", token: "bk_forged") == .unauthorized)
        }
    }

    @Test func refreshRotatesAndLogoutAllRevokes() async throws {
        try await withApp { app in
            try await register(app, "ada@example.com", "correct horse battery")
            let first = try await login(app, "ada@example.com", "correct horse battery")
            var second: IssuedToken?
            try await app.testing().test(.POST, "api/v1/auth/token/refresh", beforeRequest: { req async in
                req.headers.bearerAuthorization = .init(token: first.token)
            }, afterResponse: { res async throws in second = try res.content.decode(IssuedToken.self) })
            let next = try #require(second)
            #expect(next.token != first.token)
            #expect(try await status(app, .GET, "api/v1/auth/me", token: first.token) == .unauthorized)
            #expect(try await status(app, .GET, "api/v1/auth/me", token: next.token) == .ok)
            let other = try await login(app, "ada@example.com", "correct horse battery")
            #expect(try await status(app, .POST, "api/v1/auth/logout-all", token: next.token) == .ok)
            #expect(try await status(app, .GET, "api/v1/auth/me", token: next.token) == .unauthorized)
            #expect(try await status(app, .GET, "api/v1/auth/me", token: other.token) == .unauthorized)
        }
    }

    @Test func expiredTokenIsRejected() async throws {
        try await withApp { app in
            try await register(app, "ada@example.com", "correct horse battery")
            let issued = try await login(app, "ada@example.com", "correct horse battery")
            let row = try #require(try await UserToken.query(on: app.db).first())
            row.expiresAt = Date().addingTimeInterval(-1)
            try await row.save(on: app.db)
            #expect(try await status(app, .GET, "api/v1/auth/me", token: issued.token) == .unauthorized)
            try await TokenService().prune(on: app.db)
            #expect(try await UserToken.query(on: app.db).count() == 0)
        }
    }

    @Test func duplicateAndWeakRegistration() async throws {
        try await withApp { app in
            try await register(app, "ada@example.com", "correct horse battery")
            try await app.testing().test(.POST, "api/v1/auth/register", beforeRequest: { req async throws in
                try req.content.encode(RegisterRequest(email: "ADA@example.com", password: "correct horse battery"))
            }, afterResponse: { res async in #expect(res.status == .conflict) })
            try await app.testing().test(.POST, "api/v1/auth/register", beforeRequest: { req async throws in
                try req.content.encode(RegisterRequest(email: "x@example.com", password: "short"))
            }, afterResponse: { res async in #expect(res.status == .badRequest) })
        }
    }
    @Test func webSessionAndApiTokenOnOneBackend() async throws {
        try await withApp { app in
            try await register(app, "ada@example.com", "correct horse battery")
            // Anonymous: redirected to the login page.
            try await app.testing().test(.GET, "web/profile") { res async in
                #expect(res.status == .seeOther)
                #expect(res.headers.first(name: .location) == "/web/login")
            }
            var cookie = ""
            try await app.testing().test(.POST, "web/login", beforeRequest: { req async throws in
                try req.content.encode(ModelCredentials(username: "ada@example.com", password: "correct horse battery"))
            }, afterResponse: { res async in
                #expect(res.status == .seeOther)
                cookie = res.headers.setCookie?["vapor-session"]?.string ?? ""
            })
            #expect(!cookie.isEmpty)
            try await app.testing().test(.GET, "web/profile", beforeRequest: { req async in
                req.headers.cookie = ["vapor-session": .init(string: cookie)]
            }, afterResponse: { res async in
                #expect(res.status == .ok)
                #expect(res.body.string == "Signed in as ada@example.com")
            })
            // The session cookie does not open the token API.
            try await app.testing().test(.GET, "api/v1/auth/me", beforeRequest: { req async in
                req.headers.cookie = ["vapor-session": .init(string: cookie)]
            }, afterResponse: { res async in #expect(res.status == .unauthorized) })
        }
    }
}

Swift 6 notes

  • The Fluent models are final class X: Model, @unchecked Sendable, as in the earlier pages; the compiler cannot prove that a class with property wrappers is Sendable. Do not capture a User or UserToken in a detached task, and do not keep one across requests.

  • The authenticators and TokenService are structs without mutable stored state, so they are Sendable without annotation and can be shared by every request. Resist adding an in-memory cache of valid tokens as a var: use an actor or the cache service if you need one, and remember a cache makes revocation slower to take effect.

  • Route handlers that are @Sendable func on a RouteCollection struct compile under Swift 6 and avoid capturing self in a non-Sendable closure.

  • req.password.async runs Bcrypt on the thread pool, and its async overloads return the result straight into the async function. Do not call the synchronous Bcrypt.hash in a handler.

Common errors and pitfalls

  • type 'User' does not conform to protocol 'ModelAuthenticatable' with a note about any KeyPath ... & Sendable. Give the key paths an explicit type, as in the listing.

  • 401 for a correct password. Mixed case emails; the Authorization header sent without `Basic `; or the guard registered before the authenticator.

  • 401 with a valid token only after a few hours. The token expired or was revoked; check expires_at and revoked_at.

  • Abort.401 from req.auth.require in a route nobody guarded. The authenticator is not in the route’s group; the middleware applies only to routes registered on the grouped builder.

  • Unique constraint error on registration. Surface it as 409, not 500.

  • password_hash in a JSON response. A model returned directly. Return a DTO such as User.Public.

  • Server stalls during login bursts. Synchronous Bcrypt on the event loop.

  • Everyone logged out after a deployment. Tokens in a store that does not survive restarts (the memory session driver is the usual cause for cookies; tokens in the database survive).

  • The browser shows a username/password dialog. A 401 with WWW-Authenticate: Basic on a route a browser calls. Use the Basic scheme only on the token endpoint, and a form login with a redirect for browsers.

  • Plaintext secrets in logs. Do not log req.headers; Log request IDs and user IDs instead.

Version notes and outdated material

  • The Vapor 3 auth-provider, BasicAuthenticatable, TokenAuthenticatable and PasswordAuthenticatable protocols and the BCryptDigest service are gone. Vapor 4 uses ModelAuthenticatable, ModelTokenAuthenticatable, Bcrypt.hash and app.passwords. The Vapor 4 protocols are in Fluent, not in a separate Authentication package.

  • The books seed an admin whose password, password, is in the source (Condon ch. 18). SeedAdmin above reads the e-mail and password from the environment and fails the migration when they are missing outside development and testing.

  • The books' token flow (login with Basic, UserToken.generate, value compared as plaintext, no expiry, no revocation, isValid returning true) is the official docs' example too. This page replaces it with hashed storage, expiry, rotation and revocation.

  • Older material uses EventLoopFuture-based authenticators (BasicAuthenticator, BearerAuthenticator) and .flatMap; both still exist in 4.122, but new code uses the Async protocols shown here.

  • The 4.122.2 Fluent token and password authenticators are still future-based, with a synchronous verify; the async-friendly pieces are the Async protocols and asyncCredentialsAuthenticator() for credentials login. Treat the built-ins as convenient, not as the hardened option.

  • The official docs show [UInt8].random(count: 16).base64 for tokens (128 bits). This page uses 256 bits as hex, which is URL-safe and header-safe; base64 contains + and / and is easy to mangle in URLs.

  • Vapor 5 (5.0.0-beta.4, not released) is a major version. The authentication protocols and Fluent integration may change (async-first signatures and Sendable requirements are the direction); this page’s code targets 4.122.x. Check the Vapor 5 upgrade notes before moving.