Security Hardening
|
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, redis
4.14.0 and VaporSecurityHeaders 4.2.0, checked on 2026-10-17. Every Swift listing on this page is the real source file
of a scratch package that compiled in the Swift 6 language mode with zero warnings. The package has ten VaporTesting
tests that all pass, and the HTTP exchanges shown were captured from the running service with curl. The Redis limiters
were compiled but not run against a live Redis server, and the nginx snippet was not run. Vapor 5 is at 5.0.0-beta.4 and
is not released yet; where it differs, that is noted. The page extends the Bookshelf service of the earlier pages of
this section.
Swift removes whole classes of vulnerabilities (buffer overflows, use after free, data races caught by the compiler),
but not the web’s logic-level ones: cross-site request forgery, missing access checks, injection through string-built
queries, leaked secrets, abusable endpoints. Vapor ships good building blocks (bcrypt, sessions, CORSMiddleware, body
limits, bound database parameters) and leaves the rest to you. This page collects the rest in one place and ends with an
OWASP Top 10:2025 checklist.
Where each control lives
A hardened Vapor service puts each control at the cheapest layer that can enforce it. Coarse checks that need no application state (TLS, connection and body caps, a first rate limit) belong at the edge; checks that apply to every response belong in global middleware; checks that need the session or the route belong in route-group middleware; checks that need business knowledge (who owns this file?) belong in the handler. The Middleware page explains the onion order that makes this work: a request that fails in an outer layer never reaches the inner ones, and a response passes through the same layers on its way out.
The rest of the page builds one small Bookshelf security layer, in this order: a login form guarded by CSRF
protection, a rate limit on the login route, security headers on every response, an explicit CORS allow-list, size
limits and an upload check, and secrets read from the environment. All of it is wired in one function,
configureSecurity(_:), shown in the secrets section. The models are the canonical Bookshelf ones of the section
(User, Tag, …), so the listings can be read next to the Fluent Models page.
Cross-site request forgery (CSRF)
The attack and why Vapor does not stop it
A browser attaches the cookies of a site to every request to that site, whichever page triggered it. If a user is
signed in to the Bookshelf and visits a hostile page, that page can make the browser submit a form to POST /tags (or
/books/.../delete, or a password change), and the request arrives with the user’s valid session cookie. The attacker
cannot read the response, but a state-changing request that is accepted has already done its damage. The defence is to
accept a state-changing request only if it provably came from your own pages.
Vapor 4.122.2 has no built-in CSRF protection: there is no CSRFMiddleware in the vapor package, none in Fluent,
none in Leaf. A third-party package exists (brokenhandsio/vapor-csrf), but its last release is 1.0.0 and its last
commit dates from September 2020, for an old Vapor 4; this page does not rely on it. The control is about forty lines of
code, so the page writes it out, which also makes the design visible.
Which requests need it? Only requests that authenticate implicitly, that is by something the browser attaches on its
own: cookies (sessions), HTTP Basic credentials, client certificates. An API that authenticates with an
Authorization: Bearer ... header set by JavaScript or by a mobile app is not forgeable this way, because a hostile
page cannot make the browser add that header. So CSRF protection applies to the cookie-session part of the service,
which is why the listings below put it on a route group and leave the public JSON API outside it.
Three layers
Good protection combines three independent layers, so a mistake in one does not open the door:
-
SameSitecookies.SameSite=Lax(the default of the Vapor session cookie) makes the browser withhold the cookie from cross-sitePOSTrequests, which already blocks the classic attack. It is a browser feature, not a server check: a few old browsers ignore it, andLaxstill sends the cookie on top-levelGETnavigations, soGEThandlers must never change state. Use.strictfor a cookie that guards something sensitive if you can accept that a user following a link from an email appears signed out. -
Fetch Metadata and
Originchecks. Modern browsers addSec-Fetch-Site(same-origin,same-site,cross-siteornone) to every request, andOriginto everyPOST. The server rejects a state-changing request whoseSec-Fetch-Siteis notsame-origin/noneor whoseOriginis not on the allow-list. Neither header can be set by page script. A request without either header comes from something that is not a browser (curl, a native app) and falls through to the token check. -
A synchroniser token. A random token is stored in the session, embedded as a hidden field in every form, and compared on submission. A hostile page cannot read your pages (the same-origin policy), so it cannot learn the token. This is the layer that works everywhere, including on browsers that send neither header.
The middleware
CSRFMiddleware mints a token on the first request of a session, lets safe methods through, and for everything else
runs the origin check and then the token check. The token may come in the X-CSRF-Token header (for fetch() and
XMLHttpRequest clients) or in the _csrf field of an URL-encoded form. The comparison is constant-time so response
timing does not reveal how many leading characters matched.
// CSRF.swift
import Vapor
/// Session-bound CSRF protection for browser forms (synchroniser token pattern).
///
/// Safe methods pass. Every other method must (1) come from an allowed origin and
/// (2) carry the token that is stored in the session, in the `X-CSRF-Token` header or the `_csrf` form field.
struct CSRFMiddleware: AsyncMiddleware {
static let sessionKey = "csrf-token"
static let headerName = "X-CSRF-Token"
static let fieldName = "_csrf"
/// Origins that may submit state-changing requests, for example `https://bookshelf.example.com`.
let allowedOrigins: Set<String>
func respond(to req: Request, chainingTo next: any AsyncResponder) async throws -> Response {
// Mint the token on the first request of the session so that forms can embed it.
let token = req.csrfToken
switch req.method {
case .GET, .HEAD, .OPTIONS:
return try await next.respond(to: req)
default:
break
}
guard OriginPolicy(allowedOrigins: allowedOrigins).allows(req) else {
throw Abort(.forbidden, reason: "Cross-origin request rejected.")
}
let submitted: String?
if let header = req.headers.first(name: Self.headerName) {
submitted = header // fetch() and XMLHttpRequest clients
} else if req.headers.contentType == .urlEncodedForm {
// Middleware runs before the route collects the body, so collect it here (within the default limit).
_ = try await req.body.collect(max: req.application.routes.defaultMaxBodySize.value).get()
submitted = try? req.content.get(String.self, at: Self.fieldName)
} else {
submitted = nil
}
guard let submitted, constantTimeEqual(submitted, token) else {
throw Abort(.forbidden, reason: "Missing or invalid CSRF token.")
}
return try await next.respond(to: req)
}
}
extension Request {
/// The CSRF token of this session. Reading it creates the session (and the token) when needed.
var csrfToken: String {
if let existing = session.data[CSRFMiddleware.sessionKey] { return existing }
let token = Data([UInt8].random(count: 32)).base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
session.data[CSRFMiddleware.sessionKey] = token
return token
}
}
/// Compares two strings without returning early at the first difference.
func constantTimeEqual(_ a: String, _ b: String) -> Bool {
let x = Array(a.utf8), y = Array(b.utf8)
guard x.count == y.count else { return false }
var difference: UInt8 = 0
for (l, r) in zip(x, y) { difference |= l ^ r }
return difference == 0
}
/// Fetch Metadata and `Origin` checks, the second layer of CSRF defence.
struct OriginPolicy: Sendable {
let allowedOrigins: Set<String>
func allows(_ req: Request) -> Bool {
// Modern browsers say how the request relates to the site.
if let site = req.headers.first(name: "Sec-Fetch-Site") {
return site == "same-origin" || site == "none"
}
// Older browsers and non-browser clients: fall back to Origin.
if let origin = req.headers.first(name: .origin) {
return allowedOrigins.contains(origin)
}
// Neither header: not a browser cross-site request (curl, a native app). The token still applies.
return true
}
}
Source: OWASP: Cross Site Request Forgery; OWASP: CSRF Prevention Cheat Sheet; Vapor docs: Middleware
Four details are worth stating:
-
The middleware must collect the body itself. Vapor collects a request body (
body: .collect, the default) inside the route’s closure, which runs after all middleware. In a middlewarereq.body.datais stillnil, andreq.content.getfails. A middleware that reads the form field directly therefore answers403to form posts whose body has not arrived yet, which is always the case for a chunked one; collecting the body first, up todefaultMaxBodySize, fixes it. The forms page and the web login page use this same middleware. Only URL-encoded forms are collected this way; a multipart upload would need a bigger limit, so upload forms send the token in the header with a small script, or in a separate request. -
Order of checks. The origin check runs before the token check, so a cross-site request is rejected even when it carries a valid token. In the test below a request with a correct token and
Origin: https://evil.examplestill gets403. -
The token rotates at login. The login handler removes it from the session, so the next page gets a new one. A token that was known before authentication must not survive it.
-
same-siteis rejected. A request fromhttps://blog.example.comtohttps://app.example.comis same-site but cross-origin. The policy accepts onlysame-originandnone, because sibling subdomains are exactly where an attacker with a foothold (an XSS bug on a forgotten subdomain) comes from.
Wiring it into the routes
The login form embeds req.csrfToken. The POST /login route sits behind the rate limiter (next section), the session
and the CSRF middleware, in that order from the outside. POST /tags is an example of a protected state-changing route,
and GET /api/tags is a public read-only API that is not behind the session at all.
// HardenedRoutes.swift
import Fluent
import Vapor
struct LoginForm: Content {
var email: String
var password: String
}
struct TagForm: Content {
var name: String
}
struct CoverReceipt: Content {
var kind: String
var bytes: Int
var storedAs: String
}
func routes(_ app: Application, settings: SecuritySettings, limiter: @escaping @Sendable (Request) -> any RateLimiter) throws {
// Public JSON API: no cookies, no CSRF token needed, read only.
app.get("api", "tags") { req async throws -> [String] in
try await Tag.query(on: req.db).sort(\.$name).all().map(\.name)
}
// Browser area: session, then the CSRF check, behind it everything that changes state.
let web = app.grouped(app.sessions.middleware, CSRFMiddleware(allowedOrigins: settings.allowedOrigins))
web.get("login") { req async throws -> Response in
let html = """
<!doctype html><title>Sign in</title>
<form method="post" action="/login">
<input type="hidden" name="_csrf" value="\(req.csrfToken)">
<input name="email" type="email" required>
<input name="password" type="password" required>
<button>Sign in</button>
</form>
"""
return Response(status: .ok, headers: ["Content-Type": "text/html; charset=utf-8"], body: .init(string: html))
}
// Login is the brute-force target: five attempts per minute and client, counted before the session or the
// CSRF check run, so a flood of forged requests is limited too.
let loginLimit = RateLimitMiddleware(
name: "login", limit: 5, window: .seconds(60),
trustForwardedFor: settings.behindProxy, failOpen: false, limiter: limiter
)
let loginRoutes = app.grouped(loginLimit, app.sessions.middleware, CSRFMiddleware(allowedOrigins: settings.allowedOrigins))
loginRoutes.post("login") { req async throws -> HTTPStatus in
let form = try req.content.decode(LoginForm.self)
let user = try await User.query(on: req.db).filter(\.$email == form.email.lowercased()).first()
// An unknown email must cost as much as a wrong password: otherwise response times reveal which emails exist.
var verified = false
if let user {
verified = (try? await req.password.async.verify(form.password, created: user.passwordHash)) ?? false
} else {
_ = try? await req.password.async.hash(form.password)
}
guard verified, let user, let id = user.id else {
throw Abort(.unauthorized, reason: "Invalid email or password.") // one message for both causes
}
req.session.data["user"] = id.uuidString
req.session.data[CSRFMiddleware.sessionKey] = nil // new token after login
return .noContent
}
// A state-changing form post: protected by CSRFMiddleware, limited to the default 64 kB body.
web.post("tags") { req async throws -> Response in
guard req.session.data["user"] != nil else { throw Abort(.unauthorized) }
let form = try req.content.decode(TagForm.self)
let name = form.name.trimmingCharacters(in: .whitespacesAndNewlines)
guard (1...40).contains(name.count) else { throw Abort(.unprocessableEntity, reason: "Tag name must be 1 to 40 characters.") }
let tag = Tag(name: name)
try await tag.save(on: req.db)
return Response(status: .created)
}
// An upload: a bigger body on this route only, and validated by signature, not by name or Content-Type.
web.on(.POST, "covers", body: .collect(maxSize: "1mb")) { req async throws -> CoverReceipt in
guard req.session.data["user"] != nil else { throw Abort(.unauthorized) }
guard let body = req.body.data, body.readableBytes > 0 else { throw Abort(.badRequest, reason: "Empty upload.") }
let kind: String
let bytes = Array(body.readableBytesView.prefix(8))
if bytes.starts(with: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) { kind = "png" }
else if bytes.starts(with: [0xFF, 0xD8, 0xFF]) { kind = "jpg" }
else { throw Abort(.unsupportedMediaType, reason: "Only PNG and JPEG covers are accepted.") }
return CoverReceipt(kind: kind, bytes: body.readableBytes, storedAs: "\(UUID().uuidString).\(kind)") // the server picks the name
}
}
Source: Vapor docs: Routing; Vapor docs: Sessions
The login handler also shows three habits that belong to the same discipline: one error message for an unknown email and
a wrong password (401 Invalid email or password.), the same bcrypt cost for both paths (hash is run when the user
does not exist, so response time does not reveal which emails are registered), and a lowercase comparison of the email.
The password hashing itself, user models and login flows are the subject of
Authentication, Passwords and
Crypto and Session Authentication and Web Login. The
session cookie used here is the one configured in the next listing; the Sessions and
Cookies page explains each attribute and rotating the session ID after login (the listing above rotates the CSRF token,
but it does not rotate the session ID, which a production login should also do).
Captured behaviour
These exchanges were captured from the running service (swift run App serve). A form fetched without a token fails;
the token that came with the form, together with the session cookie, passes:
$ curl -si -c jar.txt localhost:8097/login | head -5
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
content-length: 294
set-cookie: bookshelf-session=j4KDg2GBP+...; Max-Age=28800; Path=/; HttpOnly; SameSite=Lax
$ curl -si -b jar.txt -d 'email=reader@example.com&password=change-me' localhost:8097/login
HTTP/1.1 403 Forbidden
{"reason":"Missing or invalid CSRF token.","error":true}
$ curl -si -b jar.txt -H 'Origin: https://evil.example' --data-urlencode "_csrf=$TOKEN" -d 'email=reader@example.com&password=change-me' localhost:8097/login
HTTP/1.1 403 Forbidden
$ curl -si -b jar.txt -c jar.txt --data-urlencode "_csrf=$TOKEN" -d 'email=reader@example.com&password=change-me' localhost:8097/login
HTTP/1.1 204 No Content
(The session value is shortened. reader@example.com and change-me are the demo account that configure creates
outside production; they are placeholders.)
JSON and single-page apps
A browser client of a cookie-authenticated JSON API is forgeable like a form, but it has an advantage: it can send a
custom header. A request with Content-Type: application/json or any custom header cannot be produced by a plain
cross-site form, and a cross-origin fetch() with a custom header triggers a CORS preflight that your allow-list
refuses. The middleware above accepts the token in X-CSRF-Token, so a single-page application reads the token once
(from a GET endpoint or a meta tag) and sends it on every unsafe request. If you use bearer tokens instead of cookies,
none of this applies and you do not need the middleware. The Browser-Based
Apps page of the OAuth section explains why keeping tokens in JavaScript-readable storage trades CSRF for XSS risk. The
same concepts in Rust are on CSRF & Security Hardening for Axum.
Security headers
Security headers tell the browser to enforce restrictions the server already knows about. They cost nothing and shrink the damage of an XSS or a clickjacking bug.
| Header | Value used here | What it does |
|---|---|---|
|
|
Allows scripts, styles, images and so on only from the same origin, which blocks injected inline script and third-party loads. |
|
|
Tells the browser to use HTTPS for this host for a year. Never send it over plain HTTP (browsers ignore it there) and never in development, where it would pin |
|
|
Stops the browser guessing a content type, so a text file cannot be executed as script. |
|
|
Sends only the origin, never the path and query, to other sites and never from HTTPS to HTTP. |
|
|
Switches off browser features the Bookshelf never uses. |
|
|
The legacy equivalent of |
|
not sent |
A removed browser feature. VaporSecurityHeaders still sends it by default; pass |
Two properties matter for a Vapor application. First, the middleware must wrap the error responses too: it is registered
with at: .beginning, outside ErrorMiddleware, so a 404 or 403 produced by an Abort also carries the headers
(the captured 404 below does). Second, a Content-Security-Policy that blocks inline scripts needs your templates to
cooperate: Leaf pages must keep scripts and styles in files, or use a per-request nonce that you generate, store on the
request and add to both the header and the <script nonce=...> tags.
A hand-written middleware
The middleware below is an AsyncMiddleware with a Sendable policy struct. It is short enough to audit, and it is
what the service uses. The replaceOrAdd calls overwrite a header a handler already set, so a handler cannot weaken the
policy by accident (it can still be changed deliberately by registering a route-specific policy).
// SecurityHeaders.swift
import Vapor
import VaporSecurityHeaders
/// A hand-written security-headers middleware: fewer moving parts than a package, and easy to test.
struct SecurityHeadersMiddleware: AsyncMiddleware {
struct Policy: Sendable {
var contentSecurityPolicy = "default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
var strictTransportSecurity: String? = nil
var referrerPolicy = "strict-origin-when-cross-origin"
var permissionsPolicy = "camera=(), microphone=(), geolocation=()"
}
let policy: Policy
func respond(to req: Request, chainingTo next: any AsyncResponder) async throws -> Response {
let res = try await next.respond(to: req)
res.headers.replaceOrAdd(name: .contentSecurityPolicy, value: policy.contentSecurityPolicy)
res.headers.replaceOrAdd(name: "X-Content-Type-Options", value: "nosniff")
res.headers.replaceOrAdd(name: "Referrer-Policy", value: policy.referrerPolicy)
res.headers.replaceOrAdd(name: "Permissions-Policy", value: policy.permissionsPolicy)
if let hsts = policy.strictTransportSecurity {
res.headers.replaceOrAdd(name: .strictTransportSecurity, value: hsts)
}
return res
}
}
/// The same result with VaporSecurityHeaders 4.2.0.
func makePackageSecurityHeaders(production: Bool) -> SecurityHeaders {
let csp = ContentSecurityPolicy()
.defaultSrc(sources: CSPKeywords.`self`)
.frameAncestors(sources: CSPKeywords.none)
.baseUri(sources: CSPKeywords.`self`)
.formAction(sources: CSPKeywords.`self`)
let factory = SecurityHeadersFactory()
.with(contentSecurityPolicy: ContentSecurityPolicyConfiguration(value: csp))
.with(contentTypeOptions: .init(option: .nosniff))
.with(frameOptions: .init(option: .deny))
.with(XSSProtection: nil) // the legacy header is not needed
.with(referrerPolicy: .init(.strictOriginWhenCrossOrigin))
if production {
factory.with(strictTransportSecurity: .init(maxAge: 31_536_000, includeSubdomains: true, preload: false))
}
return factory.build()
}
VaporSecurityHeaders
brokenhandsio/VaporSecurityHeaders 4.2.0 (its last release; last commit November 2024) packages the same idea with a
builder API; the second function of the listing above builds the equivalent configuration. Its defaults are
X-Content-Type-Options: nosniff, Content-Security-Policy: default-src 'self', X-Frame-Options: DENY and
X-XSS-Protection on, with HSTS, Referrer-Policy and the Server header only when configured. The package is a
Middleware (futures), not an AsyncMiddleware, and its manifest uses tools version 5.2, so it compiles in Swift 5
mode as a dependency while your own target stays in Swift 6 mode. It was added with
.package(url: "https://github.com/brokenhandsio/VaporSecurityHeaders.git", exact: "4.2.0") and the product
VaporSecurityHeaders. A test that registers makePackageSecurityHeaders(production: true) and prints the response
headers of a request shows:
x-content-type-options: nosniff
content-security-policy: default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
x-frame-options: DENY
strict-transport-security: max-age=31536000; includeSubDomains;
referrer-policy: strict-origin-when-cross-origin
Note the trailing semicolon after includeSubDomains: the package builds the header with a trailing ; when preload
is false. Browsers accept it. Weigh the choice: a package that has not had a release in two years is a dependency to
audit, whereas the hand-written middleware is code you own; either is fine as long as a test asserts the headers.
Captured from the running service (the headers appear on a 404 as well):
$ curl -si localhost:8097/nope
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
content-security-policy: default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
{"reason":"Not Found","error":true}
Rate limiting
Rate limiting caps how often one client can do something. It slows credential stuffing and brute force on the login route, scraping, and accidental overload. It is not authentication, and it does not stop a distributed attack, but it turns a free attack into an expensive one.
Algorithms
| Algorithm | How it works | Trade-off |
|---|---|---|
Fixed window |
One counter per client and time window; the counter resets when the window ends. |
One integer per key and trivial in Redis ( |
Sliding window log |
Remember the time of every accepted request in the last window and count them. |
Exact, no boundary burst, but it stores up to |
Token bucket |
A bucket refills at a steady rate; each request takes a token. |
Allows controlled bursts. Usually implemented at the proxy or with Redis scripts; see the Resilience section below. |
Where to enforce it
-
At the proxy or load balancer, for coarse limits that do not need the application: nginx
limit_req_zone/limit_req, an API gateway or a cloud WAF. These protect the Swift process from load it never has to parse. -
In middleware, for limits that need application knowledge: per route group (login versus catalogue), per authenticated user rather than per address, or per API key. This page implements that.
-
Not with Gatekeeper. The Vapor-era package for this,
ml-archive/gatekeeper("Rate limiting middleware for Vapor"), is archived on GitHub, so it receives no fixes. Write the small middleware below or use the proxy.
A proxy limit looks like this in nginx (a sketch; this snippet was not run):
# http {} context: 10 MB of state, 5 requests per minute per client address
limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m;
server {
client_max_body_size 1m; # reject big bodies before Vapor sees them
location = /login {
limit_req zone=login burst=3 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Forwarded-For $remote_addr; # overwrite, never append a client-supplied value
}
}
Source: nginx: ngx_http_limit_req_module
The limiter protocol and four implementations
RateLimiter has one method, hit, that records a request and returns a decision. There are four implementations:
fixed and sliding windows in process memory (an actor, so the dictionaries are isolated without locks) and the same
two in Redis. The Redis versions run a Lua script so that the read-modify-write is atomic across all instances of the
service; with separate INCR and EXPIRE commands a crash between them would leave a counter that never expires and
blocks a client forever. The middleware turns a decision into 429 Too Many Requests with Retry-After (RFC 9110) and
adds RateLimit-Limit and RateLimit-Remaining headers (the structured RateLimit header fields are an IETF draft, so
treat the names as conventional).
// RateLimit.swift
import Vapor
import Redis
struct RateLimitDecision: Sendable, Equatable {
var allowed: Bool
var remaining: Int
/// Seconds until a request would be allowed again (0 when allowed).
var retryAfter: Int
}
protocol RateLimiter: Sendable {
func hit(_ key: String, limit: Int, window: Duration) async throws -> RateLimitDecision
}
/// Fixed window in process memory: one counter per key and window. Cheap, but every instance counts separately,
/// and a client can burst up to twice the limit across a window boundary.
actor MemoryFixedWindowLimiter: RateLimiter {
private struct Bucket { var start: ContinuousClock.Instant; var count: Int }
private var buckets: [String: Bucket] = [:]
private let clock = ContinuousClock()
func hit(_ key: String, limit: Int, window: Duration) async throws -> RateLimitDecision {
let now = clock.now
var bucket = buckets[key] ?? Bucket(start: now, count: 0)
if now - bucket.start >= window { bucket = Bucket(start: now, count: 0) }
bucket.count += 1
buckets[key] = bucket
// Drop expired buckets now and then so that the dictionary cannot grow without bound.
if buckets.count > 10_000 { buckets = buckets.filter { now - $0.value.start < window } }
let remainingWindow = window - (now - bucket.start)
return RateLimitDecision(
allowed: bucket.count <= limit,
remaining: max(0, limit - bucket.count),
retryAfter: bucket.count <= limit ? 0 : Int(remainingWindow.components.seconds) + 1
)
}
}
/// Sliding log in process memory: remembers the time of every accepted request in the last window. Exact, but
/// stores up to `limit` timestamps per key.
actor MemorySlidingWindowLimiter: RateLimiter {
private var log: [String: [ContinuousClock.Instant]] = [:]
private let clock = ContinuousClock()
func hit(_ key: String, limit: Int, window: Duration) async throws -> RateLimitDecision {
let now = clock.now
var times = (log[key] ?? []).filter { now - $0 < window }
guard times.count < limit else {
log[key] = times
let oldest = times[0]
let wait = window - (now - oldest)
return RateLimitDecision(allowed: false, remaining: 0, retryAfter: Int(wait.components.seconds) + 1)
}
times.append(now)
log[key] = times
return RateLimitDecision(allowed: true, remaining: limit - times.count, retryAfter: 0)
}
}
/// Fixed window shared by every instance, in Redis. INCR and PEXPIRE run in one Lua script, so a crash between
/// the two cannot leave a counter without an expiry.
struct RedisFixedWindowLimiter: RateLimiter {
let redis: Application.Redis
private static let script = """
local count = redis.call('INCR', KEYS[1])
if count == 1 then redis.call('PEXPIRE', KEYS[1], ARGV[1]) end
return {count, redis.call('PTTL', KEYS[1])}
"""
func hit(_ key: String, limit: Int, window: Duration) async throws -> RateLimitDecision {
let windowMs = Int(window.components.seconds * 1000)
let reply = try await redis.send(
command: "EVAL",
with: [.init(from: Self.script), .init(from: 1), .init(from: "rl:fixed:\(key)"), .init(from: windowMs)]
)
guard let items = reply.array, items.count == 2, let count = items[0].int, let ttlMs = items[1].int else {
throw Abort(.internalServerError, reason: "Unexpected reply from Redis.")
}
return RateLimitDecision(
allowed: count <= limit,
remaining: max(0, limit - count),
retryAfter: count <= limit ? 0 : max(1, (ttlMs + 999) / 1000)
)
}
}
/// Sliding log shared by every instance, in a Redis sorted set (score = time in milliseconds).
struct RedisSlidingWindowLimiter: RateLimiter {
let redis: Application.Redis
private static let script = """
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window)
local count = redis.call('ZCARD', KEYS[1])
if count < limit then
redis.call('ZADD', KEYS[1], now, ARGV[4])
redis.call('PEXPIRE', KEYS[1], window)
return {1, limit - count - 1, 0}
end
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
return {0, 0, tonumber(oldest[2]) + window - now}
"""
func hit(_ key: String, limit: Int, window: Duration) async throws -> RateLimitDecision {
let windowMs = Int(window.components.seconds * 1000)
let nowMs = Int(Date().timeIntervalSince1970 * 1000) // clocks of the instances should be in sync (NTP)
let reply = try await redis.send(
command: "EVAL",
with: [
.init(from: Self.script), .init(from: 1), .init(from: "rl:sliding:\(key)"),
.init(from: nowMs), .init(from: windowMs), .init(from: limit), .init(from: UUID().uuidString),
]
)
guard let items = reply.array, items.count == 3,
let allowed = items[0].int, let remaining = items[1].int, let waitMs = items[2].int else {
throw Abort(.internalServerError, reason: "Unexpected reply from Redis.")
}
return RateLimitDecision(allowed: allowed == 1, remaining: remaining, retryAfter: (waitMs + 999) / 1000)
}
}
/// Applies a limiter to the routes behind it. The key is the client address plus a name for the group of routes.
struct RateLimitMiddleware: AsyncMiddleware {
let name: String
let limit: Int
let window: Duration
/// Set only when a proxy you control sets `X-Forwarded-For`; otherwise a client can invent its own address.
let trustForwardedFor: Bool
/// What to do when the limiter itself fails (Redis down): `true` lets the request through.
let failOpen: Bool
let limiter: @Sendable (Request) -> any RateLimiter
func respond(to req: Request, chainingTo next: any AsyncResponder) async throws -> Response {
let key = "\(name):\(clientAddress(of: req))"
let decision: RateLimitDecision
do {
decision = try await limiter(req).hit(key, limit: limit, window: window)
} catch {
req.logger.error("Rate limiter failed", metadata: ["error": "\(error)", "failOpen": "\(failOpen)"])
if failOpen { return try await next.respond(to: req) }
throw Abort(.serviceUnavailable)
}
guard decision.allowed else {
throw Abort(.tooManyRequests, headers: [
"Retry-After": String(decision.retryAfter),
"RateLimit-Limit": String(limit),
"RateLimit-Remaining": "0",
])
}
let res = try await next.respond(to: req)
res.headers.replaceOrAdd(name: "RateLimit-Limit", value: String(limit))
res.headers.replaceOrAdd(name: "RateLimit-Remaining", value: String(decision.remaining))
return res
}
private func clientAddress(of req: Request) -> String {
if trustForwardedFor,
let forwarded = req.headers.first(name: "X-Forwarded-For"),
let last = forwarded.split(separator: ",").last {
// The proxy appends the address it saw; the right-most entry is the only one a client cannot forge.
return last.trimmingCharacters(in: .whitespaces)
}
return req.remoteAddress?.ipAddress ?? "unknown"
}
}
Decisions encoded in the code, each of which you should make consciously:
-
The key. The listing counts per client address and route-group name.
req.remoteAddressis the TCP peer, which behind a load balancer is the balancer. SettingtrustForwardedFormakes the middleware use the last entry ofX-Forwarded-For, the address that your own proxy appended. Never trust the first entry or the header on a service that is reachable without the proxy: a client can send any value and choose its own bucket. For authenticated routes a better key is the user ID. -
Fail open or closed. If Redis is down,
failOpen: trueserves the request without a limit (availability first) andfailOpen: falseanswers503(security first). The login route above fails closed, a read-only catalogue would fail open. -
Memory or Redis. The memory limiters count per process, so with N instances an attacker gets N times the limit. Use them in tests and single-instance deployments; use Redis (or the proxy) otherwise.
configureSecuritypicks Redis whenREDIS_URLis set. -
Clocks. The sliding-window script takes the time from the Swift process. Instances with clocks far apart skew the window; keep NTP running, or have the script call
redis.call('TIME')instead. -
Memory growth.
MemoryFixedWindowLimiterprunes expired buckets after 10 000 keys; the sliding log trims a key’s timestamps on every hit. A key that never returns stays in the dictionary until then.
The captured behaviour: five attempts per minute reach the CSRF check, the sixth is stopped before it, with a
Retry-After:
$ for i in 1 2 3; do curl -s -o /dev/null -w "%{http_code} " -d 'a=b' localhost:8097/login; done
403 403 429
$ curl -si -d 'a=b' localhost:8097/login
HTTP/1.1 429 Too Many Requests
Retry-After: 55
RateLimit-Limit: 5
RateLimit-Remaining: 0
(Three requests made earlier in the same minute, the ones without a token, from another origin and the successful login, had already used three of the five attempts. The limiter sits outside the CSRF check, so a flood of forged requests is limited too.)
The Rate Limiter page of the Resilience section covers the concepts for the JVM, and distributed rate limiting with Bucket4j shows the token-bucket variant on shared storage; the algorithms are the same, only the language differs. The Redis and Caching page covers the Redis client used here.
CORS done right
Cross-Origin Resource Sharing is not a security feature of the server: it is a way to relax the browser’s same-origin policy for specific origins. Enabling CORS never protects anything; a wrong CORS policy exposes data. It is also unrelated to CSRF: CORS controls whether a page may read a response, while a forged form post never needs to read it.
Vapor’s CORSMiddleware takes a Configuration. The value that matters is allowedOrigin:
allowedOrigin |
Effect |
|---|---|
|
no origin allowed |
|
|
|
copies the request’s |
|
an explicit allow-list: the origin is echoed only when it is in the list; anything else gets no |
|
a fixed string you provide |
Use .any with the exact origins (scheme, host and port). Keep allowedMethods and allowedHeaders to what the
clients use, and set allowCredentials: true only when cookies must cross origins; the browser rejects * together
with credentials anyway. The middleware is registered with at: .beginning so error responses carry CORS headers too
(otherwise the browser shows an opaque network error instead of your 403). Captured from the running service (allowed
origin http://localhost:8080):
$ curl -si -X OPTIONS -H 'Origin: http://localhost:8080' -H 'Access-Control-Request-Method: GET' localhost:8097/api/tags
HTTP/1.1 200 OK
access-control-allow-origin: http://localhost:8080
access-control-allow-headers: accept, content-type, x-csrf-token
access-control-allow-methods: GET, POST, OPTIONS
access-control-max-age: 600
access-control-allow-credentials: true
$ curl -si -X OPTIONS -H 'Origin: https://evil.example' -H 'Access-Control-Request-Method: GET' localhost:8097/api/tags
HTTP/1.1 200 OK
access-control-allow-headers: accept, content-type, x-csrf-token
access-control-allow-methods: GET, POST, OPTIONS
access-control-max-age: 600
access-control-allow-credentials: true
The second response has no access-control-allow-origin, so the browser blocks the cross-origin read. The 200 status
on the preflight is normal; the missing header is the refusal. The general concepts are on the site’s
CORS page.
Request size limits and upload validation
Every byte a client sends costs memory, disk or CPU. Vapor’s defaults are conservative: a collected body (the default
strategy) is limited to app.routes.defaultMaxBodySize, which is 16kb. The listing sets it to 64kb explicitly, so
the limit is visible in the code, and raises it for one route only:
-
app.routes.defaultMaxBodySize = "64kb"is the global limit for routes that collect their body (the default). -
web.on(.POST, "covers", body: .collect(maxSize: "1mb")) { ... }raises it for one route. -
body: .streamhands the handler the stream so it can enforce its own limit while writing to disk; the File Uploads and Streaming page shows the counting and signature checks. -
A body that exceeds the limit is answered
413 Payload Too Large. The limit applies when the body is collected, which happens inside the route (after middleware), so a request whose middleware rejects it first never triggers the size check; see the pitfalls.
The in-memory test client (app.testing(), the default) does not go through the HTTP server and so does not enforce
body limits. The size test in the package uses app.testing(method: .running(port: 0)), which starts a real server, and
asserts 413. Put a limit at the proxy as well (client_max_body_size in nginx above): the proxy stops the body before
it reaches the process.
Upload validation is the second half of this. Everything in an upload is attacker-controlled: the bytes, the filename
and the declared Content-Type. The /covers route in the routes listing accepts only a PNG or JPEG signature (the
first bytes of the body), ignores the claimed content type, and picks the stored name itself (UUID().uuidString plus
an extension chosen by the server) so a name such as ../../etc/passwd can never reach the file system. The full
checklist (limits, signatures, filenames, location outside Public/, serving with nosniff and attachment,
ownership, antivirus) is on the File Uploads and Streaming page.
TLS everywhere
Cookies, passwords and tokens travel in the clear over HTTP. The rules are short:
-
Terminate TLS either in the Vapor process (
app.http.server.configuration.tlsConfiguration) or at a reverse proxy; the Server Configuration page shows both, with certificates, HTTP/2 and renewal. -
Redirect HTTP to HTTPS at the proxy. VaporSecurityHeaders also ships an
HTTPSRedirectMiddleware; it decides from theX-Forwarded-Protoheader, so it is only as trustworthy as the proxy that sets it. -
Set
Strict-Transport-Securityonce HTTPS works (production only, as the listing does) andisSecure: trueon session cookies, so a browser never sends the session over HTTP. Behind a TLS-terminating proxy the application sees HTTP but the browser sees HTTPS, soSecureis correct. -
Use TLS for every connection the service opens: PostgreSQL and MySQL (their TLS configuration), Redis (a
rediss://URL makesRedisConfigurationuse a default TLS configuration), outgoingreq.clientcalls. A database on the same private network is not a reason to skip it. -
The
preloaddirective of HSTS enrols a domain in browser preload lists and is hard to undo; only add it after reading hstspreload.org requirements and with every subdomain on HTTPS.
Secrets handling
A secret is anything that grants access: signing keys, database and Redis passwords, API keys, the private key of an
APNs or JWT signer. The rules: never in source control, in Package.swift, in a Docker image layer or in a log line;
read once at start-up; fail fast when missing in production. The
Configuration and Environment page explains Environment.get,
dotenv files and Environment.secret(key:) in full. The listing below shows the part that matters for security:
SecuritySettings.load accepts either NAME or NAME_FILE (a path to a file mounted by Docker or Kubernetes),
supplies a development value only outside release builds, and refuses to start in production with a missing key or one
that still starts with the placeholder text.
// Hardening.swift
import Fluent
import Redis
import Vapor
/// Everything that comes from the environment, read once at start-up. Values below are placeholders.
struct SecuritySettings: Sendable {
var allowedOrigins: Set<String>
var signingKey: String
var redisURL: String?
var behindProxy: Bool
static func load(for environment: Environment) async throws -> SecuritySettings {
// NAME wins, otherwise NAME_FILE points at a file mounted by Docker or Kubernetes.
func secret(_ name: String) async throws -> String? {
if let value = Environment.get(name) { return value }
return try await Environment.secret(key: "\(name)_FILE")
}
let origins = (Environment.get("ALLOWED_ORIGINS") ?? "http://localhost:8080")
.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
var key = try await secret("SIGNING_KEY")
if key == nil && !environment.isRelease { key = "change-me-development-only" } // never in production
guard let key, key.utf8.count >= 16 else {
throw Abort(.internalServerError, reason: "SIGNING_KEY (or SIGNING_KEY_FILE) must be set to at least 16 characters.")
}
if environment.isRelease && key.hasPrefix("change-me") {
throw Abort(.internalServerError, reason: "SIGNING_KEY still has its placeholder value.")
}
return SecuritySettings(
allowedOrigins: Set(origins),
signingKey: key,
redisURL: Environment.get("REDIS_URL"),
behindProxy: Environment.get("BEHIND_PROXY") == "true"
)
}
}
func configureSecurity(_ app: Application) async throws {
let settings = try await SecuritySettings.load(for: app.environment)
let production = app.environment == .production
// 1. Request size limits: the global default is small; routes that need more say so explicitly.
app.routes.defaultMaxBodySize = "64kb"
// 2. Global response middleware, outermost first. CORS and headers go before ErrorMiddleware so that error
// responses carry them too.
app.middleware.use(
SecurityHeadersMiddleware(policy: .init(
strictTransportSecurity: production ? "max-age=31536000; includeSubDomains" : nil
)),
at: .beginning
)
app.middleware.use(
CORSMiddleware(configuration: .init(
allowedOrigin: .any(Array(settings.allowedOrigins)), // an explicit list, never .all or .originBased
allowedMethods: [.GET, .POST, .OPTIONS],
allowedHeaders: [.accept, .contentType, .init(CSRFMiddleware.headerName)],
allowCredentials: true,
cacheExpiration: 600
)),
at: .beginning
)
// 3. Sessions with a hardened cookie.
let secureCookie = production
app.sessions.configuration = SessionsConfiguration(cookieName: "bookshelf-session") { id in
HTTPCookies.Value(
string: id.string, expires: nil, maxAge: 60 * 60 * 8, domain: nil, path: "/",
isSecure: secureCookie, isHTTPOnly: true, sameSite: .lax
)
}
app.sessions.use(.memory)
// 4. A rate limiter: Redis when configured (shared by every instance), memory otherwise.
let limiter: @Sendable (Request) -> any RateLimiter
if let redisURL = settings.redisURL {
app.redis.configuration = try RedisConfiguration(url: redisURL)
let redis = app.redis
limiter = { _ in RedisFixedWindowLimiter(redis: redis) }
} else {
let shared = MemoryFixedWindowLimiter()
limiter = { _ in shared }
}
try routes(app, settings: settings, limiter: limiter)
}
The values change-me-development-only and change-me are obvious placeholders for a local run; no real key appears
anywhere. Environment.secret returns nil when the variable is unset or reading failed, so a permission problem on
the mounted file looks like "not configured"; the load function turns that into one clear start-up error. Other habits:
do not log req.headers or whole Request descriptions (they contain Cookie and Authorization), do not echo
configuration at start-up, keep .env files out of git (and scan the repository with a secret scanner), and rotate any
secret that was ever committed, even briefly. For AWS-hosted services prefer instance roles over long-lived keys.
The default ErrorMiddleware in production shows only the reason of an Abort, never a stack trace, but reasons are
shown to the client: write them for a stranger, as the login handler does. See the
Error Handling page.
Keeping dependencies up to date
Vapor has had security advisories (the GitHub security advisories of vapor/vapor list, among others, an integer
overflow in URI parsing in January 2024 and an incorrect error handling that could crash a server in October 2023), and
so have SwiftNIO and the HTTP and TLS libraries underneath it. A service pinned to a version from two years ago carries
those flaws.
-
Commit
Package.resolvedso builds are reproducible, and update on purpose:swift package updatefollowed by the test suite. -
Let a bot propose updates: Dependabot supports the Swift package ecosystem (
package-ecosystem: "swift"in.github/dependabot.yml). GitHub’s dependency graph also readsPackage.resolved; for any other scanner, check that it supports Swift packages before relying on it. -
Subscribe to the GitHub security advisories of
vapor/vaporand the packages you use, and pin the Swift toolchain in the Dockerfile so the base image is rebuilt on a schedule too. -
Prefer maintained packages. This page found two (
vapor-csrf, 2020; Gatekeeper, archived) that are not, and one (VaporSecurityHeaders) whose last release is from 2024. Know which of your dependencies are in that state.
Input handling, injection and output encoding
Most injection bugs come from building a string out of user input. Vapor makes the safe way the easy way:
-
Fluent binds values:
.filter(\.$email == form.email)sends the email as a parameter, never spliced into SQL. The danger is raw SQL: with SQLKit, interpolate values with\(bind: value), never\(unsafeRaw: value); see the SQLKit and Raw SQL page. -
Validation (
Validatable,validations.add) rejects malformed input before it reaches a model; see the Validation page. Validate length and format, and keep the allow-list approach: decide what is allowed, reject the rest. -
Leaf escapes
#(variable)output for HTML by default; raw output (#unsafeHTML(...)) bypasses it and should be reserved for HTML you generated yourself. See the Leaf Templates page. -
Mass assignment: decode into a purpose-built
Contentstruct (TagForm,LoginFormabove) instead of decoding straight into a Fluent model, so a client cannot setroleoridby adding a field. -
Access control is a handler concern: check that the signed-in user may touch this record (
filter(\.$user.$id == userID)), not only that they are signed in.
An OWASP Top 10:2025 checklist for Vapor
The table maps the controls on this page and its neighbours to the OWASP Top 10:2025, whose categories are listed with the identifiers used on the OWASP site. The 2025 edition replaces the 2021 list and adds Software Supply Chain Failures (A03) and Mishandling of Exceptional Conditions (A10); server-side request forgery is folded into A01.
| Risk | Vapor feature or control |
|---|---|
A01:2025 Broken Access Control |
Route-group middleware for authentication; ownership filters in queries; role checks in handlers; CSRF middleware and |
A02:2025 Security Misconfiguration |
Security headers; |
A03:2025 Software Supply Chain Failures |
Committed |
A04:2025 Cryptographic Failures |
TLS everywhere with HSTS; |
A05:2025 Injection |
Fluent parameter binding; SQLKit |
A06:2025 Insecure Design |
Separate input and output types ( |
A07:2025 Authentication Failures |
Bcrypt with a sensible cost; the login route rate-limited; rotate the session ID and CSRF token at login (see Sessions and Cookies); expiry on sessions and tokens; HTTP-only cookies; optionally 2FA (see the OAuth section’s authentication methods page). |
A08:2025 Software or Data Integrity Failures |
Signed tokens validated with the right algorithm and key (see JSON Web Tokens (JWT)); never deserialise untrusted data into privileged types; verify webhook signatures; build from a locked |
A09:2025 Security Logging & Alerting Failures |
|
A10:2025 Mishandling of Exceptional Conditions |
Default |
Testing the controls
Security controls that no test asserts quietly disappear in a refactor (see Testing for the test setup). The package has ten tests; two are shown here (the whole file is in the scratch package): the CSRF test walks the four cases of the table above, and the rate-limit test shows that forged requests count against the limit.
@Test func formPostNeedsTheSessionToken() async throws {
try await withApp { app in
let (cookie, token) = try await fetchForm(app)
let good = LoginPost(email: "reader@example.com", password: "change-me", _csrf: token)
// a wrong token
try await post(app, "login", cookie: cookie,
form: LoginPost(email: good.email, password: good.password, _csrf: "forged")) {
#expect($0.status == .forbidden)
}
// the right token, wrong password, then the right password
try await post(app, "login", cookie: cookie,
form: LoginPost(email: good.email, password: "wrong", _csrf: token)) {
#expect($0.status == .unauthorized)
}
try await post(app, "login", cookie: cookie, form: good) { #expect($0.status == .noContent) }
}
}
@Test func loginIsRateLimited() async throws {
try await withApp { app in
var statuses: [HTTPStatus] = []
for _ in 1...7 {
try await app.testing().test(.POST, "login", beforeRequest: { req in
try req.content.encode(["email": "x@example.com", "password": "nope"], as: .urlEncodedForm)
}, afterResponse: { res async throws in statuses.append(res.status) })
}
// five attempts reach the CSRF check (403), the rest are stopped before it
#expect(statuses == [.forbidden, .forbidden, .forbidden, .forbidden, .forbidden,
.tooManyRequests, .tooManyRequests])
}
}
Source: Vapor docs: Testing
The helper withApp creates an Application with .testing, runs configure, and shuts it down; fetchForm reads
the session cookie and the hidden _csrf value from GET /login. The other tests cover the cross-origin rejection
with a valid token, the header token for fetch() clients, the security headers on a success and a 404, 413 on a 70
kB body (on a real server), signature-based upload checks, the CORS allow-list and both memory limiters.
Swift 6 concurrency notes
-
Middleware(and soAsyncMiddleware) requiresSendablein 4.122.2; a middleware is a struct ofSendablevalues (Set<String>, aPolicystruct, a closure marked@Sendable). Avarcaptured by a middleware or route closure is a Swift 6 error. -
The memory limiters are
actor`s: their dictionaries are isolated, `hitisasync, and no lock is needed. TheContinuousClockis monotonic, so changes of the wall clock cannot reopen a window. -
Application.RedisisSendable,Request.Redisis not. The first draft storedany RedisClient(fromreq.redis) in aSendablestruct and failed with "stored property 'redis' of 'Sendable'-conforming struct … has non-Sendable type". The fix is to captureapp.redisonce when configuring and keep that. -
The limiter factory is a
@Sendable (Request) -> any RateLimiterclosure, so the choice (memory or Redis) is made once, at configuration time, not per request.
Common errors and pitfalls
-
Every form post is rejected with
403although the token is right. The middleware readreq.contentbefore the body was collected. Collect the body in the middleware (as the listing does), or send the token in theX-CSRF-Tokenheader. -
A multipart upload form fails with
413. The CSRF middleware collects URL-encoded bodies up to the default limit; for upload forms send the token in a header. -
403for a legitimate AJAX call. The client did not sendX-CSRF-Token, or sent a stale one after login (the token rotates at login). -
CORS error in the browser although the server answered.
CORSMiddlewareis registered afterErrorMiddleware, so error responses lack the headers; or the origin has a trailing slash or a different port than the allow-list entry. -
Origin allow-list rejects your own site. Behind a proxy the browser’s
Originis the public one; the allow-list must containhttps://bookshelf.example.com, not the internal address. -
Everyone shares one rate-limit bucket. Behind a load balancer
req.remoteAddressis the balancer. EnabletrustForwardedForfor a proxy you control. The opposite error is trustingX-Forwarded-Foron a service the clients can reach directly. -
Redis counter never expires.
INCRandEXPIREas two commands with a crash in between. Use one Lua script, as the listing does. -
Rate limit seems to double. Fixed windows allow a burst across the boundary; use the sliding window when that matters.
-
Body-limit test passes with a huge body. The in-memory test client skips the HTTP layer; use
app.testing(method: .running(port: 0)). -
413is never returned for a route that rejects the request first. Middleware runs before the route collects the body, so a request refused by CSRF or authentication is refused with403/401without its body being read. -
HSTS on
localhost. A browser that saw HSTS forlocalhostkeeps forcing HTTPS on later HTTP runs; send HSTS only in production. -
Securecookie not sent in development.Securecookies are never sent over plain HTTP; the listing enablesisSecureonly in production. -
The CSP blocks inline
<script>in a Leaf page. That is the policy working. Move the script to a file underPublic/or add a per-request nonce. -
Placeholder secret in production.
SecuritySettings.loadrefuses to start in that case; do not weaken the check to "make the container start".
Version notes and where the books are outdated
-
Vapor 4.122.2: no CSRF, rate-limit or security-header middleware ships in the
vaporpackage; theCORSMiddlewareandFileMiddlewareare the only security-related built-ins besides sessions and bcrypt. -
Vapor 5 (5.0.0-beta.4, not released): the signatures on this page are for 4.x, and Vapor 5 removes the NIO types from its public API, so check What’s New, Upgrading and Vapor 5 before moving. The controls and their reasons do not change.
-
Browser behaviour:
SameSite=Laxas a default for cookies without the attribute is browser behaviour (Chromium, Firefox, Safari differ in details); Vapor’s own cookie factory setsLaxexplicitly.Sec-Fetch-Siteis sent by current Chromium, Firefox and Safari releases; the code keeps theOriginand token fallbacks. -
Gatekeeper and
vapor-csrf: unmaintained, as stated above. -
OWASP Top 10:2025 replaces the 2021 list used in older material; its A03 and A10 are new.
The books on Vapor (Condon et al.'s Kodeco book on a Vapor 3 era codebase, Yeung’s and Patel’s Packt books) treat CSRF
with a token hidden in the Leaf forms and treat security mostly as authentication. That token idea is right and is what
the middleware above does, but the books were written before SameSite cookies became the browser default, before Fetch
Metadata headers existed, and before the OWASP list changed; they do not cover security headers, rate limiting, CORS
configuration risks (.originBased), body limits or dependency hygiene. Vapor 3 and early Vapor 4 code registers
middleware through configure(_:) services and uses EventLoopFuture; use the AsyncMiddleware form shown here.
References
-
web.dev: Protect your resources from web attacks with Fetch Metadata
-
CSRF & Security Hardening in Axum (the same concepts in Rust)