Authorization: Roles, Ownership and Policies
|
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. The service was run with SQLite in memory and exercised with curl, and a
VaporTesting suite of 31 tests (30 cases of a status-code matrix plus one test of mass assignment) passed; the output shown
is captured from those runs. 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 and uses its User, Book, Review and Loan
models and the UserRole enum (reader, librarian, admin). How a user gets authenticated (passwords, session cookies,
Fluent tokens, JWT) is the subject of Authentication, Fluent Authentication and API Tokens, Session Authentication and Web Login and JSON Web Tokens (JWT); here a deliberately tiny stand-in
authenticator is used so that the listings are complete.
Authentication, authorization and the three status codes
Authentication answers "who is this?". Authorization answers "may this identity do this to that thing?". They are separate steps, and the second one runs on every request that touches protected data, not once at login. OWASP ranks the failure of the second step, broken access control, first in its Top 10 (A01:2025), and the commonest form of it in APIs is the one this page spends most time on: checking that the caller is signed in but never checking whose record the URL names.
HTTP has distinct answers for the two failures, and a service should use them consistently:
| Status | Meaning | Bookshelf example |
|---|---|---|
|
The request carries no valid credentials. Despite the name it is about authentication. RFC 9110 requires a
|
No |
|
The server knows who you are and refuses. Repeating the request with the same credentials will not help. |
A reader calls the librarian-only loan listing, or edits somebody else’s review. |
|
Used on purpose to hide that a resource exists. RFC 9110 allows a server to answer 404 instead of 403 when it does not want to reveal a forbidden target. |
A reader asks for a draft book or for another reader’s loan. |
A practical rule: use 403 when the resource is public knowledge but the action is not allowed (a review is visible to everyone, so refusing the edit with 403 reveals nothing), and 404 when the very existence of the resource is the secret (drafts, other people’s loans). Either way, never let the response body or the timing distinguish "does not exist" from "not yours" when you chose 404.
Where it runs in the request
Authorization is spread over three places in the request lifecycle, from cheap and coarse to precise and specific:
-
Authenticator middleware. It reads the credentials and, when they are valid, stores the user in
req.auth. By design it does not reject anonymous requests, so that several authenticators can be composed (see the Middleware page for the onion model and ordering). -
Guard middleware on a route group.
User.guardMiddleware()rejects requests where no user was authenticated (401). A role middleware rejects users whose role is not allowed (403). Both run before the handler, so a refused request does no database work for the handler. -
Inside the handler. Only here are the route parameters decoded and the target record loaded, so only here can you check ownership ("is this review yours?") and apply scoping (a Fluent query that can only return what this user may see).
The order of grouped(...) calls is the order of execution, outermost first: authenticate, then guard, then role. Put them
the other way round and an anonymous request to an admin route would fail the role check as a 403 instead of a 401. Two
interactions are worth remembering. A request that matches no route reaches only the global middleware, never the group
middleware, so it is a plain 404 for everyone, which is fine. And a CORS preflight (OPTIONS) carries no credentials, so
CORSMiddleware must be registered globally before anything that would answer it with a 401; the
CORS page of the web section covers the headers.
Swift 6 concurrency notes
-
Middleware and policies are
Sendablevalue types (struct`s holding a `Set<UserRole>or nothing at all). Vapor shares one middleware instance across all requests, so state in a middleware must be immutable or protected by an actor; see the Actors, Isolation and Sendable page of the Swift Reference. -
req.authis safe to use inside a request: its cache is lock-protected. TheUserinstance it returns is a Fluent model (a class declared@unchecked Sendablein the Bookshelf models). Read it freely during the request, but do not hand the same instance to a detached task that mutates it. -
The authorization decision is a plain synchronous function of values already in memory (
user,review). Keep it that way: a policy that needs the database should receive the rows it needs as arguments, which also makes it trivial to unit test.
A stand-in authenticator
The listing below is demo only. The bearer token is the user’s UUID, which anybody can guess or copy; a real service
verifies a hashed, expiring token or a signed JWT, as the Authentication pages show. It exists so that the rest of the page
has a User in req.auth. Note the shape: it conforms to AsyncBearerAuthenticator, calls req.auth.login on success and does
nothing otherwise, never throwing for a missing or unknown token.
// Authentication.swift - the smallest possible authentication layer, only so that the authorization code has a user.
import Fluent
import Vapor
extension User: Authenticatable {}
/// DEMO ONLY: the bearer token IS the user's id. A real service verifies a hashed, expiring token or a signed JWT
/// (see the xref:backend/vapor/authentication.adoc[Authentication] pages). It never rejects: it only logs a user in when it can.
struct DemoTokenAuthenticator: AsyncBearerAuthenticator {
func authenticate(bearer: BearerAuthorization, for request: Request) async throws {
guard let id = UUID(uuidString: bearer.token),
let user = try await User.find(id, on: request.db) else { return }
request.auth.login(user)
}
}
Source: Vapor docs: Authentication
Roles
The Bookshelf UserRole has three cases. Two designs are common, and the page uses the first:
-
A ladder.
reader < librarian < admin, each role able to do everything the one below can. Easy to explain, and enough for most small services. The code below keeps an explicitSet<UserRole>of allowed roles per route instead of comparing ranks, so a route can also say "librarians only, not admins" when the business requires it. -
Permissions. Roles are only bundles of finer permissions (
catalogue.write,loans.read.all), and the code checks permissions. This scales better once roles multiply, and it is the shape OAuth scopes already have (see Scopes, claims and permissions).
The role lives on the user row and is read when the authenticator loads the user, so a role change takes effect on the next request. If the role were instead copied into a JWT claim, it would stay stale until the token expires; that trade-off belongs to the JWT page.
A role middleware, a per-handler guard and a policy
// Authorization.swift
import Fluent
import Vapor
// MARK: - Roles
extension UserRole {
/// Bookshelf roles form a ladder: every librarian can do what a reader can, every admin what a librarian can.
var rank: Int {
switch self {
case .reader: 0
case .librarian: 1
case .admin: 2
}
}
var isStaff: Bool { self != .reader }
}
extension HTTPHeaders {
/// Challenge sent with every 401, as RFC 9110 requires.
static let bearerChallenge: HTTPHeaders = ["WWW-Authenticate": "Bearer realm=\"bookshelf\""]
}
extension Request {
/// The authenticated user, or a 401 with a challenge.
func requireUser() throws -> User {
guard let user = auth.get(User.self) else {
throw Abort(.unauthorized, headers: .bearerChallenge, reason: "Authentication required.")
}
return user
}
/// Per-handler role guard: 401 when nobody is signed in, 403 when the role is not allowed.
@discardableResult
func requireRole(_ roles: UserRole...) throws -> User {
let user = try requireUser()
guard roles.contains(user.role) else {
logger.warning("Forbidden", metadata: ["user": "\(user.id?.uuidString ?? "?")", "role": "\(user.role.rawValue)"])
throw Abort(.forbidden, reason: "Your role is not allowed to do this.")
}
return user
}
}
/// Route-group role guard. Register it after the authenticator.
struct RoleMiddleware: AsyncMiddleware {
let allowed: Set<UserRole>
init(allowing roles: UserRole...) { self.allowed = Set(roles) }
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
let user = try request.requireUser()
guard allowed.contains(user.role) else {
request.logger.warning("Forbidden", metadata: ["user": "\(user.id?.uuidString ?? "?")", "role": "\(user.role.rawValue)"])
throw Abort(.forbidden, reason: "Your role is not allowed to do this.")
}
return try await next.respond(to: request)
}
}
// MARK: - Policies
/// Who may touch a review: the author edits, the author or any staff member deletes (moderation).
struct ReviewPolicy: Sendable {
enum Action: Sendable { case edit, delete }
func allows(_ action: Action, _ review: Review, as user: User) -> Bool {
let isAuthor = review.$user.id == user.id
switch action {
case .edit: return isAuthor
case .delete: return isAuthor || user.role.isStaff
}
}
func authorize(_ action: Action, _ review: Review, as user: User) throws {
guard allows(action, review, as: user) else {
throw Abort(.forbidden, reason: "You may not \(action) this review.")
}
}
}
/// Who may see and return a loan: the borrower or any staff member.
struct LoanPolicy: Sendable {
func allowsView(_ loan: Loan, as user: User) -> Bool { loan.$user.id == user.id || user.role.isStaff }
func allowsReturn(_ loan: Loan, as user: User) -> Bool { allowsView(loan, as: user) }
}
extension Request {
var reviewPolicy: ReviewPolicy { ReviewPolicy() }
var loanPolicy: LoanPolicy { LoanPolicy() }
}
// MARK: - Scoped queries
extension QueryBuilder where Model == Book {
/// Readers see published books only; staff see drafts and archived books too.
func visible(to user: User) -> Self {
if user.role == .reader { filter(\.$status == .published) } else { self }
}
}
extension QueryBuilder where Model == Loan {
/// Readers see their own loans only; staff see every loan.
func visible(to user: User) throws -> Self {
if user.role == .reader { filter(\.$user.$id == (try user.requireID())) } else { self }
}
}
What the listing shows:
-
RoleMiddlewareis anAsyncMiddleware. It throws anAbort, whichErrorMiddlewareturns into the JSON error body (see Error handling). It checks for a user itself, so it returns the right 401 even if somebody forgets the guard. -
req.requireRole(...)is the same check as a function, for the case where a single route of a group needs a stricter rule, or where the rule depends on the data (for example "librarians may archive, but only admins may archive a book that has open loans"). -
The
401carriesWWW-Authenticate: Bearer realm="bookshelf". Vapor’s defaultguardMiddleware()throws a bareAbort(.unauthorized)without that header, which is why the routes below pass their own error. -
ReviewPolicyandLoanPolicyare policy objects: one small type per resource that answers "may this user perform this action on this record?". The route handler asks, and the rule lives in one place, easy to read and to test, instead of anifscattered across handlers. A policy that needs configuration (a loan limit per role, say) can be created once and stored on the application; see the services and storage page. -
The two
visible(to:)extensions are the scopes, covered in their own section below.
Routes
The route collection wires the layers together. signedIn is authenticate + guard; staff and admins add a role check.
// BookshelfRoutes.swift
import Fluent
import Vapor
struct BookOut: Content {
let id: UUID
let title: String
let status: BookStatus
}
struct LoanOut: Content {
let id: UUID
let bookID: UUID
let userID: UUID
let returned: Bool
}
struct ReviewOut: Content {
let id: UUID
let rating: Int
let body: String
let userID: UUID
}
struct NewReview: Content, Validatable {
let rating: Int
let body: String
static func validations(_ v: inout Validations) { v.add("rating", as: Int.self, is: .range(1...5)) }
}
struct ReviewPatch: Content {
let rating: Int?
let body: String?
}
struct UserOut: Content {
let id: UUID
let email: String
let role: UserRole
}
struct RolePatch: Content { let role: UserRole }
extension Book { func out() throws -> BookOut { BookOut(id: try requireID(), title: title, status: status) } }
extension Loan {
func out() throws -> LoanOut {
LoanOut(id: try requireID(), bookID: $book.id, userID: $user.id, returned: returnedAt != nil)
}
}
extension Review {
func out() throws -> ReviewOut { ReviewOut(id: try requireID(), rating: rating, body: body, userID: $user.id) }
}
extension User { func out() throws -> UserOut { UserOut(id: try requireID(), email: email, role: role) } }
struct BookshelfAccess: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
// 1. authenticate (never rejects), 2. guard: someone must be signed in (401).
let signedIn = routes.grouped("api")
.grouped(DemoTokenAuthenticator())
.grouped(User.guardMiddleware(throwing: Abort(.unauthorized, headers: .bearerChallenge, reason: "Authentication required.")))
// 3. role guards on route groups (403).
let staff = signedIn.grouped(RoleMiddleware(allowing: .librarian, .admin))
let admins = signedIn.grouped(RoleMiddleware(allowing: .admin))
signedIn.get("books", use: listBooks)
signedIn.get("books", ":bookID", use: showBook)
signedIn.post("books", ":bookID", "archive", use: archiveBook) // role checked inside the handler
signedIn.post("books", ":bookID", "reviews", use: addReview)
staff.get("loans", "all", use: listAllLoans)
admins.delete("books", ":bookID", use: deleteBook)
admins.get("users", use: listUsers)
admins.put("users", ":userID", "role", use: changeRole)
signedIn.get("loans", use: listLoans)
signedIn.get("loans", ":loanID", use: showLoan)
signedIn.post("loans", ":loanID", "return", use: returnLoan)
signedIn.patch("reviews", ":reviewID", use: editReview)
signedIn.delete("reviews", ":reviewID", use: deleteReview)
signedIn.get("users", ":userID", use: showUser)
}
// MARK: Books
@Sendable func listBooks(req: Request) async throws -> [BookOut] {
let user = try req.requireUser()
return try await Book.query(on: req.db).visible(to: user).sort(\.$title).all().map { try $0.out() }
}
@Sendable func showBook(req: Request) async throws -> BookOut {
let user = try req.requireUser()
let id = try req.parameters.require("bookID", as: UUID.self)
// The visibility scope is part of the lookup: a draft the reader may not see is simply "not found".
guard let book = try await Book.query(on: req.db).visible(to: user).filter(\.$id == id).first() else {
throw Abort(.notFound)
}
return try book.out()
}
@Sendable func archiveBook(req: Request) async throws -> BookOut {
try req.requireRole(.librarian, .admin)
guard let book = try await Book.find(req.parameters.get("bookID"), on: req.db) else { throw Abort(.notFound) }
book.status = .archived
try await book.save(on: req.db)
return try book.out()
}
@Sendable func deleteBook(req: Request) async throws -> HTTPStatus {
guard let book = try await Book.find(req.parameters.get("bookID"), on: req.db) else { throw Abort(.notFound) }
try await book.delete(on: req.db)
return .noContent
}
// MARK: Reviews
@Sendable func addReview(req: Request) async throws -> Response {
let user = try req.requireUser()
try NewReview.validate(content: req)
let input = try req.content.decode(NewReview.self)
let id = try req.parameters.require("bookID", as: UUID.self)
// Readers may only review books they can see.
guard let book = try await Book.query(on: req.db).visible(to: user).filter(\.$id == id).first() else {
throw Abort(.notFound)
}
// The author comes from the credentials, never from the request body.
let review = Review(rating: input.rating, body: input.body, bookID: try book.requireID(), userID: try user.requireID())
try await review.save(on: req.db)
return try await review.out().encodeResponse(status: .created, for: req)
}
@Sendable func editReview(req: Request) async throws -> ReviewOut {
let user = try req.requireUser()
guard let review = try await Review.find(req.parameters.get("reviewID"), on: req.db) else { throw Abort(.notFound) }
try req.reviewPolicy.authorize(.edit, review, as: user)
let patch = try req.content.decode(ReviewPatch.self)
if let rating = patch.rating { review.rating = rating }
if let body = patch.body { review.body = body }
try await review.save(on: req.db)
return try review.out()
}
@Sendable func deleteReview(req: Request) async throws -> HTTPStatus {
let user = try req.requireUser()
guard let review = try await Review.find(req.parameters.get("reviewID"), on: req.db) else { throw Abort(.notFound) }
try req.reviewPolicy.authorize(.delete, review, as: user)
try await review.delete(on: req.db)
return .noContent
}
// MARK: Loans
@Sendable func listLoans(req: Request) async throws -> [LoanOut] {
let user = try req.requireUser()
return try await Loan.query(on: req.db).visible(to: user).all().map { try $0.out() }
}
@Sendable func listAllLoans(req: Request) async throws -> [LoanOut] {
try await Loan.query(on: req.db).all().map { try $0.out() }
}
@Sendable func showLoan(req: Request) async throws -> LoanOut {
let user = try req.requireUser()
let id = try req.parameters.require("loanID", as: UUID.self)
guard let loan = try await Loan.query(on: req.db).visible(to: user).filter(\.$id == id).first() else {
throw Abort(.notFound) // other people's loans do not exist, as far as this user can tell
}
return try loan.out()
}
@Sendable func returnLoan(req: Request) async throws -> LoanOut {
let user = try req.requireUser()
let id = try req.parameters.require("loanID", as: UUID.self)
guard let loan = try await Loan.query(on: req.db).visible(to: user).filter(\.$id == id).first() else {
throw Abort(.notFound)
}
guard req.loanPolicy.allowsReturn(loan, as: user) else { throw Abort(.forbidden) } // second line of defence
loan.returnedAt = Date()
try await loan.save(on: req.db)
return try loan.out()
}
// MARK: Users
@Sendable func listUsers(req: Request) async throws -> [UserOut] {
try await User.query(on: req.db).sort(\.$email).all().map { try $0.out() }
}
@Sendable func showUser(req: Request) async throws -> UserOut {
let me = try req.requireUser()
let id = try req.parameters.require("userID", as: UUID.self)
// Object-level check: the ID in the URL is attacker-controlled.
guard id == me.id || me.role == .admin else { throw Abort(.forbidden) }
guard let user = try await User.find(id, on: req.db) else { throw Abort(.notFound) }
return try user.out()
}
@Sendable func changeRole(req: Request) async throws -> UserOut {
let admin = try req.requireRole(.admin)
let id = try req.parameters.require("userID", as: UUID.self)
guard id != admin.id else { throw Abort(.conflict, reason: "Admins cannot change their own role.") }
guard let user = try await User.find(id, on: req.db) else { throw Abort(.notFound) }
user.role = try req.content.decode(RolePatch.self).role
try await user.save(on: req.db)
return try user.out()
}
}
/// DEMO ONLY: the vulnerable version of `showUser`, kept to show the attack. Never register this in a real service.
struct InsecureUsers: RouteCollection {
func boot(routes: any RoutesBuilder) throws {
routes.grouped("insecure").grouped(DemoTokenAuthenticator()).get("users", ":userID") { req async throws -> UserOut in
_ = try req.requireUser() // authenticated, but nothing checks WHOSE record this is
guard let user = try await User.find(req.parameters.get("userID"), on: req.db) else { throw Abort(.notFound) }
return try user.out()
}
}
}
Read the handlers as a catalogue of the techniques:
-
listBooks,showBook: scoping. Readers see published books only because the query itself filters; a draft is a 404. -
archiveBook: the role check inside the handler withreq.requireRole. -
deleteBook,listUsers,changeRole: whole-route protection by theadminsgroup. -
editReview,deleteReview: ownership throughReviewPolicy. -
listLoans,showLoan,returnLoan: scoping and a policy as a second line of defence. -
addReview: the author is taken fromreq.auth, never from the request body. -
showUseragainstInsecureUsers: the IDOR, next.
Ownership and insecure direct object references
A role says what kind of user you are; ownership says which records are yours. Role middleware cannot express ownership,
because at middleware time the handler has not yet decoded :userID or loaded anything. The classic mistake is a route like
GET /users/:userID
whose handler loads whatever ID the URL contains. The caller is authenticated and has a perfectly legitimate role, yet can
read any other user by editing the number in the URL. This is called an insecure direct object reference (IDOR); the OWASP API
Security Top 10 names it Broken Object Level Authorization. InsecureUsers in the listing above is that handler, kept only
to show the attack. Captured from the running service, with the token of reader Alice (token and IDs are throwaway demo values):
$ curl -H "Authorization: Bearer 00000000-0000-0000-0000-0000000000A1" localhost:8093/insecure/users/00000000-0000-0000-0000-0000000000D4
HTTP/1.1 200 OK
{"id":"00000000-0000-0000-0000-0000000000D4","email":"ada@example.com","role":"admin"}
$ curl -H "Authorization: Bearer 00000000-0000-0000-0000-0000000000A1" localhost:8093/api/users/00000000-0000-0000-0000-0000000000D4
HTTP/1.1 403 Forbidden
{"reason":"Forbidden","error":true}
The fix is one line in showUser: compare the ID in the URL with the authenticated user (id == me.id || me.role == .admin)
before loading anything. The same bug is found wherever an ID arrives from the client: a profile-picture upload that takes
the user ID from the URL, a download link, a nested resource (/users/:id/loans), a WebSocket message or a background-job
payload. The upload case is covered on the File uploads and streaming page;
the general fix is always the same: derive "whose" from req.auth, and when the client must name a record, check it
belongs to the caller.
UUIDs make IDs hard to guess, but they are not a defence: they leak through logs, referrers, shared links and other API responses. Treat them as identifiers, not secrets.
Scoping Fluent queries to the current user
Checking after loading ("fetch the loan, then see whether it is mine") works, but it is easy to forget in one handler. A safer pattern is to make the query incapable of returning what the caller may not see, and to put that rule in one named extension:
extension QueryBuilder where Model == Loan {
/// Readers see their own loans only; staff see every loan.
func visible(to user: User) throws -> Self {
if user.role == .reader { filter(\.$user.$id == (try user.requireID())) } else { self }
}
}
Source: Vapor docs: Fluent query
(This is the same extension that appears in Authorization.swift above.) Every handler then writes
Loan.query(on: req.db).visible(to: user), and a forgotten check becomes a visible omission in code review. Benefits:
-
the database does the filtering, so listings and counts are correct and pagination does not leak: an unscoped query that is filtered afterwards in Swift returns pages with holes and a total count that betrays hidden rows (see the Fluent queries and pagination page);
-
a hidden row simply is not found, which gives the 404-not-403 behaviour for free;
-
the scope composes with other filters, sorting and paging.
The same idea extends to tenants: a tenant_id filter applied in one place is how multi-tenant services keep customers apart.
Scopes protect reads and also the lookup of a row to update or delete. They do not replace the policy for actions:
the reader’s own loan is visible, but whether the reader may delete it is a different question.
A remark on races: "check, then act" has a small window between the check and the write, and a role changed in that window is applied one request late. For rules that must hold absolutely (never delete the last admin), enforce them in the database (a constraint) or inside a transaction, as covered on the Transactions, model middleware and soft delete page.
Roles must not be writable by the user
Mass assignment is an authorization bug too. If a profile-update route decodes straight into the User model, a client that
sends "role": "admin" promotes itself. Decode into a DTO that lacks the field (ReviewPatch, NewReview above carry no
author or role), and change roles only through a dedicated admin route such as changeRole, which also refuses to let an admin
change their own role, so the last admin cannot lock everybody out. The test readerCannotEscalateThroughBody below posts a
userID in the body and checks that it is ignored.
Testing the matrix
An authorization rule that is not tested will be broken by the next refactor. A table of (who, request, expected status) is the most readable form; Swift Testing’s parameterised tests report each row separately:
import Fluent
import Testing
import VaporTesting
@testable import App
struct Case: Sendable, CustomTestStringConvertible {
let who: UUID?
let method: HTTPMethod
let path: String
let body: String?
let expected: HTTPStatus
var testDescription: String { "\(who.map { String($0.uuidString.suffix(2)) } ?? "anon") \(method) \(path) -> \(expected.code)" }
init(_ who: UUID?, _ method: HTTPMethod, _ path: String, _ expected: HTTPStatus, body: String? = nil) {
self.who = who; self.method = method; self.path = path; self.expected = expected; self.body = body
}
}
let matrix: [Case] = [
// authentication versus authorization: 401 without credentials, 403 with the wrong role
Case(nil, .GET, "/api/books", .unauthorized),
Case(Fixture.alice, .GET, "/api/loans/all", .forbidden),
Case(Fixture.lena, .GET, "/api/loans/all", .ok),
Case(Fixture.alice, .DELETE, "/api/books/\(Fixture.published)", .forbidden),
Case(Fixture.lena, .DELETE, "/api/books/\(Fixture.published)", .forbidden),
Case(Fixture.ada, .DELETE, "/api/books/\(Fixture.published)", .noContent),
Case(nil, .GET, "/api/users", .unauthorized),
Case(Fixture.lena, .GET, "/api/users", .forbidden),
Case(Fixture.ada, .GET, "/api/users", .ok),
// per-handler guard
Case(Fixture.alice, .POST, "/api/books/\(Fixture.published)/archive", .forbidden),
Case(Fixture.lena, .POST, "/api/books/\(Fixture.published)/archive", .ok),
// scoped queries: readers do not see drafts, and a hidden draft is a 404, not a 403
Case(Fixture.alice, .GET, "/api/books/\(Fixture.published)", .ok),
Case(Fixture.alice, .GET, "/api/books/\(Fixture.draft)", .notFound),
Case(Fixture.lena, .GET, "/api/books/\(Fixture.draft)", .ok),
// loans belong to their borrower
Case(Fixture.alice, .GET, "/api/loans/\(Fixture.aliceLoan)", .ok),
Case(Fixture.alice, .GET, "/api/loans/\(Fixture.bobLoan)", .notFound),
Case(Fixture.lena, .GET, "/api/loans/\(Fixture.bobLoan)", .ok),
Case(Fixture.bob, .POST, "/api/loans/\(Fixture.aliceLoan)/return", .notFound),
Case(Fixture.alice, .POST, "/api/loans/\(Fixture.aliceLoan)/return", .ok),
// reviews: only the author edits, the author or staff delete
Case(Fixture.bob, .PATCH, "/api/reviews/\(Fixture.aliceReview)", .forbidden, body: #"{"body":"Edited by bob"}"#),
Case(Fixture.lena, .PATCH, "/api/reviews/\(Fixture.aliceReview)", .forbidden, body: #"{"body":"Edited by lena"}"#),
Case(Fixture.alice, .PATCH, "/api/reviews/\(Fixture.aliceReview)", .ok, body: #"{"body":"Still wonderful."}"#),
Case(Fixture.bob, .DELETE, "/api/reviews/\(Fixture.aliceReview)", .forbidden),
Case(Fixture.lena, .DELETE, "/api/reviews/\(Fixture.aliceReview)", .noContent),
// object-level check on users, and the vulnerable twin
Case(Fixture.alice, .GET, "/api/users/\(Fixture.alice)", .ok),
Case(Fixture.alice, .GET, "/api/users/\(Fixture.ada)", .forbidden),
Case(Fixture.ada, .GET, "/api/users/\(Fixture.alice)", .ok),
Case(Fixture.alice, .GET, "/insecure/users/\(Fixture.ada)", .ok), // the IDOR
// role changes
Case(Fixture.ada, .PUT, "/api/users/\(Fixture.bob)/role", .ok, body: #"{"role":"librarian"}"#),
Case(Fixture.ada, .PUT, "/api/users/\(Fixture.ada)/role", .conflict, body: #"{"role":"reader"}"#),
]
@Suite struct AuthorizationTests {
@Test(arguments: matrix) func matrixCase(_ c: Case) async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
try await app.testing().test(c.method, c.path, beforeRequest: { req async in
if let who = c.who { req.headers.bearerAuthorization = .init(token: who.uuidString) }
if let body = c.body {
req.headers.contentType = .json
req.body = .init(string: body)
}
}, afterResponse: { res async in
#expect(res.status == c.expected)
if res.status == .unauthorized {
#expect(res.headers.first(name: .wwwAuthenticate)?.hasPrefix("Bearer") == true)
}
})
} catch { try await app.asyncShutdown(); throw error }
try await app.asyncShutdown()
}
@Test func readerCannotEscalateThroughBody() async throws {
let app = try await Application.make(.testing)
do {
try await configure(app)
// the review author is taken from the token; a "userID" in the body is ignored
try await app.testing().test(.POST, "/api/books/\(Fixture.published)/reviews", beforeRequest: { req async in
req.headers.bearerAuthorization = .init(token: Fixture.bob.uuidString)
req.headers.contentType = .json
req.body = .init(string: #"{"rating":4,"body":"Nice","userID":"\#(Fixture.alice)"}"#)
}, afterResponse: { res async throws in
#expect(res.status == .created)
#expect(try res.content.decode(ReviewOut.self).userID == Fixture.bob)
})
let user = try await User.find(Fixture.bob, on: app.db)
#expect(user?.role == .reader)
} catch { try await app.asyncShutdown(); throw error }
try await app.asyncShutdown()
}
}
Source: Vapor docs: Testing
Result of swift test in the scratch package:
✔ Test matrixCase(_:) with 30 test cases passed after 0.240 seconds.
✔ Test readerCannotEscalateThroughBody() passed after 0.240 seconds.
✔ Suite AuthorizationTests passed after 0.240 seconds.
✔ Test run with 2 tests in 1 suite passed after 0.241 seconds.
Test the denial paths at least as carefully as the success paths, and include the cases people forget: no credentials, the wrong role, the right role on someone else’s record, and the "staff may, but only through the correct route" combinations.
Captured with curl from the running service (reader Alice, librarian Lena):
$ curl -i localhost:8093/api/books
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="bookshelf"
{"error":true,"reason":"Authentication required."}
$ curl ... (Alice) localhost:8093/api/books -> 200, one published book
$ curl ... (Lena) localhost:8093/api/books -> 200, published and draft books
$ curl ... (Alice) localhost:8093/api/loans/all -> 403 {"reason":"Your role is not allowed to do this.","error":true}
$ curl ... (Alice) localhost:8093/api/books/<draft id> -> 404 {"reason":"Not Found","error":true}
$ curl ... (Alice) localhost:8093/api/loans -> 200, only Alice's loan
$ curl -X PATCH ... (Bob) /api/reviews/<Alice's review> -> 403 {"reason":"You may not edit this review.","error":true}
Common errors and pitfalls
-
Authenticating and calling it authorized.
GuardMiddlewareonly proves that somebody is signed in. It says nothing about what they may do. -
Checking the role but not the owner. The IDOR above. Ask of every route that takes an ID: "what stops user A from sending user B’s ID?"
-
Checking only in the client. Hiding a button is not authorization. Every rule is enforced on the server.
-
Guard in the wrong order.
grouped(RoleMiddleware).grouped(authenticator)runs the role check before anyone is logged in, so every request is refused (here as a 401). Order is authenticate, guard, role. -
A route registered outside the protected group. Adding
app.get(...)instead ofsignedIn.get(...)leaves it public. Prefer a single entry point (signedIn) from which all API routes are created, and test that anonymous requests to each route are refused. -
req.auth.require(User.self)and the missing header. It throws a bare 401 with noWWW-Authenticateheader; use a helper likerequireUser()that adds it. -
Trusting a client-supplied owner. Never read
userIDfrom the body or query string to decide whose record to create. -
Leaking through errors and listings. A 403 for a record that should be secret, a total count that includes hidden rows, or an error message that names the owner all reveal what the 404 was meant to hide.
-
Forgetting the other doors. WebSocket upgrades (
shouldUpgradeon the WebSockets page), server-sent events, file downloads and background jobs need the same checks, and a job re-checks authorization when it runs, not only when it was queued. -
Logging the credentials. The logs above record the user ID and role of a refused request, not the token.
Version notes and outdated material
-
The Vapor documentation covers authentication and
GuardMiddlewarebut ships no authorization framework: there is noPolicyorcanin Vapor 4.122, so roles and policies are application code, as on this page. Third-party packages exist; verify their Swift 6 and Vapor 4.122 compatibility before adopting one. -
Authenticatable,req.authandguardMiddlewarecome from Vapor 3’sAuthenticationpackage folded into the core of Vapor 4; material built on the Vapor 2/3auth-providerno longer applies. -
The Vapor 3 era book chapters implement roles by an enum on the user and a hand-written middleware, as here, but usually stop at the role check. They predate the habit of treating ownership and query scoping as first-class, and some profile-upload examples take the user ID from the URL without an ownership check (an IDOR). Add the checks shown above before copying such code.
-
Vapor 5 (5.0.0-beta.4, not released) is a major version. The
AsyncMiddlewaresignature used here (chainingTo next: any AsyncResponder) is the 4.x one; check the Vapor 5 upgrade notes for the middleware and authenticator signatures before moving.