Middleware

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with swift-nio 2.104.0 and the swift-crypto version Vapor resolves, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package, and the behaviour described was verified with a VaporTesting test suite and with curl against the running service; the output shown is captured from those runs. Vapor 5 is at 5.0.0-beta.4 and is not released yet; where its middleware differs, that is noted. The page extends the Bookshelf service of the earlier pages. Swift itself is covered by the Swift Reference, and async functions, Sendable and actors by Async/Await and Tasks and Actors, Isolation and Sendable.

What middleware is

A middleware is a link in a chain of objects that sit between the HTTP server and your route handlers. Each one receives the Request and the next Responder in the chain and decides what happens next:

  • run some code, then call the next responder and return its Response unchanged (logging, metrics);

  • run some code, call the next responder, then modify the Response on the way out (adding headers, setting cookies);

  • run some code and not call the next responder, returning its own Response or throwing an error (authentication failures, maintenance mode, cached answers, redirects);

  • catch an error thrown by an inner link and turn it into a Response (ErrorMiddleware).

Middleware exist for the concerns that apply to many routes at once: cross-origin headers, request IDs, authentication and authorisation guards, rate limits, sessions, compression, static files, error rendering and tracing. Code that only one handler needs belongs in that handler (or in a service it calls), not in middleware.

Where it runs in the request

In the request lifecycle (SwiftNIO accepts the connection and parses HTTP, Vapor builds a Request, the responder chain runs the middleware, the router has matched a route, the handler runs, and the returned value is encoded into the Response), the middleware chain is the layer around the route. Vapor’s DefaultResponder looks the route up in the router first, then calls that route’s pre-built responder: the handler wrapped by every global middleware (in registration order, first registered outermost) and, inside those, by the middleware of the route’s groups. The same global chain also wraps the not-found responder, so a request that matches no route still passes through the global middleware but never through group middleware.

The result is an onion. A request travels inward through the layers, reaches the handler at the centre, and the response travels outward through the same layers in reverse order.

Middleware chain as an onion around the route handler: CORS, logging and error middleware, global middleware, route-group middleware, then the handler
sequenceDiagram participant C as Client participant M1 as CORSMiddleware participant M2 as ErrorMiddleware participant M3 as RequestIDMiddleware participant G as APIKeyMiddleware (group) participant H as Route handler C->>M1: GET /admin/stats M1->>M2: next.respond M2->>M3: next.respond M3->>G: next.respond G->>H: key is valid, next.respond H-->>G: Response G-->>M3: Response M3-->>M2: Response plus X-Request-ID M2-->>M1: Response M1-->>C: Response plus CORS headers

Middleware and AsyncMiddleware

Vapor defines two protocols in Sources/Vapor/Middleware/Middleware.swift and Sources/Vapor/Concurrency/AsyncMiddleware.swift:

// Vapor 4.122.2, abridged
@preconcurrency
public protocol Middleware: Sendable {
    func respond(to request: Request, chainingTo next: any Responder) -> EventLoopFuture<Response>
}

public protocol AsyncMiddleware: Middleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response
}

Middleware is the original, EventLoopFuture-based protocol. AsyncMiddleware refines it, and a default implementation of the future-based requirement wraps your async method, so a type conforming to AsyncMiddleware is also a Middleware and can be registered anywhere a Middleware is expected. All new code implements AsyncMiddleware; the future-based protocol exists for older code and for middleware libraries that have not migrated. Both protocols require Sendable: one middleware instance serves every request on every event loop thread at the same time.

The one method, respond(to:chainingTo:), has the shape of every middleware:

func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
    // 1. code that runs on the way in (the request is still unhandled)
    let response = try await next.respond(to: request)   // 2. hand over to the inner layers
    // 3. code that runs on the way out (the response exists)
    return response
}

The three decisions a middleware makes follow from that shape:

Decision How it is written

Pass through, optionally decorate

let response = try await next.respond(to: request) and then change response.headers, response.cookies or response.status

Short-circuit with a response

return a Response without calling next; the inner layers and the handler never run

Short-circuit with an error

throw Abort(...) (or any Error) before or after calling next; the nearest ErrorMiddleware further out turns it into a response

Recover from an error

do { try await next.respond(to: request) } catch { return ... }; this is what ErrorMiddleware does

Two details matter in practice. First, anything the middleware does after next.respond only runs when the inner layers returned; if they threw, the line after try await next.respond is skipped, and a defer block or a do/catch is needed to run code in both cases (the request-ID middleware below uses catch). Second, a response’s body may still be streaming when the middleware sees the Response: headers and status can be changed, but the body is written later by the server, so code that measures "time to the last byte" cannot be written in a middleware.

Registering global middleware

app.middleware is a Middlewares value, a list of Middleware stored on the application. Its two members are:

  • use(_ middleware: Middleware, at position: Middlewares.Position = .end), which appends (.end, the default) or prepends (.beginning) a middleware instance;

  • resolve(), which returns the array Vapor uses when it builds the responder at startup.

The list is not empty on a new application: the first read of app.middleware creates it with RouteLoggingMiddleware(logLevel: .info) followed by ErrorMiddleware.default(environment:), so every request is logged and every thrown error becomes a response before you add anything. Your registrations come after those two unless you pass at: .beginning, and the whole list can be replaced with app.middleware = Middlewares() to start from zero.

import Vapor

let orderRecorder = OrderRecorder()

func configure(_ app: Application) async throws {
    // 1. Start from the defaults (RouteLoggingMiddleware, then ErrorMiddleware) and add ours.
    app.middleware.use(CORSMiddleware(configuration: .init(
        allowedOrigin: .any(["https://app.example.com"]),
        allowedMethods: [.GET, .POST, .PUT, .PATCH, .DELETE, .OPTIONS],
        allowedHeaders: [.accept, .authorization, .contentType, .origin, .xRequestedWith, "X-API-Key"],
        allowCredentials: true,
        cacheExpiration: 600,
        exposedHeaders: [HTTPHeaders.Name("X-Request-ID"), HTTPHeaders.Name("Server-Timing")]
    )), at: .beginning)
    app.middleware.use(RequestIDMiddleware())
    app.middleware.use(RequestTimingMiddleware())
    app.middleware.use(FileMiddleware(
        publicDirectory: app.directory.publicDirectory,
        defaultFile: "index.html",
        cachePolicy: .cacheUpToDuration(.minutes(5))
    ))

    // 2. Sessions are optional and need a driver first.
    app.sessions.use(.memory)
    app.middleware.use(app.sessions.middleware)

    try routes(app)
}

The chain is composed once, when the application creates its responder at startup: DefaultResponder wraps every route (and the not-found responder) in the resolved [Middleware] array at that moment. Registering middleware from a request handler, or after the server has started, has no effect on the running chain. Do all registration in configure.

Ordering

The order of use calls is the order of the layers, outermost first. For the configuration above the effective global chain, outside to inside, is:

  1. CORSMiddleware, because of at: .beginning

  2. RouteLoggingMiddleware, a default

  3. ErrorMiddleware, a default

  4. RequestIDMiddleware

  5. RequestTimingMiddleware

  6. FileMiddleware

  7. SessionsMiddleware

  8. the handler

Rules that follow from the onion:

  • ErrorMiddleware sits near the outside, and anything that can throw must be inside it. A thrown Abort from an inner layer is caught by the nearest ErrorMiddleware further out and turned into a JSON response with the right status. A middleware outside ErrorMiddleware that throws produces an error nobody converts, and the client gets a bare connection-level failure or a generic 500 from the server. That is why the defaults put ErrorMiddleware first among the ones you usually add.

  • CORSMiddleware goes outside ErrorMiddleware. The Vapor source documents this in the CORS middleware’s own comment: insert it before error middleware so that failed responses (a 401, a 500) still carry the Access-Control-Allow-* headers. If it is inside, the browser reports an opaque CORS failure instead of your 401, which hides the real problem in front-end development. The configuration above achieves it with at: .beginning.

  • Decorators go outside the things they decorate. A request ID or correlation header is added by a layer outside the one that can fail, or the error response has no header (the catch in the request-ID middleware below exists for that reason).

  • Authentication before authorisation. A middleware that loads the user (req.auth.login) must be outside the one that requires the user (GuardMiddleware; see Authentication and Authorization).

  • Cheap short-circuits first. A maintenance flag, an IP allow list or a rate limit costs less than a database lookup (a Redis-backed limiter is in Redis & Caching), so register it before the layers that do real work.

  • FileMiddleware position decides who pays. Layers outside it (logging, timing, CORS) run for static files too; layers after it (sessions, authentication) are skipped for any request the file middleware answers.

The ordering is observable. This middleware records when it runs, and the test below registers it globally and in a group:

import Vapor
import NIOConcurrencyHelpers

/// Records the order in which middleware run, to make the onion visible in a test.
final class OrderRecorder: Sendable {
    private let events = NIOLockedValueBox<[String]>([])
    func record(_ event: String) { events.withLockedValue { $0.append(event) } }
    var snapshot: [String] { events.withLockedValue { $0 } }
    func reset() { events.withLockedValue { $0.removeAll() } }
}

struct TraceMiddleware: AsyncMiddleware {
    let name: String
    let recorder: OrderRecorder

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        recorder.record("\(name) before")
        defer { recorder.record("\(name) after") }
        return try await next.respond(to: request)
    }
}
ORDER: ["global before", "group-A before", "group-B before", "handler", "group-B after", "group-A after", "global after"]

That output is from the orderingIsOutsideIn test in the scratch package, which requests a route registered with app.grouped(TraceMiddleware(name: "group-A", ...), TraceMiddleware(name: "group-B", ...)): the first middleware passed to grouped is the outer one, exactly as with app.middleware.use.

Route-group middleware

Global middleware applies to every route. Middleware that only some routes need is attached to a group with the RoutesBuilder methods grouped(_:) and group(_:configure:), each in a variadic form (Middleware...) and an array form ([Middleware]). grouped returns a new builder you keep registering routes on; group takes a closure. Both can be combined with path components, and groups nest.

import Vapor

struct BookDTO: Content {
    var id: Int
    var title: String
}

struct MissingSecret: Error {
    let variable: String
}

func routes(_ app: Application) throws {
    app.get("hello") { req async -> String in
        "Hello, world! (\(req.correlationID ?? "no id"))"
    }

    // Group middleware wraps only the routes registered inside the group.
    // Production must set BOOKSHELF_API_KEY; development and tests use an obvious placeholder.
    let apiKey: String
    if let fromEnvironment = Environment.get("BOOKSHELF_API_KEY") {
        apiKey = fromEnvironment
    } else if app.environment == .production {
        throw MissingSecret(variable: "BOOKSHELF_API_KEY")
    } else {
        apiKey = "change-me-placeholder"
    }
    let admin = app.grouped("admin").grouped(APIKeyMiddleware(apiKey: apiKey))
    admin.get("stats") { req async -> [String: Int] in
        ["books": 42]
    }

    let api = app.grouped("api", "v1")
    api.get("books") { req async -> [BookDTO] in
        [BookDTO(id: 1, title: "Dune")]
    }
    api.get("boom") { req async throws -> String in
        throw Abort(.imATeapot, reason: "short and stout")
    }

    // Ordering demo
    let recorder = orderRecorder
    app.middleware.use(TraceMiddleware(name: "global", recorder: recorder))
    let traced = app.grouped(TraceMiddleware(name: "group-A", recorder: recorder),
                             TraceMiddleware(name: "group-B", recorder: recorder))
    traced.get("traced") { req async -> String in
        recorder.record("handler")
        return "done"
    }
}

(The TraceMiddleware lines at the end belong to the ordering demonstration, not to the Bookshelf API.)

Behind the scenes, grouped returns a MiddlewareGroup that wraps each route’s responder when the route is added: route.responder = middleware.makeResponder(chainingTo: route.responder). Three consequences:

  • Group middleware is inside every global middleware, always. You cannot make a group middleware run before CORSMiddleware.

  • Nested groups compose outside-in in the order you nest them, and within one grouped(a, b) call a is outside b.

  • Group middleware never sees a 404. When no route matches, DefaultResponder calls its not-found responder, which is wrapped by the global middleware only. In the test notFoundSeesOnlyGlobalMiddleware, GET /nope records ["global before", "global after"] and none of the group traces. The same is true for a method mismatch: POST /hello when only GET /hello exists answered 404 Not Found in the scratch service, not a 405. So a group-level API-key check still lets an unauthenticated client tell which paths exist (a real path answers 401, an unknown one 404). If that matters, put the check in global middleware and exempt the public paths there.

A rule of thumb that follows: register a middleware globally when it must see every request, including 404s and static files (logging, request IDs, CORS, tracing, compression); register it on a group when it only makes sense for routes that matched (authentication, authorisation, per-API rate limits).

Built-in middleware

Vapor ships these middleware in the Vapor module (Sources/Vapor/Middleware/, Authentication/ and Sessions/):

Type What it does Registration

ErrorMiddleware

catches errors from inner layers and turns them into a Response (JSON {"error":true,"reason":...} by default)

default; ErrorMiddleware.default(environment:) or ErrorMiddleware { request, error in ... }

RouteLoggingMiddleware

logs METHOD /path for every request at a configurable level (default .info)

default; RouteLoggingMiddleware(logLevel:)

FileMiddleware

serves files from a public directory, with ETag, range and cache-policy support

FileMiddleware(publicDirectory:defaultFile:directoryAction:advancedETagComparison:cachePolicy:)

CORSMiddleware

answers preflight requests and adds Access-Control-* headers

CORSMiddleware(configuration:)

SessionsMiddleware

loads the session from the cookie and writes the cookie back when req.session was used

app.sessions.middleware, after app.sessions.use(...)

TracingMiddleware

creates a server span per request following the OpenTelemetry HTTP semantic conventions

TracingMiddleware() or TracingMiddleware(setCustomAttributes:)

ResponseCompressionMiddleware

overrides the compression setting for the routes it wraps

RoutesBuilder.responseCompression(_:force:) or ResponseCompressionMiddleware(override:)

GuardMiddleware, RedirectMiddleware and the authenticators

authentication and authorisation guards (the Authenticatable helpers guardMiddleware() and redirectMiddleware(path:))

group middleware, covered with authentication

ErrorMiddleware

ErrorMiddleware.default(environment:) maps an error to a response using the error’s own conformances: an AbortError supplies the status, reason and headers; a DebuggableError supplies a reason and a source location; any other error becomes a 500 whose reason is Something went wrong. in a release environment and the error’s description otherwise (so a production build does not leak internals, a development build is easier to debug). It reports the error through req.logger.report(error:metadata:...) with method, URL and user agent, then encodes the JSON body with the global JSON encoder. The 418 from the example above produced exactly this body, captured from the running service:

$ curl -si localhost:8080/api/v1/boom -H 'Origin: https://app.example.com'
HTTP/1.1 418 I'm a teapot
X-Request-ID: 200F86C6-FFB7-46D2-B73A-E57643EA98B8
content-type: application/json; charset=utf-8
content-length: 41
access-control-allow-origin: https://app.example.com
...

{"error":true,"reason":"short and stout"}

The closure form builds a different error body (for example RFC 9457 Problem Details, or a body the front-end expects) and is usually registered in place of the default. Replacing the chain, as this snippet shows, means you also lose the default RouteLoggingMiddleware until you add it back:

/// Replaces the default chain completely, with an ErrorMiddleware of our own.
func replaceDefaultChain(_ app: Application) {
    app.middleware = Middlewares()
    app.middleware.use(ErrorMiddleware { request, error in
        let status: HTTPResponseStatus = (error as? any AbortError)?.status ?? .internalServerError
        request.logger.report(error: error)
        let body = Response.Body(string: "{\"error\":\"\(status.reasonPhrase)\"}")
        var headers = HTTPHeaders()
        headers.contentType = .json
        return Response(status: status, headers: headers, body: body)
    })
}

Mapping domain errors, Fluent errors and validation failures onto statuses is the subject of the Error Handling page. Note that ErrorMiddleware only converts errors thrown by inner layers; a response with status 500 that was returned is passed through untouched.

FileMiddleware

FileMiddleware(publicDirectory:defaultFile:directoryAction:advancedETagComparison:cachePolicy:) answers a request when the path, percent-decoded and with leading slashes removed, names a file below publicDirectory (any path containing ../ is rejected with 403). Otherwise it calls next, which is how a request for /api/v1/books reaches the router.

  • defaultFile is served when the request names a directory (/ or /docs/); a value starting with / is relative to the public directory root ("/index.html" is the single-page-app fallback target for the root only).

  • directoryAction: .redirect redirects /docs to /docs/ (permanent); the default .none passes the request on.

  • advancedETagComparison: true hashes the file byte by byte (and caches the hash), false derives the ETag from modification time and size.

  • cachePolicy is .browserDefault, .noCache, .cacheUpToDuration(.minutes(5)) or .custom(cacheControlHeader:ageHeader:). The example service answered GET / with cache-control: max-age=300 and age: 0 using .cacheUpToDuration(.minutes(5)).

The directory is usually app.directory.publicDirectory, which is <working directory>/Public/. A missing working directory (Xcode’s default, a different cwd under systemd) is the classic reason a file under Public/ returns 404. The middleware is not enabled by the generated configure.swift; you add it. Static files and the file I/O API are developed on the Static Files and File I/O page.

CORSMiddleware

Browsers block a page from reading a response from another origin unless the response says so; CORSMiddleware produces those headers and answers the OPTIONS preflight itself. The server-side configuration is CORSMiddleware.Configuration:

Parameter Meaning

allowedOrigin

.originBased (echo the request’s Origin, the default() setting), .all (*), .any(["https://app.example.com"]) (echo the origin only when it is in the list), .custom("https://app.example.com"), .none

allowedMethods

[HTTPMethod], joined into Access-Control-Allow-Methods

allowedHeaders

[HTTPHeaders.Name], joined into Access-Control-Allow-Headers; add custom headers like X-API-Key here or the preflight fails

allowCredentials

Access-Control-Allow-Credentials: true, needed for cookies and Authorization with fetch(..., { credentials: "include" })

cacheExpiration

Access-Control-Max-Age in seconds, default 600

exposedHeaders

Access-Control-Expose-Headers; headers JavaScript may read from the response, for example X-Request-ID

CORSMiddleware.Configuration.default() uses .originBased with GET, POST, PUT, OPTIONS, DELETE, PATCH and the headers Accept, Authorization, Content-Type, Origin, X-Requested-With. Origin-based echoing with allowCredentials: true allows every site to make credentialed requests; for a real API use .any([...]) with your front-end origins. Requests without an Origin header (curl, server-to-server) pass straight through, so a CORS configuration is not an access control. The preflight captured from the service above:

$ curl -si -X OPTIONS localhost:8080/api/v1/books \
    -H 'Origin: https://app.example.com' -H 'Access-Control-Request-Method: POST'
HTTP/1.1 200 OK
content-length: 0
access-control-allow-origin: https://app.example.com
access-control-allow-headers: accept, authorization, content-type, origin, x-requested-with, x-api-key
access-control-allow-methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
access-control-expose-headers: x-request-id, server-timing
access-control-max-age: 600
access-control-allow-credentials: true

The exposedHeaders: parameter takes [HTTPHeaders.Name] in 4.122; an older overload that took [String] is deprecated and produces a deprecation warning, which is why the listing writes HTTPHeaders.Name("X-Request-ID"). The concept of CORS itself, browser behaviour and the failure modes are covered in CORS.

Sessions

app.sessions.middleware returns a SessionsMiddleware bound to the configured driver. It must be registered, and a driver chosen first (app.sessions.use(.memory) in the listing above; .memory is lost on restart and is per process, so it is for development only). It reads the session cookie (vapor-session by default, from app.sessions.configuration), restores the session if the store knows the id, and after the inner layers finish writes the cookie if req.session was touched. Calling app.sessions.middleware without a driver crashes at startup with the message No driver configured. Configure with app.sessions.use(...). The default SessionsConfiguration.default() cookie is valid for one week, SameSite=Lax, with neither Secure nor HttpOnly set; production services replace cookieFactory to set both. Redis and Fluent-backed stores belong with Sessions and Cookies and Session Authentication and Web Login (see also Security Hardening); the registration order rule is that the middleware must come before any authenticator that uses the session.

Logging, tracing and compression

RouteLoggingMiddleware is already in the default chain (see Logging); a second instance only makes sense with a different level. TracingMiddleware is an AsyncMiddleware that extracts the incoming trace context from the request headers, starts a server span named after the route (vapor_route_undefined when no route matched), sets the OpenTelemetry HTTP attributes (http.request.method, url.path, http.route, http.response.status_code and more), marks 5xx responses as errors and exposes the span’s context as req.serviceContext while the request runs. It produces data only when a tracing backend is bootstrapped with swift-distributed-tracing (for example through swift-otel). Register it near the outside, so the span covers the other layers; a group-level registration with an ErrorMiddleware inside it keeps the status code on the span and leaves health probes untraced, as shown on Tracing, Metrics & OpenTelemetry. setCustomAttributes runs on every request and must be cheap.

ResponseCompressionMiddleware does not compress anything itself. It sets the HTTPHeaders.ResponseCompression preference (.enable, .disable, .useDefault) that the server’s own compression configuration consults, and only when allowRequestOverrides is enabled in that configuration. Wrap routes with app.grouped("downloads").responseCompression(.disable) for already-compressed payloads.

import Vapor
import Tracing

func builtInMiddlewareExamples(_ app: Application) {
    // Log at debug level instead of info (replaces nothing: the default one still exists).
    app.middleware.use(RouteLoggingMiddleware(logLevel: .debug))

    // Redirect /docs to /docs/ when a directory with that name exists, and cache for an hour.
    app.middleware.use(FileMiddleware(
        publicDirectory: app.directory.publicDirectory,
        defaultFile: "index.html",
        directoryAction: .redirect,
        advancedETagComparison: false,
        cachePolicy: .cacheUpToDuration(.hours(1))
    ))

    // OpenTelemetry-style server spans (needs a bootstrapped swift-distributed-tracing backend).
    app.middleware.use(TracingMiddleware { attributes, request in
        attributes["bookshelf.tenant"] = request.headers.first(name: "X-Tenant") ?? "none"
    })

    // Route-level override of the response compression setting.
    let downloads = app.grouped("downloads").grouped(ResponseCompressionMiddleware(override: .disable))
    downloads.get("archive") { _ async -> String in "already compressed" }
    // The same thing through the RoutesBuilder helper.
    app.grouped("exports").responseCompression(.enable).get("books.csv") { _ async -> String in "id,title" }
}

The listing above was compiled, not run: the tracing middleware needs a backend and the file middleware a populated public directory. The import Tracing line comes from swift-distributed-tracing, which Vapor depends on; declare .product(name: "Tracing", package: "swift-distributed-tracing") in your own manifest rather than relying on the transitive dependency.

Writing custom middleware

The recipe is the same each time: a struct (or final class) conforming to AsyncMiddleware, with let properties for configuration, one respond method, and registration in configure.

Request timing

import Vapor

/// Adds a `Server-Timing` header with the time spent in the rest of the chain.
struct RequestTimingMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let clock = ContinuousClock()
        let start = clock.now
        let response = try await next.respond(to: request)
        let elapsed = start.duration(to: clock.now)
        let milliseconds = Double(elapsed.components.seconds) * 1000
            + Double(elapsed.components.attoseconds) / 1_000_000_000_000_000
        response.headers.replaceOrAdd(
            name: "Server-Timing",
            value: "app;dur=\(String(format: "%.2f", milliseconds))"
        )
        return response
    }
}

It measures with ContinuousClock (monotonic, unlike Date) and reports with the standard Server-Timing header, which browser developer tools display. The captured response of GET /hello shows Server-Timing: app;dur=0.58. Two caveats from the structure of a middleware: the header appears only on responses that were returned, so the 401 and 418 responses above have none (the error was thrown through this layer; a defer or do/catch is needed to decorate error paths), and the duration ends when the handler returns, not when the body has been written to the socket.

Request IDs

Vapor already gives every Request a logger with a request-id metadata value, taken from the X-Request-Id request header when present and a fresh UUID otherwise. What it does not do is echo the id on the response, bound the length and character set of an id supplied by a client, or let handlers read the id. The middleware below does those:

import Vapor

/// Reuses an incoming `X-Request-ID` (when it looks safe) or creates one, adds it to the
/// request logger and echoes it on the response, also when a later error is thrown.
struct RequestIDMiddleware: AsyncMiddleware {
    static let headerName = "X-Request-ID"

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let incoming = request.headers.first(name: Self.headerName)
        let id = incoming.flatMap(Self.sanitized) ?? UUID().uuidString
        request.logger[metadataKey: "correlation-id"] = .string(id)
        request.storage[CorrelationIDKey.self] = id

        do {
            let response = try await next.respond(to: request)
            response.headers.replaceOrAdd(name: Self.headerName, value: id)
            return response
        } catch let abort as any AbortError {
            // Re-throw with the header added, so ErrorMiddleware (further out) keeps it.
            var headers = abort.headers
            headers.replaceOrAdd(name: Self.headerName, value: id)
            throw Abort(abort.status, headers: headers, reason: abort.reason)
        }
    }

    /// Accepts 8...64 characters of letters, digits, `-` and `_` only (no log injection).
    private static func sanitized(_ value: String) -> String? {
        let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
        guard (8...64).contains(value.count),
              value.unicodeScalars.allSatisfy(allowed.contains) else { return nil }
        return value
    }
}

struct CorrelationIDKey: StorageKey {
    typealias Value = String
}

extension Request {
    /// The correlation id stored by `RequestIDMiddleware`, if it ran.
    var correlationID: String? {
        storage[CorrelationIDKey.self]
    }
}
  • An incoming X-Request-ID is accepted only when it is 8 to 64 letters, digits, - or _; anything else (spaces, CR/LF, very long strings) is replaced, which prevents log injection and header smuggling through the id.

  • The id goes into request.logger[metadataKey:], so every log line of the request carries correlation-id, and into request.storage behind a StorageKey, so req.correlationID is available to handlers and later middleware.

  • The catch branch re-throws an Abort with the header added, so ErrorMiddleware (further out) puts X-Request-ID on the 401 and 418 responses too. Errors that are not AbortError pass up without the header; for those either also map them here or move the header assignment into a custom ErrorMiddleware closure.

The captured responses show both paths (X-Request-ID: bookshelf-0001 accepted, a UUID created for the request without one):

$ curl -si localhost:8080/hello -H 'X-Request-ID: bookshelf-0001'
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 30
Server-Timing: app;dur=0.58
X-Request-ID: bookshelf-0001

Hello, world! (bookshelf-0001)

A request ID is a correlation aid, not a credential: never trust it for authorisation.

API-key check with a constant-time comparison

Machine-to-machine endpoints are often protected with a shared key in a header. Comparing the strings with == stops at the first differing byte, which in principle lets an attacker who can measure response times learn the key one character at a time. The middleware below compares fixed-length SHA-256 digests of the provided and the expected key and examines every byte of both before deciding:

import Vapor

/// Protects a route group with a shared API key sent in `X-API-Key`.
struct APIKeyMiddleware: AsyncMiddleware {
    private let expectedDigest: SHA256.Digest

    /// - Parameter apiKey: the expected key; read it from the environment, never hard-code it.
    init(apiKey: String) {
        self.expectedDigest = SHA256.hash(data: Data(apiKey.utf8))
    }

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        guard let provided = request.headers.first(name: "X-API-Key") else {
            throw Abort(.unauthorized, reason: "Missing API key.")
        }
        // Hash both sides so the comparison runs over equal-length values, then compare
        // every byte without returning early.
        let providedDigest = SHA256.hash(data: Data(provided.utf8))
        guard Self.constantTimeEqual(providedDigest, expectedDigest) else {
            throw Abort(.unauthorized, reason: "Invalid API key.")
        }
        return try await next.respond(to: request)
    }

    private static func constantTimeEqual(_ lhs: SHA256.Digest, _ rhs: SHA256.Digest) -> Bool {
        var difference: UInt8 = 0
        for (a, b) in zip(lhs, rhs) {
            difference |= a ^ b
        }
        return difference == 0
    }
}

Notes on this implementation:

  • Hashing both sides first gives two values of equal length, so the comparison does not leak the length of the real key, and the |= accumulation means the loop does not return at the first mismatch.

  • The key is a constructor argument; read it from the environment (Environment.get("BOOKSHELF_API_KEY")) in routes and never commit a real one. The change-me-placeholder in the listing of routes.swift is a visible placeholder for local runs and tests, not a default for deployment: routes throws at startup when the variable is missing in production.

  • Constant-time behaviour of Swift code is best effort: the optimiser and the platform are not guaranteed to preserve it. Treat it as defence in depth next to TLS, key rotation and rate limiting. For anything beyond a shared secret between your own services, use the authentication features (bearer tokens, JWT) rather than a hand-rolled key.

  • The middleware throws Abort(.unauthorized), so the response comes from ErrorMiddleware. A WWW-Authenticate header can be added through the headers: parameter of Abort.

  • Books and tutorials for early Vapor 4 frequently show a key check as guard req.headers["X-API-Key"].first == key (a plain == and futures); that works, but the version above is the one to copy today. The Kodeco middleware chapter was consulted for concept coverage only; this page’s code is original and written for Vapor 4.122.

The captured behaviour:

$ curl -si localhost:8080/admin/stats
HTTP/1.1 401 Unauthorized
X-Request-ID: F124ACAD-5EA5-425E-8758-3088842835B9
content-type: application/json; charset=utf-8

{"error":true,"reason":"Missing API key."}

$ curl -si localhost:8080/admin/stats -H 'X-API-Key: change-me-placeholder'
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
Server-Timing: app;dur=0.77
X-Request-ID: F2463091-E956-4C59-B7D8-9E4D8F6A972F

{"books":42}

Short-circuiting: a maintenance switch

A middleware that never calls next stops the request at its own ring. This one answers 503 with Retry-After while a flag is set; the closure is @Sendable because the middleware must be:

import Vapor

/// Short-circuits every request with 503 while a flag is set; the handler never runs.
struct MaintenanceMiddleware: AsyncMiddleware {
    let isEnabled: @Sendable () -> Bool

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        guard !isEnabled() else {
            var headers = HTTPHeaders()
            headers.replaceOrAdd(name: .retryAfter, value: "120")
            throw Abort(.serviceUnavailable, headers: headers, reason: "Down for maintenance.")
        }
        return try await next.respond(to: request)
    }
}

Register it inside ErrorMiddleware (the default position after the two defaults). Registered with at: .beginning, outside ErrorMiddleware, the thrown Abort is not converted and the server closes the connection without a response, as noted in the ordering section. If you want it outside, build and return a Response instead of throwing.

Middleware with state and Swift 6

A middleware is created once and shared across all requests and all event loops, and both protocols require Sendable. Under the Swift 6 language mode the compiler enforces that:

  • a struct with only let properties of Sendable types (as all the examples above) is automatically Sendable;

  • a final class needs immutable (let) Sendable stored properties, or @unchecked Sendable with its own locking, which you must then be able to justify;

  • a mutable var stored in the middleware is a compile error (stored property 'x' of 'Sendable'-conforming struct has non-Sendable type or a mutation of a captured variable from a @Sendable closure); the cure is an actor, a NIOLockedValueBox, or storing per-request data in request.storage instead.

/// Per-process mutable state in a middleware: keep it in an actor so the middleware stays Sendable.
actor RequestCounter {
    private var total = 0
    func increment() -> Int {
        total += 1
        return total
    }
}

struct CountingMiddleware: AsyncMiddleware {
    let counter = RequestCounter()

    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let count = await counter.increment()
        let response = try await next.respond(to: request)
        response.headers.replaceOrAdd(name: "X-Request-Count", value: String(count))
        return response
    }
}

The actor serialises the increments, and the await inside respond is fine because AsyncMiddleware.respond is async. Per-request state (a start time, a request id, the authenticated user) does not belong in the middleware at all: use local variables in respond, request.storage or request.auth. For middleware that holds long-lived resources (a rate-limit store, an HTTP client), take the resource through the initializer from app and keep it in a let.

Build one group once and share it when several controllers need the same stack:

/// A group built once and reused by several controllers.
func protectedRoutes(_ app: Application, apiKey: String) -> any RoutesBuilder {
    app.grouped("internal")
        .grouped(RequestTimingMiddleware(), APIKeyMiddleware(apiKey: apiKey))
}

Testing middleware

See Testing for the general setup. Use VaporTesting and app.testing().test(...), which runs the full chain in-process, so the test exercises ordering, short-circuits and headers exactly as production would, without a socket. The suite in the scratch package follows this shape for every behaviour shown on this page:

import VaporTesting
import Testing
@testable import App

@Suite(.serialized) struct MiddlewareTests {
    func withApp(_ body: (Application) async throws -> Void) async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            try await body(app)
        } catch {
            try await app.asyncShutdown()
            throw error
        }
        try await app.asyncShutdown()
    }

    @Test func apiKeyGuardsOnlyTheGroup() async throws {
        try await withApp { app in
            try await app.testing().test(.GET, "admin/stats") { res async in
                #expect(res.status == .unauthorized)
            }
            try await app.testing().test(.GET, "admin/stats", headers: ["X-API-Key": "change-me-placeholder"]) { res async in
                #expect(res.status == .ok)
            }
            try await app.testing().test(.GET, "api/v1/books") { res async in
                #expect(res.status == .ok)
            }
        }
    }
}

Ordering tests record events in a Sendable collector, as the OrderRecorder above does. Test a middleware in isolation by mounting it on a throwaway route of a fresh Application, not by calling respond directly, because building a Request and an AsyncResponder by hand ties the test to Vapor internals.

Common errors and pitfalls

Symptom Cause Fix

The browser shows a CORS error instead of the real 401 or 500

CORSMiddleware is registered inside ErrorMiddleware, so error responses have no Access-Control-* headers

register it with at: .beginning

Preflight fails for a custom header

the header is missing from allowedHeaders

add it ("X-API-Key" in the example)

Cookies are not sent cross-origin

allowCredentials is false, or the browser request lacks credentials: "include", or allowedOrigin is .all (the wildcard is rejected for credentialed requests)

use .any([...]) and allowCredentials: true

A header added after next.respond is missing on error responses

the error was thrown through the middleware, so the code after try await never ran

use do/catch or defer, or add the header in the error middleware

The server closes the connection with no response (curl: (52))

a middleware outside ErrorMiddleware threw

keep throwing middleware inside the error middleware, or return a Response

Abort.404 for every file under Public/

FileMiddleware not registered, or the process runs from another working directory

register it; set the working directory in the Xcode scheme or the service unit

Group middleware does not run for unknown URLs

group middleware wraps only matched routes

use global middleware for checks that must see every request

fatalError: No driver configured. Configure with app.sessions.use(...)

app.sessions.middleware read before choosing a driver

call app.sessions.use(...) first

Middleware registered in a handler or after startup has no effect

the chain is composed once at startup

register in configure

Swift 6 error about non-Sendable stored property or captured variable in a middleware

middleware must be Sendable and is shared across requests

immutable properties, an actor, or NIOLockedValueBox

Both Middleware and AsyncMiddleware implemented

ambiguous intent; AsyncMiddleware already provides the future-based method

implement only the async method

Timing or compression looks wrong for streamed bodies

the middleware sees headers before the body is written

do not measure or modify bodies in middleware; wrap the body stream in the handler

Version notes and outdated material

  • The Middleware protocol, app.middleware.use and grouped are the Vapor 4 API. With at: .beginning available, the older idiom of assigning app.middleware = Middlewares() and re-adding ErrorMiddleware by hand just to get a middleware first is not needed.

  • AsyncMiddleware was added to Vapor 4 with async/await support; books written for Vapor 3 or early Vapor 4 show respond(to:chainingTo:) returning a future (Future<Response> in Vapor 3, EventLoopFuture<Response> in Vapor 4), and Vapor 3 registered middleware through MiddlewareConfig and services.register(...) in configure(_:_:_:), which no longer exists.

  • FileMiddleware(publicDirectory:) alone is the older form shown in early Vapor 4 material; the current initializer adds defaultFile, directoryAction, advancedETagComparison and cachePolicy.

  • CORSMiddleware.Configuration.init taking exposedHeaders: [String] is deprecated in 4.122 in favour of [HTTPHeaders.Name].

  • Swift 6 language mode makes the Sendable requirement a compile-time error where the Swift 5 mode printed a warning or nothing; books from the Swift 5 era (including the ones consulted) never mention it.

  • TracingMiddleware and req.serviceContext are recent additions tied to swift-distributed-tracing; older material uses swift-metrics and swift-log only.

  • Vapor 5 (5.0.0-beta.4, not released) is a major version that changes some APIs; this page’s code targets 4.122.x. Review the Vapor 5 upgrade notes before moving; the register-and-wrap model (global, grouped, outside-in order) is the concept to keep, but exact signatures were not verified for this page.