Authentication

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 swift-nio 2.104.0, checked on 2026-10-17. It needs nothing beyond the Vapor product: authentication is part of Vapor itself. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, exercised by a VaporTesting suite (Swift Testing) and by curl against the running service, and the output shown is captured from those runs. Vapor 5 is at 5.0.0-beta.4 and is not released; the main branch of Vapor changes the shape of these protocols, and the differences are collected in Version notes and outdated material. The page extends the Bookshelf service of the earlier pages of this section. To keep the mechanics visible, the user store here is an in-memory actor and the user type a plain struct called Account; the next pages of this section of the guide replace it with the Fluent User and a token model (Fluent Authentication and API Tokens), and with session logins (Session Authentication and Web Login), JWT (JSON Web Tokens (JWT)) and Authorization. Swift itself is covered by the Swift Reference, and async functions, Sendable and actors by Async/Await and Tasks and Actors, Isolation and Sendable.

Authentication, authorization and what Vapor provides

Authentication answers "who is calling?"; authorization answers "what may they do?". Vapor’s authentication API is small and deliberately separates the two steps that other frameworks fuse:

  • an authenticator looks at the request (a header, a body, a session) and, if the credentials are valid, stores the authenticated value in a per-request cache, req.auth;

  • a guard (or the handler itself) then checks the cache and rejects the request when nobody is logged in.

An authenticator does not reject a request whose credentials are wrong or missing. It leaves req.auth empty and passes the request on. That is what allows several authenticators (password, token, API key, session) to be stacked on one route group and to coexist with routes that welcome anonymous callers. The rejection is a separate, explicit step.

Everything is built from the pieces described below:

Piece Role

Authenticatable

Marker protocol with no requirements. Any type you want to store in req.auth conforms to it: a struct, a class or a Fluent model.

req.auth (Request.Authentication)

Per-request cache keyed by type: login, get, has, require, logout.

AsyncBasicAuthenticator, AsyncBearerAuthenticator, AsyncCredentialsAuthenticator

Protocols that parse an Authorization: Basic, an Authorization: Bearer header or a decoded body, and call your authenticate method. Each is itself an AsyncMiddleware.

AsyncRequestAuthenticator

The protocol underneath them: one method, authenticate(request:), for any other scheme (an API key header, a client certificate subject).

GuardMiddleware (User.guardMiddleware())

Throws 401 unless the type is in req.auth.

RedirectMiddleware (User.redirectMiddleware(path:))

Redirects to a login page unless the type is in req.auth; for browser routes.

Session authenticators

Restore the user from a session cookie; covered by the Sessions page and Session Authentication and Web Login.

Where it sits in the request lifecycle

Authenticators and guards are ordinary middleware, so they follow the rules of the Middleware page: they run after the server has parsed the HTTP request and built the Request, after the router has chosen the route, and in the order in which they were added to the group, outermost first. A typical protected route looks like this from the outside in: global middleware (ErrorMiddleware, logging, CORS), then the group’s authenticators, then the guard, then any role guard, then the handler. The handler’s returned value is encoded into the Response on the way out, and an error thrown by the guard is turned into a 401 response by the nearest ErrorMiddleware further out (see Error Handling).

flowchart TD A["Request with Authorization header, body or session cookie"] --> B["Authenticator 1 (Basic)"] B --> C["Authenticator 2 (Bearer)"] C --> D["Authenticator 3 (other scheme)"] D --> E{"Is the type in req.auth?"} B -. "valid credentials: req.auth.login" .-> S[("req.auth cache for this request")] C -. "valid credentials: req.auth.login" .-> S D -. "valid credentials: req.auth.login" .-> S E -- "no: guard throws Abort 401" --> X["ErrorMiddleware renders 401"] E -- "yes" --> F{"Role or permission guard"} F -- "not allowed: Abort 403" --> Y["ErrorMiddleware renders 403"] F -- "allowed" --> G["Route handler: req.auth.require(Account.self)"] G --> H["Response encoded and sent"]

Three consequences follow from this position:

  • The cache is per request. req.auth.login(account) makes the account visible to the rest of this request only; nothing is written to a cookie or a database. A client stays "logged in" only because it sends credentials (or a token, or a session cookie) again on every request.

  • Order matters, because middleware run in the order given. Authenticators must come before the guard that checks their result.

  • A route outside any group that contains an authenticator never sees the account, even when the client sent valid credentials. Authentication is opt-in per route group.

Authenticatable and req.auth

Authenticatable is declared in Sources/Vapor/Authentication/Authenticator.swift as public protocol Authenticatable { }. The cache is exposed as req.auth, a small struct whose methods are:

Method Behaviour

req.auth.login(_ instance:)

Stores the instance under its type. A later login of the same type replaces the earlier one.

req.auth.get(_ type:)

The stored instance, or nil if there is none. The type can be omitted when it is inferable.

req.auth.has(_ type:)

true when get would return a value.

req.auth.require(_ type:)

The stored instance, or throws Abort(.unauthorized). Marked @discardableResult.

req.auth.logout(_ type:)

Removes the stored instance of that type for the rest of the request.

The cache is keyed by ObjectIdentifier of the type, so one request can carry several different authenticated types at once (an Account and a ServiceClient, for instance), but only one value of each. It lives in the request’s storage and is guarded by a lock, so it is safe to touch from any task that handles the request.

A subtlety in Swift 6: Authenticatable does not require Sendable in Vapor 4 "for legacy reasons", because Fluent 4 models are reference types with mutable property wrappers and can never be Sendable. The cache therefore wraps what you store in an internal @unchecked Sendable box. Your own plain value types should still be Sendable (as Account is below), because that keeps them safe to pass to actors and child tasks; Fluent models are declared @unchecked Sendable by convention on the Fluent Models page. Vapor 5 makes Authenticatable: Sendable a requirement.

The Bookshelf account and its password hashing

The listing defines the user value that authenticators put in req.auth, plus the in-memory store used by every example on this page. Account carries only what handlers need and never the password hash, which keeps it safe to return from a route (it is Content) and cheap to copy.

import Vapor

enum UserRole: String, Codable, Sendable { case reader, librarian, admin }

/// What the authenticators put into `req.auth`. A plain value type: nothing but
/// the facts the handlers need, never the password hash.
struct Account: Authenticatable, Content, Sendable {
    var id: UUID
    var email: String
    var role: UserRole
}

/// In-memory stand-in for the `users` table of the Bookshelf service.
actor AccountStore {
    struct Record: Sendable {
        let account: Account
        let passwordHash: String
    }

    private var byEmail: [String: Record] = [:]
    private var tokens: [String: UUID] = [:]   // bearer token -> account id
    private var byID: [UUID: Record] = [:]

    /// A real bcrypt hash of a throwaway value, verified when the e-mail is unknown
    /// so that "no such user" costs the same time as "wrong password".
    let decoyHash: String

    init(decoyHash: String) { self.decoyHash = decoyHash }

    func add(email: String, role: UserRole, passwordHash: String, token: String? = nil) {
        let record = Record(account: Account(id: UUID(), email: email, role: role), passwordHash: passwordHash)
        byEmail[email] = record
        byID[record.account.id] = record
        if let token { tokens[token] = record.account.id }
    }

    func record(email: String) -> Record? { byEmail[email.lowercased()] }

    func account(forToken token: String) -> Account? {
        guard let id = tokens[token] else { return nil }
        return byID[id]?.account
    }
}

extension Request {
    /// Checks an e-mail and password against the store with the application's
    /// password hasher, on the thread pool (bcrypt is deliberately slow).
    func verifiedAccount(email: String, password: String, in store: AccountStore) async throws -> Account? {
        let record = await store.record(email: email)
        let hash = if let record { record.passwordHash } else { store.decoyHash }
        let matches = try await self.password.async.verify(password, created: hash)
        return matches ? record?.account : nil
    }
}

Four points in this listing carry security weight:

  • Hashing. Vapor’s default password hasher is bcrypt at cost 12 (Application.init calls passwords.use(.bcrypt); see Passwords and Crypto). req.password.async.verify(_:created:) runs the hash comparison on Vapor’s thread pool and resumes on the request’s event loop, so the few hundred milliseconds bcrypt takes at cost 12 does not block an event-loop thread. The synchronous req.password.verify and app.password.hash exist, but call them only at startup or in commands: on an event loop they stall every request that shares it. Hashes embed their own cost, so raising the cost later keeps old hashes verifiable.

  • Same work for unknown users. When the e-mail is not found, the code still verifies against a decoy hash. Returning early would make "no such user" measurably faster than "wrong password", which lets an attacker enumerate registered addresses.

  • Constant-cost comparison of anything secret that is not a bcrypt hash uses secureCompare(to:) (shown in the composition example below), not ==, which stops at the first different byte.

  • No plaintext. PlaintextHasher (app.passwords.use(.plaintext)) exists for tests that must not wait for bcrypt. Never use it in a deployed configuration. The page shows the cheap alternative: a lower bcrypt cost outside production, set in the configure listing below.

Basic authentication

HTTP Basic sends Authorization: Basic base64(email:password) on every request. AsyncBasicAuthenticator requires one method, authenticate(basic:for:), and supplies the rest: its default authenticate(request:) reads req.headers.basicAuthorization and calls you only when the header is present and well-formed. The parser accepts the scheme in any letter case, requires exactly two space-separated parts, base64-decodes the credentials, and splits on the first colon, so a password may itself contain colons but a username may not. A malformed header is treated as no header at all.

/// `Authorization: Basic base64(email:password)`
struct BasicAccountAuthenticator: AsyncBasicAuthenticator {
    let store: AccountStore

    func authenticate(basic: BasicAuthorization, for request: Request) async throws {
        // Wrong credentials are not an error here: leave req.auth empty and let a guard decide.
        if let account = try await request.verifiedAccount(email: basic.username, password: basic.password, in: store) {
            request.auth.login(account)
        }
    }
}

Basic authentication sends the password with every request, so it is acceptable only over TLS, and then mainly for one job: exchanging a password for a token at a single login endpoint (the token page does exactly that). For everyday API calls prefer a bearer token, which can be scoped, expired and revoked without changing the password.

Bearer authentication

AsyncBearerAuthenticator is the same shape for Authorization: Bearer <token>: implement authenticate(bearer:for:) and read bearer.token.

/// `Authorization: Bearer <token>`
struct BearerAccountAuthenticator: AsyncBearerAuthenticator {
    let store: AccountStore

    func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
        if let account = await store.account(forToken: bearer.token) {
            request.auth.login(account)
        }
    }
}

The lookup above compares an opaque token against a dictionary of plaintext tokens, which is acceptable only for this demonstration. A real token store keeps only a hash of each token, compares hashes, and gives tokens an expiry. That design, with Fluent models and rotation, is the subject of Fluent Authentication and API Tokens. A bearer token that is a signed JWT needs no store at all: the authenticator verifies the signature instead (see JSON Web Tokens (JWT)).

Credentials authenticators for login forms and JSON logins

AsyncCredentialsAuthenticator decodes the request body into a Content type you name as its Credentials, and calls authenticate(credentials:for:) only when decoding succeeds. It works for HTML form posts and for JSON bodies alike, which is what makes it the right tool for a POST /login endpoint. A body that does not decode, or a request without a body, is silently skipped, so the guard that follows is what turns a missing or malformed body into the 401.

struct LoginCredentials: Content {
    var email: String
    var password: String
}

/// A JSON or form body `{"email": ..., "password": ...}`
struct CredentialsAccountAuthenticator: AsyncCredentialsAuthenticator {
    typealias Credentials = LoginCredentials
    let store: AccountStore

    func authenticate(credentials: LoginCredentials, for request: Request) async throws {
        if let account = try await request.verifiedAccount(email: credentials.email, password: credentials.password, in: store) {
            request.auth.login(account)
        }
    }
}

Use credentials authenticators only on login routes, and register them on POST routes only: a GET that logs in puts the password in the URL, in logs and in browser history. Whether the login then persists is a separate decision: add a session so the browser keeps a cookie, or issue a token that the client sends next time (see the Sessions page and the pages after this one).

Guards: GuardMiddleware, role guards and RedirectMiddleware

An authenticator never throws for bad credentials, so a route that must be protected needs a guard after it.

Guard Behaviour

Account.guardMiddleware()

Throws Abort(.unauthorized, reason: "Account not authenticated.") unless Account is in req.auth. Pass throwing: for another error, for example a more generic reason on a login route, as the routes listing does.

Account.redirectMiddleware(path: "/login")

Answers 303 See Other with Location: /login instead of an error. For HTML routes.

Account.redirectMiddleware(makePath:)

Same with the target computed from the request, for example to remember the page the visitor wanted. The closure is @Sendable.

req.auth.require(Account.self) in a handler

Throws Abort(.unauthorized) and returns the value in one step; the idiom for reading the user in a handler.

Authentication ("is somebody logged in?") yields 401; authorization ("is this somebody allowed?") yields 403. Vapor’s guard only covers the first; the second is code you write, as in RoleGuardMiddleware, which uses require (so it also works when placed after only an authenticator) and then checks the role.

/// 401 when nobody is logged in, 403 when somebody is but the role is not enough.
struct RoleGuardMiddleware: AsyncMiddleware {
    let allowed: Set<UserRole>

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let account = try request.auth.require(Account.self)          // Abort(.unauthorized)
        guard allowed.contains(account.role) else {
            throw Abort(.forbidden, reason: "Role \(account.role.rawValue) may not do this.")
        }
        return try await next.respond(to: request)
    }
}

Redirects are for the browser. A page that redirects to /login is useful only if a login there creates something the next request will carry, which means a session authenticator ahead of the redirect middleware, in that order. Use guardMiddleware (401) for JSON APIs: a mobile app or script given a 303 to an HTML page cannot do anything useful with it. The redirect target in the example below is built with .alphanumerics percent-encoding so that the original path and query survive as a single next parameter value; validate that value on the login page before redirecting to it (otherwise it is an open redirect), as Session Authentication and Web Login does.

Composing authenticators

There are two kinds of composition and Vapor’s documentation names both:

Several methods for one user type. A route group accepts a password, a token, or both, and every authenticator that recognises its credential logs the same type in. Authenticators are middleware, so all of them run, in order; none short-circuits when an earlier one succeeded, and a later login of the same type replaces an earlier one. Put the cheapest and most specific first and do not rely on "first wins". The session authenticators are the one exception: they do nothing if the type is already in req.auth.

Several user types. The group accepts, say, people and other services. Each authenticator logs in its own type, and a guard or the handler decides what combination is acceptable. A single guardMiddleware checks one type, so "either type" needs a small custom middleware, as RequireAnyMiddleware shows; the handler can then branch with get or has.

import Vapor

/// A second kind of caller: another service presenting a shared key.
struct ServiceClient: Authenticatable, Sendable {
    let name: String
}

struct ServiceKeyAuthenticator: AsyncMiddleware {
    let expectedKey: String

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        if let presented = request.headers.first(name: "X-Service-Key"),
           Array(presented.utf8).secureCompare(to: Array(expectedKey.utf8)) {      // no early exit
            request.auth.login(ServiceClient(name: "catalog-sync"))
        }
        return try await next.respond(to: request)
    }
}

/// Lets a request through when ANY of the listed types is authenticated.
struct RequireAnyMiddleware: AsyncMiddleware {
    let check: @Sendable (Request) -> Bool

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        guard check(request) else { throw Abort(.unauthorized, reason: "Authentication required.") }
        return try await next.respond(to: request)
    }
}

/// Vapor 4 does not send a WWW-Authenticate header with its 401s; add one on the way out.
struct ChallengeMiddleware: AsyncMiddleware {
    let challenge: String

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        do {
            return try await next.respond(to: request)
        } catch let abort as any AbortError where abort.status == .unauthorized {
            var headers = abort.headers
            headers.replaceOrAdd(name: .wwwAuthenticate, value: challenge)
            throw Abort(.unauthorized, headers: headers, reason: abort.reason)
        }
    }
}

func compositionRoutes(_ app: Application, store: AccountStore) throws {
    // Only development and testing may fall back to the placeholder; anywhere else a missing key stops the boot.
    let serviceKey: String
    if let fromEnvironment = Environment.get("BOOKSHELF_SERVICE_KEY") {
        serviceKey = fromEnvironment
    } else if app.environment == .development || app.environment == .testing {
        serviceKey = "change-me-service-key"
    } else {
        throw Abort(.internalServerError, reason: "BOOKSHELF_SERVICE_KEY is not set")
    }

    // People (Basic or Bearer) or the catalog-sync service (X-Service-Key): the first authenticator
    // that recognises the request logs its own type in; the guard accepts either type.
    let either = app.grouped("api", "v1", "sync").grouped(
        BasicAccountAuthenticator(store: store),
        BearerAccountAuthenticator(store: store),
        ServiceKeyAuthenticator(expectedKey: serviceKey),
        RequireAnyMiddleware { $0.auth.has(Account.self) || $0.auth.has(ServiceClient.self) }
    )
    either.get("status") { req async -> String in
        if let account = req.auth.get(Account.self) { return "person: \(account.email)" }
        if let client = req.auth.get(ServiceClient.self) { return "service: \(client.name)" }
        return "unreachable: the guard already rejected anonymous callers"
    }
}

Two further details are in the listing. ServiceKeyAuthenticator is not built on one of the three header protocols, because its credential is a custom header; it is a plain AsyncMiddleware that calls req.auth.login, which is exactly what AsyncRequestAuthenticator does for you with a one-method authenticate(request:). And ChallengeMiddleware adds the WWW-Authenticate response header, which Vapor 4 does not send on its own: an API or browser client cannot learn from a bare 401 which schemes the server accepts, and browsers will not show their Basic-auth dialog without it. Place it outside the guard, because it has to see the error the guard throws.

Wiring it together

The route table below registers one example of each pattern. Public routes have no middleware. The optional route has authenticators without a guard. The protected group puts the challenge, the two authenticators and the guard in that order; the staff group adds the role guard; the login route puts a credentials authenticator before a guard with a deliberately generic message; the auth-demo route calls every req.auth method once; the last two routes show the two redirect forms.

import Vapor

func routes(_ app: Application, store: AccountStore) throws {
    let basic = BasicAccountAuthenticator(store: store)
    let bearer = BearerAccountAuthenticator(store: store)
    let credentials = CredentialsAccountAuthenticator(store: store)

    let api = app.grouped("api", "v1")

    // 1. Public: no authenticator, no guard.
    api.get("books") { _ async -> [String] in ["Dune", "Neuromancer"] }

    // 2. Optional: authenticators without a guard; the handler decides what anonymous callers get.
    let optional = api.grouped(basic, bearer)
    optional.get("greeting") { req async -> String in
        if let account = req.auth.get(Account.self) {
            return "Hello, \(account.email)"
        }
        return "Hello, stranger"
    }

    // 3. Protected: authenticators first, guard last.
    let protected = api.grouped(ChallengeMiddleware(challenge: "Bearer realm=\"bookshelf\", Basic realm=\"bookshelf\""), basic, bearer, Account.guardMiddleware())
    protected.get("me") { req async throws -> Account in
        try req.auth.require(Account.self)
    }

    // 4. Role-restricted: one more middleware after the guard.
    let staff = protected.grouped(RoleGuardMiddleware(allowed: [.librarian, .admin]))
    staff.get("loans", "overdue") { _ async -> [String] in ["Loan 17 (Dune)", "Loan 23 (Emma)"] }

    // 5. Credentials login: the authenticator reads the body, the guard rejects failures.
    api.grouped(credentials, Account.guardMiddleware(throwing: Abort(.unauthorized, reason: "Invalid e-mail or password.")))
        .post("login") { req async throws -> Account in
            try req.auth.require(Account.self)
        }

    // 6. req.auth in one place: login, get, has, require and logout.
    api.get("auth-demo") { req async throws -> String in
        let before = req.auth.has(Account.self)
        req.auth.login(Account(id: UUID(), email: "demo@example.com", role: .reader))
        let during = req.auth.get(Account.self)?.email ?? "nobody"
        let required = try req.auth.require(Account.self).role.rawValue
        req.auth.logout(Account.self)
        let after = req.auth.has(Account.self)
        return "before=\(before) during=\(during) role=\(required) after=\(after)"
    }

    try compositionRoutes(app, store: store)

    // 7. Redirect instead of 401 for browser routes.
    app.grouped(Account.redirectMiddleware(path: "/login")).get("dashboard") { _ async -> String in "Dashboard" }
    app.grouped(Account.redirectMiddleware(makePath: { req in
        let next = req.url.string.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "/"
        return "/login?next=\(next)"
    })).get("reading-list") { _ async -> String in "Reading list" }
}

The configure function selects the hasher, builds the store with placeholder accounts, and calls routes.

import Vapor

func configure(_ app: Application) async throws {
    // bcrypt is the default hasher at cost 12; cheaper rounds keep development and tests fast.
    app.passwords.use(.bcrypt(cost: app.environment == .production ? 12 : 4))

    let store = AccountStore(decoyHash: try app.password.hash(UUID().uuidString))
    // Placeholder development accounts. Real accounts live in the database (next page of this section).
    await store.add(email: "reader@example.com", role: .reader,
                    passwordHash: try app.password.hash("change-me-reader"), token: "dev-token-reader")
    await store.add(email: "librarian@example.com", role: .librarian,
                    passwordHash: try app.password.hash("change-me-librarian"), token: "dev-token-librarian")
    await store.add(email: "admin@example.com", role: .admin,
                    passwordHash: try app.password.hash("change-me-admin"), token: "dev-token-admin")

    try routes(app, store: store)
}

The accounts, passwords and tokens in this listing (change-me-reader, dev-token-reader and so on) are obvious development placeholders for a throwaway in-memory store. Real accounts come from the database, real tokens are random and hashed at rest, and the service key is read from the environment, here BOOKSHELF_SERVICE_KEY. The placeholder is accepted only in the development and testing environments; anywhere else compositionRoutes throws and the application does not start (see the Configuration and Environment page). The first administrator of a real deployment is seeded the same way, from environment variables with no default; the Fluent Authentication and API Tokens page shows the migration.

Trying it with curl

With the service started by swift run App serve, the responses below were captured on 2026-10-17 (the account IDs are random per run and the key order of the JSON is not guaranteed).

$ curl -si http://127.0.0.1:8080/api/v1/me
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer realm="bookshelf", Basic realm="bookshelf"
content-type: application/json; charset=utf-8

{"reason":"Account not authenticated.","error":true}

$ curl -s -u reader@example.com:change-me-reader http://127.0.0.1:8080/api/v1/me
{"id":"5162F4B1-3F4C-4B51-90DA-95A741BA095B","email":"reader@example.com","role":"reader"}

$ curl -si -H "Authorization: Bearer dev-token-reader" http://127.0.0.1:8080/api/v1/loans/overdue
HTTP/1.1 403 Forbidden

{"error":true,"reason":"Role reader may not do this."}

$ curl -s -H "Authorization: Bearer dev-token-librarian" http://127.0.0.1:8080/api/v1/loans/overdue
["Loan 17 (Dune)","Loan 23 (Emma)"]

$ curl -s -X POST -H 'content-type: application/json' \
    -d '{"email":"admin@example.com","password":"wrong"}' http://127.0.0.1:8080/api/v1/login
{"error":true,"reason":"Invalid e-mail or password."}

$ curl -s -H "X-Service-Key: change-me-service-key" http://127.0.0.1:8080/api/v1/sync/status
service: catalog-sync

$ curl -si http://127.0.0.1:8080/dashboard
HTTP/1.1 303 See Other
location: /login

Testing authentication

VaporTesting can set the authorization headers through the same typed accessors the server reads, so a test builds basicAuthorization and bearerAuthorization values instead of base64 by hand. The suite below covers the public route, a rejected and an accepted Basic login, a bearer login, the 403 of the role guard, the credentials login with a wrong password and a malformed body, the req.auth methods and the redirects; the printed lines are the ones quoted in this page.

import VaporTesting
import Testing
@testable import App

@Suite(.serialized) struct AuthenticationTests {
    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()
    }

    @Test func publicAndOptional() async throws {
        try await withApp { app in
            try await app.testing().test(.GET, "api/v1/books") { res async in #expect(res.status == .ok) }
            try await app.testing().test(.GET, "api/v1/greeting") { res async in
                #expect(res.body.string == "Hello, stranger")
            }
            try await app.testing().test(.GET, "api/v1/greeting", beforeRequest: { req in
                req.headers.basicAuthorization = .init(username: "reader@example.com", password: "change-me-reader")
            }) { res async in #expect(res.body.string == "Hello, reader@example.com") }
        }
    }

    @Test func basicAndBearerAndGuard() async throws {
        try await withApp { app in
            try await app.testing().test(.GET, "api/v1/me") { res async in
                #expect(res.status == .unauthorized)
                print("NO CREDS:", res.status.code, res.headers.first(name: "WWW-Authenticate") ?? "(no WWW-Authenticate)", res.body.string)
            }
            try await app.testing().test(.GET, "api/v1/me", beforeRequest: { req in
                req.headers.basicAuthorization = .init(username: "reader@example.com", password: "wrong")
            }) { res async in
                #expect(res.status == .unauthorized)
                print("BAD PASSWORD:", res.body.string)
            }
            try await app.testing().test(.GET, "api/v1/me", beforeRequest: { req in
                req.headers.basicAuthorization = .init(username: "reader@example.com", password: "change-me-reader")
            }) { res async in
                #expect(res.status == .ok)
                print("BASIC OK:", res.body.string)
            }
            try await app.testing().test(.GET, "api/v1/me", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: "dev-token-admin")
            }) { res async in
                #expect(res.status == .ok)
                print("BEARER OK:", res.body.string)
            }
            try await app.testing().test(.GET, "api/v1/me", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: "nope")
            }) { res async in #expect(res.status == .unauthorized) }
            try await app.testing().test(.GET, "api/v1/me", headers: ["Authorization": "bearer dev-token-admin"]) { res async in
                #expect(res.status == .ok)
            }
        }
    }

    @Test func roleGuard() async throws {
        try await withApp { app in
            try await app.testing().test(.GET, "api/v1/loans/overdue", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: "dev-token-reader")
            }) { res async in
                #expect(res.status == .forbidden)
                print("READER:", res.status.code, res.body.string)
            }
            try await app.testing().test(.GET, "api/v1/loans/overdue", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: "dev-token-librarian")
            }) { res async in
                #expect(res.status == .ok)
                print("LIBRARIAN:", res.body.string)
            }
        }
    }

    @Test func credentialsLogin() async throws {
        try await withApp { app in
            try await app.testing().test(.POST, "api/v1/login", beforeRequest: { req in
                try req.content.encode(LoginCredentials(email: "admin@example.com", password: "change-me-admin"))
            }) { res async in
                #expect(res.status == .ok)
                print("LOGIN:", res.body.string)
            }
            try await app.testing().test(.POST, "api/v1/login", beforeRequest: { req in
                try req.content.encode(LoginCredentials(email: "admin@example.com", password: "wrong"))
            }) { res async in
                #expect(res.status == .unauthorized)
                print("LOGIN BAD:", res.body.string)
            }
            try await app.testing().test(.POST, "api/v1/login", beforeRequest: { req in
                try req.content.encode(["unexpected": "shape"])
            }) { res async in
                #expect(res.status == .unauthorized)
                print("LOGIN MALFORMED:", res.body.string)
            }
        }
    }

    @Test func authDemoAndRedirects() async throws {
        try await withApp { app in
            try await app.testing().test(.GET, "api/v1/auth-demo") { res async in
                print("AUTH DEMO:", res.body.string)
            }
            try await app.testing().test(.GET, "dashboard") { res async in
                #expect(res.status == .seeOther)
                print("REDIRECT:", res.status.code, res.headers.first(name: .location) ?? "-")
            }
            try await app.testing().test(.GET, "reading-list?page=2") { res async in
                print("REDIRECT NEXT:", res.status.code, res.headers.first(name: .location) ?? "-")
            }
        }
    }
}

extension AuthenticationTests {
    @Test func composedUsersAndChallenge() async throws {
        try await withApp { app in
            try await app.testing().test(.GET, "api/v1/me") { res async in
                print("CHALLENGE:", res.status.code, res.headers.first(name: .wwwAuthenticate) ?? "-")
                #expect(res.headers.first(name: .wwwAuthenticate) != nil)
            }
            try await app.testing().test(.GET, "api/v1/sync/status", headers: ["X-Service-Key": "change-me-service-key"]) { res async in
                print("SERVICE:", res.body.string)
                #expect(res.body.string == "service: catalog-sync")
            }
            try await app.testing().test(.GET, "api/v1/sync/status", beforeRequest: { req in
                req.headers.bearerAuthorization = .init(token: "dev-token-reader")
            }) { res async in
                print("PERSON:", res.body.string)
            }
            try await app.testing().test(.GET, "api/v1/sync/status", headers: ["X-Service-Key": "wrong"]) { res async in
                #expect(res.status == .unauthorized)
            }
        }
    }
}

Captured output of swift test (six tests passing):

NO CREDS: 401 Bearer realm="bookshelf", Basic realm="bookshelf" {"reason":"Account not authenticated.","error":true}
BASIC OK: {"email":"reader@example.com","id":"871ED085-7C06-4EAF-B1F0-7C75B9A0C478","role":"reader"}
READER: 403 {"reason":"Role reader may not do this.","error":true}
LOGIN BAD: {"reason":"Invalid e-mail or password.","error":true}
LOGIN MALFORMED: {"reason":"Invalid e-mail or password.","error":true}
AUTH DEMO: before=false during=demo@example.com role=reader after=false
REDIRECT: 303 /login
REDIRECT NEXT: 303 /login?next=%2Freading%2Dlist%3Fpage%3D2
SERVICE: service: catalog-sync
PERSON: person: reader@example.com
✔ Test run with 6 tests in 1 suite passed

Two testing tips. Set bcrypt to a low cost in the testing environment, as configure does, or each login costs a few hundred milliseconds. And to test a handler without running the authenticators, call req.auth.login(...) from a small test-only middleware, rather than building real headers.

Swift 6 concurrency notes

  • Authenticators and guards are AsyncMiddleware, which is Sendable: one instance serves all requests on all event loops at once. Store only let values of Sendable types in them (an actor reference such as AccountStore, a Set of an enum, a @Sendable closure). A mutable cache inside an authenticator needs an actor or a lock.

  • The authenticate methods are async, so they can await an actor, a Fluent query or the async password hasher. Do not wrap blocking calls (app.password.hash, synchronous bcrypt) in them; use req.password.async.

  • req.auth values are boxed @unchecked Sendable in Vapor 4. Keep the stored type an immutable value (as Account), not a mutable reference you hand to other tasks.

  • redirectMiddleware(makePath:) takes a @Sendable closure; capture only Sendable values.

  • Do not store the Request or req.auth values in an actor beyond the request; the cache is request-scoped.

Common errors and pitfalls

  • Guard placed before the authenticator. The group is grouped(Account.guardMiddleware(), basic): every request gets 401 because the guard runs first. Authenticators go first.

  • Authenticator without a guard on a "protected" route. Requests with no or bad credentials reach the handler, and get returns nil. Either add the guard or use require in the handler.

  • Throwing 401 from an authenticator for a wrong password. This breaks composition: an earlier method that fails stops a later one that would have succeeded, and anonymous-friendly routes become unreachable. Return silently; throw only for server faults.

  • Expecting login to persist. req.auth.login lasts for one request. Without a session or a token the user is "logged out" on the next request.

  • Rejecting with 401 but no WWW-Authenticate. Vapor 4 does not add the header. Clients cannot discover the scheme, and browsers show no credentials dialog. Add it in a middleware, as ChallengeMiddleware does.

  • Wrong status codes. 401 is "not authenticated" and 403 is "authenticated but not allowed". Using 403 for missing credentials hides the need to log in; using 401 for a role failure makes clients retry with the same credentials.

  • Distinguishable failures. Different messages or timing for an unknown e-mail and a wrong password leak which addresses are registered. Use one message and the decoy hash.

  • Blocking bcrypt on an event loop. req.password.verify or Bcrypt.verify inside a handler stalls every request on that event loop; use req.password.async.

  • Plaintext tokens in the database. A leaked table then equals every valid session. Store hashes.

  • Basic over plain HTTP. The password is readable by anyone on the path. Terminate TLS in front of the service (see Security Hardening).

  • Reusing the credentials authenticator on GET. Credentials end up in URLs and logs.

  • Compiler diagnostics. A struct conforming to AsyncMiddleware with a non-Sendable stored property fails in Swift 6 mode as a Sendable diagnostic on the stored property; store an actor or a Sendable value. Declaring func authenticate(basic:for:) without async throws makes the type not conform to AsyncBasicAuthenticator.

  • Runtime fatal error No password verifier configured. Appears only if the default was replaced by an empty provider; call app.passwords.use(.bcrypt).

Version notes and outdated material

  • Vapor 2 and 3 to Vapor 4. Vapor 2 shipped auth-provider; Vapor 3 shipped the vapor/auth package, imported as Authentication, registered with a provider in configure.swift. Both are superseded by the built-in APIs on this page, which have lived in the vapor package since Vapor 4.0 (the async protocols arrived with the async/await support). The table below lists the old names next to their Vapor 4 replacements.

  • The Fluent-based protocols (ModelAuthenticatable and friends) moved into the Fluent package; they are the subject of Fluent Authentication and API Tokens.

  • The consulted books predate Swift 6 concurrency and the async protocols. Their concepts (bcrypt hashing, public DTOs without the hash, Basic login, tokens, guard middleware, redirects) still hold; where their code differs from the above, the differences are in the table below, and token storage in plaintext, tokens that never expire and .plaintext hashers in sample code are teaching shortcuts to avoid in production.

Vapor 2 (auth-provider) Vapor 3 (vapor/auth) Vapor 4.122 (this page)

PasswordAuthenticationMiddleware, TokenAuthenticationMiddleware

User.basicAuthMiddleware(using:), User.tokenAuthMiddleware()

AsyncBasicAuthenticator, AsyncBearerAuthenticator conformances, used as route-group middleware (User.authenticator() for Fluent models, next page)

PasswordAuthenticatable, TokenAuthenticatable

BasicAuthenticatable, BearerAuthenticatable, PasswordAuthenticatable, TokenAuthenticatable

ModelAuthenticatable, ModelTokenAuthenticatable (Fluent package) or your own authenticator

req.auth.authenticate(_:), req.auth.authenticated(_:)

req.authenticate(_:), req.authenticated(_:), req.requireAuthenticated(_:)

req.auth.login, get, require, has, logout

PersistMiddleware for sessions

AuthenticationSessionsMiddleware

SessionAuthenticator or ModelSessionAuthenticatable, see the Sessions page

a provider added to the Droplet configuration

try services.register(AuthenticationProvider())

nothing to register

BCryptHasher from the Vapor 2 crypto package

BCryptDigest() from Crypto

app.passwords.use(.bcrypt), req.password.async

manual checks inside the handler

GuardAuthenticationMiddleware<User>

User.guardMiddleware(), User.redirectMiddleware(path:)

  • Vapor 5. Vapor 5 is at 5.0.0-beta.4 and not released. A look at the main branch on 2026-10-17 shows these changes (the beta tag itself was not built, so treat them as the direction, not a final API): the Async prefix goes away, so the protocols are named BasicAuthenticator, BearerAuthenticator, CredentialsAuthenticator and RequestAuthenticator and are async throws only (no EventLoopFuture variants); Authenticatable refines Sendable; BasicAuthenticator and BearerAuthenticator gain a realm property (default "Vapor") and automatically add a WWW-Authenticate challenge to a 401 produced by the wrapped chain, which makes ChallengeMiddleware unnecessary for those two; the credentials authenticator decodes with try? await request.content.decode(...). The guard and redirect middleware keep the same spelling. Review the Vapor 5 upgrade notes (What’s New, Upgrading and Vapor 5) before migrating.

  • Timing of the async protocols. AsyncBasicAuthenticator and its siblings are the recommended choice since async/await support arrived in Vapor 4; the future-based BasicAuthenticator, BearerAuthenticator and CredentialsAuthenticator (returning EventLoopFuture<Void>) still exist in 4.122 for older code and are not used on this page.