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 |
|---|---|
|
Marker protocol with no requirements. Any type you want to store in |
|
Per-request cache keyed by type: |
|
Protocols that parse an |
|
The protocol underneath them: one method, |
|
Throws |
|
Redirects to a login page unless the type is in |
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).
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 |
|---|---|
|
Stores the instance under its type. A later |
|
The stored instance, or |
|
|
|
The stored instance, or throws |
|
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.initcallspasswords.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 synchronousreq.password.verifyandapp.password.hashexist, 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 theconfigurelisting 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 |
|---|---|
|
Throws |
|
Answers |
|
Same with the target computed from the request, for example to remember the page the visitor wanted. The closure is |
|
Throws |
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" }
}
Source: Vapor docs: Authentication
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)
}
}
}
}
Source: Vapor docs: Testing
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 isSendable: one instance serves all requests on all event loops at once. Store onlyletvalues ofSendabletypes in them (an actor reference such asAccountStore, aSetof an enum, a@Sendableclosure). A mutable cache inside an authenticator needs an actor or a lock. -
The
authenticatemethods areasync, so they canawaitan actor, a Fluent query or the async password hasher. Do not wrap blocking calls (app.password.hash, synchronous bcrypt) in them; usereq.password.async. -
req.authvalues are boxed@unchecked Sendablein Vapor 4. Keep the stored type an immutable value (asAccount), not a mutable reference you hand to other tasks. -
redirectMiddleware(makePath:)takes a@Sendableclosure; capture onlySendablevalues. -
Do not store the
Requestorreq.authvalues 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 gets401because 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
getreturnsnil. Either add the guard or userequirein the handler. -
Throwing
401from 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
loginto persist.req.auth.loginlasts for one request. Without a session or a token the user is "logged out" on the next request. -
Rejecting with
401but noWWW-Authenticate. Vapor 4 does not add the header. Clients cannot discover the scheme, and browsers show no credentials dialog. Add it in a middleware, asChallengeMiddlewaredoes. -
Wrong status codes.
401is "not authenticated" and403is "authenticated but not allowed". Using403for missing credentials hides the need to log in; using401for 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.verifyorBcrypt.verifyinside a handler stalls every request on that event loop; usereq.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
AsyncMiddlewarewith a non-Sendablestored property fails in Swift 6 mode as a Sendable diagnostic on the stored property; store an actor or aSendablevalue. Declaringfunc authenticate(basic:for:)withoutasync throwsmakes the type not conform toAsyncBasicAuthenticator. -
Runtime fatal error
No password verifier configured. Appears only if the default was replaced by an empty provider; callapp.passwords.use(.bcrypt).
Version notes and outdated material
-
Vapor 2 and 3 to Vapor 4. Vapor 2 shipped
auth-provider; Vapor 3 shipped thevapor/authpackage, imported asAuthentication, registered with a provider inconfigure.swift. Both are superseded by the built-in APIs on this page, which have lived in thevaporpackage 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 (
ModelAuthenticatableand 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
.plaintexthashers in sample code are teaching shortcuts to avoid in production.
Vapor 2 (auth-provider) |
Vapor 3 (vapor/auth) |
Vapor 4.122 (this page) |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
a provider added to the Droplet configuration |
|
nothing to register |
|
|
|
manual checks inside the handler |
|
|
-
Vapor 5. Vapor 5 is at 5.0.0-beta.4 and not released. A look at the
mainbranch on 2026-10-17 shows these changes (the beta tag itself was not built, so treat them as the direction, not a final API): theAsyncprefix goes away, so the protocols are namedBasicAuthenticator,BearerAuthenticator,CredentialsAuthenticatorandRequestAuthenticatorand areasync throwsonly (noEventLoopFuturevariants);AuthenticatablerefinesSendable;BasicAuthenticatorandBearerAuthenticatorgain arealmproperty (default"Vapor") and automatically add aWWW-Authenticatechallenge to a401produced by the wrapped chain, which makesChallengeMiddlewareunnecessary for those two; the credentials authenticator decodes withtry? 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.
AsyncBasicAuthenticatorand its siblings are the recommended choice since async/await support arrived in Vapor 4; the future-basedBasicAuthenticator,BearerAuthenticatorandCredentialsAuthenticator(returningEventLoopFuture<Void>) still exist in 4.122 for older code and are not used on this page.