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.

Where each security control sits on the way to a Vapor handler: the edge with TLS and caps, global middleware with headers and CORS, route-group middleware with rate limit, sessions and CSRF, the handler with validation and ownership checks, and the response

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:

  1. SameSite cookies. SameSite=Lax (the default of the Vapor session cookie) makes the browser withhold the cookie from cross-site POST requests, which already blocks the classic attack. It is a browser feature, not a server check: a few old browsers ignore it, and Lax still sends the cookie on top-level GET navigations, so GET handlers must never change state. Use .strict for a cookie that guards something sensitive if you can accept that a user following a link from an email appears signed out.

  2. Fetch Metadata and Origin checks. Modern browsers add Sec-Fetch-Site (same-origin, same-site, cross-site or none) to every request, and Origin to every POST. The server rejects a state-changing request whose Sec-Fetch-Site is not same-origin/none or whose Origin is 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.

  3. 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
    }
}

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 middleware req.body.data is still nil, and req.content.get fails. A middleware that reads the form field directly therefore answers 403 to form posts whose body has not arrived yet, which is always the case for a chunked one; collecting the body first, up to defaultMaxBodySize, 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.example still gets 403.

  • 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-site is rejected. A request from https://blog.example.com to https://app.example.com is same-site but cross-origin. The policy accepts only same-origin and none, 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
    }
}

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

Content-Security-Policy

default-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'

Allows scripts, styles, images and so on only from the same origin, which blocks injected inline script and third-party loads. frame-ancestors, base-uri and form-action do not fall back to default-src, so they must be listed.

Strict-Transport-Security

max-age=31536000; includeSubDomains (production only)

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 localhost.

X-Content-Type-Options

nosniff

Stops the browser guessing a content type, so a text file cannot be executed as script.

Referrer-Policy

strict-origin-when-cross-origin

Sends only the origin, never the path and query, to other sites and never from HTTPS to HTTP.

Permissions-Policy

camera=(), microphone=(), geolocation=()

Switches off browser features the Bookshelf never uses.

X-Frame-Options

DENY (VaporSecurityHeaders default)

The legacy equivalent of frame-ancestors 'none', for old browsers.

X-XSS-Protection

not sent

A removed browser feature. VaporSecurityHeaders still sends it by default; pass nil to leave it out.

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 (INCR + PEXPIRE). A client can use its limit at the end of one window and again at the start of the next, up to twice the limit in a short span.

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 limit entries per key (a Redis sorted set).

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
    }
}

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.remoteAddress is the TCP peer, which behind a load balancer is the balancer. Setting trustForwardedFor makes the middleware use the last entry of X-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: true serves the request without a limit (availability first) and failOpen: false answers 503 (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. configureSecurity picks Redis when REDIS_URL is 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. MemoryFixedWindowLimiter prunes 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

.none

no origin allowed

.all

Access-Control-Allow-Origin: *; fine for fully public, credential-less data only

.originBased

copies the request’s Origin into the response, which is the same as allowing every origin, and with allowCredentials: true it lets any website read authenticated responses. This is the library default (Configuration.default()).

.any([...])

an explicit allow-list: the origin is echoed only when it is in the list; anything else gets no Access-Control-Allow-Origin header

.custom(String)

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: .stream hands 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 the X-Forwarded-Proto header, so it is only as trustworthy as the proxy that sets it.

  • Set Strict-Transport-Security once HTTPS works (production only, as the listing does) and isSecure: true on 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, so Secure is correct.

  • Use TLS for every connection the service opens: PostgreSQL and MySQL (their TLS configuration), Redis (a rediss:// URL makes RedisConfiguration use a default TLS configuration), outgoing req.client calls. A database on the same private network is not a reason to skip it.

  • The preload directive 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.resolved so builds are reproducible, and update on purpose: swift package update followed 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 reads Package.resolved; for any other scanner, check that it supports Swift packages before relying on it.

  • Subscribe to the GitHub security advisories of vapor/vapor and 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 Content struct (TagForm, LoginForm above) instead of decoding straight into a Fluent model, so a client cannot set role or id by 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 SameSite cookies; an explicit CORS allow-list; for outgoing calls with req.client, an allow-list of hosts and no following of user-supplied URLs (SSRF).

A02:2025 Security Misconfiguration

Security headers; Secure/HttpOnly cookies; production environment (--env production) so errors are not detailed; no .originBased CORS; internal ports not published; default credentials removed (the demo account only exists outside production).

A03:2025 Software Supply Chain Failures

Committed Package.resolved, exact or reviewed versions, Dependabot for the Swift ecosystem, GitHub advisories, pinned toolchain image, an audit of unmaintained packages.

A04:2025 Cryptographic Failures

TLS everywhere with HSTS; app.passwords.use(.bcrypt(cost: 12)) in production; random tokens from [UInt8].random(count:); no home-made cryptography (SwiftCrypto is re-exported by Vapor); secrets from the environment or mounted files.

A05:2025 Injection

Fluent parameter binding; SQLKit bind:; Leaf auto-escaping; CSP; validation of every input; no shell commands built from input.

A06:2025 Insecure Design

Separate input and output types (Content DTOs); rate limits on login and other expensive actions; one error message for login failures; threat-model abuse cases (enumeration, scraping) before launch.

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 Package.resolved.

A09:2025 Security Logging & Alerting Failures

req.logger with structured metadata (see Logging) for logins, `403`s, `429`s and CSRF failures (the middleware logs rate-limiter failures); do not log secrets; ship logs to a place where alerts can be set.

A10:2025 Mishandling of Exceptional Conditions

Default ErrorMiddleware or a custom one that never leaks internals; fail closed when the limiter or a verifier fails (failOpen: false); timeouts and body limits; a handler that throws leaves state unchanged (use transactions).

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])
    }
}

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 so AsyncMiddleware) requires Sendable in 4.122.2; a middleware is a struct of Sendable values (Set<String>, a Policy struct, a closure marked @Sendable). A var captured by a middleware or route closure is a Swift 6 error.

  • The memory limiters are actor`s: their dictionaries are isolated, `hit is async, and no lock is needed. The ContinuousClock is monotonic, so changes of the wall clock cannot reopen a window.

  • Application.Redis is Sendable, Request.Redis is not. The first draft stored any RedisClient (from req.redis) in a Sendable struct and failed with "stored property 'redis' of 'Sendable'-conforming struct …​ has non-Sendable type". The fix is to capture app.redis once when configuring and keep that.

  • The limiter factory is a @Sendable (Request) -> any RateLimiter closure, 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 403 although the token is right. The middleware read req.content before the body was collected. Collect the body in the middleware (as the listing does), or send the token in the X-CSRF-Token header.

  • 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.

  • 403 for a legitimate AJAX call. The client did not send X-CSRF-Token, or sent a stale one after login (the token rotates at login).

  • CORS error in the browser although the server answered. CORSMiddleware is registered after ErrorMiddleware, 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 Origin is the public one; the allow-list must contain https://bookshelf.example.com, not the internal address.

  • Everyone shares one rate-limit bucket. Behind a load balancer req.remoteAddress is the balancer. Enable trustForwardedFor for a proxy you control. The opposite error is trusting X-Forwarded-For on a service the clients can reach directly.

  • Redis counter never expires. INCR and EXPIRE as 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)).

  • 413 is 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 with 403/401 without its body being read.

  • HSTS on localhost. A browser that saw HSTS for localhost keeps forcing HTTPS on later HTTP runs; send HSTS only in production.

  • Secure cookie not sent in development. Secure cookies are never sent over plain HTTP; the listing enables isSecure only in production.

  • The CSP blocks inline <script> in a Leaf page. That is the policy working. Move the script to a file under Public/ or add a per-request nonce.

  • Placeholder secret in production. SecuritySettings.load refuses 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 vapor package; the CORSMiddleware and FileMiddleware are 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=Lax as a default for cookies without the attribute is browser behaviour (Chromium, Firefox, Safari differ in details); Vapor’s own cookie factory sets Lax explicitly. Sec-Fetch-Site is sent by current Chromium, Firefox and Safari releases; the code keeps the Origin and 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.