Sessions and Cookies

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

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

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode with tools version 6.3, as the official template does; Vapor 4.122.2 itself needs tools version 6.0), with fluent 4.13.0 and redis 4.14.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in a scratch package. The memory, Fluent (SQLite) and custom actor drivers were run with curl and a cookie jar and covered by a VaporTesting suite, and the output shown is captured from those runs. The Redis delegate was compiled but not run against a live Redis server. 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, and the Actors, Isolation and Sendable page of the Swift Reference explains the actor used in the custom driver.

What a session is

HTTP is stateless: every request stands alone. A session gives the server a small bag of data that follows one browser across requests. Vapor stores that bag on the server, under a random identifier, and sends the browser only the identifier in a cookie. On the next request the browser returns the cookie, Vapor looks the data up, and the handler sees the same req.session.data as before.

Two things follow from that design:

  • The cookie carries only an opaque ID. The data (a visit counter, a shopping cart, a flash message, the ID of the logged-in user) never leaves the server, so the browser cannot read or edit it. Anyone who steals the ID, however, is that session, so the ID must be unguessable (Vapor uses 32 random bytes, base64 encoded) and the cookie must be protected.

  • The data is held by a driver, which must be shared by every instance of the application and survive restarts if users are not to be logged out at random.

Sessions suit server-rendered web applications whose clients are browsers. For a JSON API consumed by apps, the official documentation recommends stateless token authentication instead; Fluent Authentication and API Tokens shows how one backend can serve both. Using a session to remember who is logged in is the subject of Session Authentication and Web Login. This page covers the mechanism underneath it.

Where it runs in the request

SessionsMiddleware is an ordinary middleware (see the Middleware page for the onion model; Security Hardening covers the cookie attributes from the attacker’s side). It wraps everything registered after it, so only routes behind it can use req.session. In the request lifecycle:

  1. SwiftNIO accepts the request; Vapor builds the Request and the responder chain starts.

  2. SessionsMiddleware looks for a cookie with the configured name. If there is one, it asks the driver to read the data for that ID. Found: the session is restored. Not found (expired, deleted, forged): an empty session is started without an ID, so the unknown ID is never adopted.

  3. The route handler runs. The first access of req.session creates an empty session if none was restored.

  4. On the way out, the middleware inspects the session. If it is still valid, it calls createSession (no ID yet) or updateSession (an ID exists) and sets the cookie on the response. If the handler called session.destroy(), it calls deleteSession and sets an expired cookie.

sequenceDiagram participant B as Browser participant M as SessionsMiddleware participant H as Route handler participant D as Session driver B->>M: GET /visits, Cookie bookshelf-session=abc M->>D: readSession(abc) D-->>M: SessionData or nil M->>H: next.respond, req.session restored H-->>M: Response, session.data changed M->>D: updateSession(abc) or createSession D-->>M: SessionID M-->>B: Response plus Set-Cookie bookshelf-session

Two consequences are easy to miss. First, the cookie is sent on every response of a request that touched the session, not only the first, so a cookie with a relative lifetime slides forward as the user keeps using the site. Second, req.session is not free to read: merely touching it creates a session (and, at response time, a driver record and a Set-Cookie), so anonymous traffic such as health checks and crawlers must not go through it. Use req.hasSession to ask without creating, or register the middleware on a route group instead of globally.

app.sessions.configuration holds the cookie name and a cookieFactory, a function from a SessionID to an HTTPCookies.Value. The factory decides every cookie attribute. The default factory sets the name vapor-session, a one-week Expires, path /, SameSite=Lax, and neither Secure nor HttpOnly. Replace it with one that sets both:

// SessionSetup.swift
import Vapor

func configureSessions(_ app: Application) {
    // Secure cookies need HTTPS; keep them off only for local development and tests.
    let secure = app.environment != .development && app.environment != .testing

    app.sessions.configuration = SessionsConfiguration(cookieName: "bookshelf-session") { sessionID in
        HTTPCookies.Value(
            string: sessionID.string,
            expires: nil,
            maxAge: 60 * 60 * 24 * 7,      // one week, as a relative lifetime
            domain: nil,                   // host-only cookie
            path: "/",
            isSecure: secure,
            isHTTPOnly: true,
            sameSite: .lax
        )
    }

    // Choose the driver here (see the next section); the default is .memory.

    // The middleware copies the driver and configuration at this moment.
    app.middleware.use(app.sessions.middleware)
}

The order matters. app.sessions.middleware is a computed property that builds a SessionsMiddleware from the driver and configuration as they are when it is read. Changing the configuration or calling app.sessions.use(...) after app.middleware.use(app.sessions.middleware) has no effect on the middleware already registered.

Captured from the running service (development environment, so no Secure):

$ curl -si -c jar.txt localhost:8090/visits
HTTP/1.1 200 OK
set-cookie: bookshelf-session=SeJTQ9jjt0zo2IBnPKN9JW8tz8rn8NDAfFz1qLqeTDk=; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax

and with --env production:

set-cookie: bookshelf-session=SfPU5TjqNamLrj/+yfXQhw4yvysunxWAH16kYcQAmoE=; Max-Age=604800; Path=/; Secure; HttpOnly; SameSite=Lax

The choices, one by one:

Attribute Advice

isHTTPOnly

true. JavaScript cannot read the cookie, which limits what a cross-site scripting bug can steal.

isSecure

true in every environment served over HTTPS. A browser refuses to send a Secure cookie over plain HTTP, which is why the listing switches it off for local development. Behind a TLS-terminating proxy the application still sees HTTP, but the browser sees HTTPS, so the attribute is correct there.

sameSite

.lax is a good default: the cookie is sent on top-level navigations (clicking a link to your site) but not on cross-site sub-requests, which blunts cross-site request forgery. .strict also withholds it on inbound links, so a user arriving from an email appears logged out. .none requires Secure and is needed only for embedded cross-site use.

maxAge or expires

maxAge is a lifetime relative to the response and does not depend on the clock of the browser. expires is an absolute date. Set neither for a session cookie that the browser drops when it closes (browsers that restore tabs may keep it). This lifetime is enforced by the browser only; see the next section for the server side.

domain

nil makes a host-only cookie. Setting a domain also sends the cookie to every sub-domain, which widens the exposure.

path

/ so every route sees it.

Sessions can also be limited to a part of the site by registering the middleware on a group: let web = app.grouped(app.sessions.middleware). Routes outside the group then neither read nor set the cookie, and calling req.session there trips an assertionFailure in debug builds.

Drivers

A driver implements the four-method SessionDriver protocol (create, read, update, delete). Vapor ships the in-memory driver and, through their packages, the Fluent and Redis drivers. Without any configuration an Application uses the memory driver (Application.init calls sessions.use(.memory)).

Driver Where the data lives Survives restart Server-side expiry

.memory

a dictionary in the process

no

none; entries are never removed except by destroy()

.fluent

table _fluent_sessions (key string, data JSON)

yes

none; the table has no timestamp column

.redis

key vrs-<id> holding the JSON

yes (as durable as the Redis setup)

none by default; a custom delegate can set SETEX

Whatever the driver, the cookie lifetime only stops an honest browser from sending an old ID. The record on the server stays until something deletes it, and a stolen cookie value keeps working as long as the record exists. For a real deployment pick one of the following: a Redis delegate with a TTL, a custom driver with a deadline column, or a periodic job that prunes old rows.

Memory

.memory needs no setup and is ideal for tests and for a single local instance. Its state is private to one process, so with two instances behind a load balancer a user would be logged in on one and anonymous on the other, and every restart or deployment logs everyone out. It also grows without bound if clients never log out.

Fluent

import Fluent

app.migrations.add(SessionRecord.migration)   // creates _fluent_sessions
app.sessions.use(.fluent)                     // or .fluent(.sqlite) for a named database
app.middleware.use(app.sessions.middleware)   // after the two lines above

The migration is SessionRecord.migration and must be added to app.migrations like any other (see the Migrations and Schema page). The data column is JSON, so this works on every Fluent driver of the Fluent overview page. Captured against SQLite after two visits and one "recently viewed" request, read back through SessionRecord by a debug route:

$ curl -s localhost:8090/debug/session-rows
["bs+WNb [(key: \"recent\", value: \"b1\"), (key: \"visits\", value: \"2\")]"]
$ curl -s -b jar.txt -X POST localhost:8090/logout -o /dev/null
$ curl -s localhost:8090/debug/session-rows
[]

destroy() deleted the row. The driver does one query per read and one per write on every request that uses sessions, so for busy sites prefer Redis, or keep the session small. The model is public, so you can query it, for example to count sessions or to log a user out everywhere by deleting rows. The SessionRecord has no creation or update timestamp, so you cannot prune by age with it; a custom driver (below) can.

Redis

// RedisSessions.swift
import Redis
import Vapor

/// Same JSON value as the default delegate, but with a Redis expiry (SETEX).
struct ExpiringRedisSessions: RedisSessionsDelegate {
    let timeToLiveSeconds: Int

    func redis<Client: RedisClient>(_ client: Client, store data: SessionData, with key: RedisKey) -> EventLoopFuture<Void> {
        client.setex(key, toJSON: data, expirationInSeconds: timeToLiveSeconds)
    }

    func redis<Client: RedisClient>(_ client: Client, fetchDataFor key: RedisKey) -> EventLoopFuture<SessionData?> {
        client.get(key, asJSON: SessionData.self)
    }

    // Optional: a prefix per service and environment.
    func makeRedisKey(for sessionID: SessionID) -> RedisKey {
        RedisKey("bookshelf:sessions:\(sessionID.string)")
    }
}

func configureRedisSessions(_ app: Application) throws {
    app.redis.configuration = try RedisConfiguration(hostname: "localhost")
    app.sessions.use(.redis(delegate: ExpiringRedisSessions(timeToLiveSeconds: 60 * 30)))
    app.middleware.use(app.sessions.middleware)
}

app.sessions.use(.redis) alone selects the default delegate, which stores the JSON with a plain SET (no expiry) under the key vrs-<id>. The RedisSessionsDelegate protocol exists to change exactly that: the key, the ID generator (makeNewID) and how the value is stored and fetched. The delegate above stores with SETEX, so Redis removes idle sessions by itself; because every request that touches the session writes it again, the expiry is sliding, matching the cookie. This listing was compiled but not run against a live server. The connection pool, RedisConfiguration and the cache driver are in the Redis and Caching page. Redis is the usual production choice because it is shared by all instances, fast, and expires by itself; if it is flushed or fails over without persistence, everyone is logged out.

A custom driver

A driver is useful when none of the shipped ones fits: a deadline, an absolute lifetime, a different store. SessionDriver itself returns EventLoopFuture`s, but Vapor 4.122.2 also has `AsyncSessionDriver, a refinement whose four requirements are async throws; its extension implements the future-returning SessionDriver methods by running them in a task. An async implementation therefore conforms to AsyncSessionDriver and never touches a future. The state lives in an actor, which makes the data race safety checkable by the compiler instead of by a lock; the driver struct only holds a reference to it.

// TTLSessions.swift
import Foundation
import Vapor

/// State lives in an actor, so there is no lock to get wrong.
actor TTLSessionStore {
    private struct Entry {
        var data: SessionData
        var expiresAt: Date
    }

    private var entries: [SessionID: Entry] = [:]
    private let timeToLive: TimeInterval

    init(timeToLive: TimeInterval) { self.timeToLive = timeToLive }

    func create(_ data: SessionData) -> SessionID {
        purgeExpired()
        let id = SessionID(string: [UInt8].random(count: 32).base64String())
        entries[id] = Entry(data: data, expiresAt: Date().addingTimeInterval(timeToLive))
        return id
    }

    func read(_ id: SessionID) -> SessionData? {
        guard let entry = entries[id] else { return nil }
        guard entry.expiresAt > Date() else {
            entries[id] = nil
            return nil
        }
        return entry.data
    }

    /// Sliding expiry: every write pushes the deadline forward.
    func update(_ id: SessionID, to data: SessionData) -> SessionID {
        entries[id] = Entry(data: data, expiresAt: Date().addingTimeInterval(timeToLive))
        return id
    }

    func delete(_ id: SessionID) { entries[id] = nil }

    var count: Int { entries.count }

    private func purgeExpired() {
        let now = Date()
        entries = entries.filter { $0.value.expiresAt > now }
    }
}

struct TTLSessionDriver: AsyncSessionDriver {
    let store: TTLSessionStore

    func createSession(_ data: SessionData, for request: Request) async throws -> SessionID {
        await store.create(data)
    }

    func readSession(_ sessionID: SessionID, for request: Request) async throws -> SessionData? {
        await store.read(sessionID)
    }

    func updateSession(_ sessionID: SessionID, to data: SessionData, for request: Request) async throws -> SessionID {
        await store.update(sessionID, to: data)
    }

    func deleteSession(_ sessionID: SessionID, for request: Request) async throws {
        await store.delete(sessionID)
    }
}

Registered with a factory closure. The store must be created once and captured, otherwise every call of the factory would start with an empty one:

let store = TTLSessionStore(timeToLive: 1800)
app.sessions.use { _ in TTLSessionDriver(store: store) }
app.middleware.use(app.sessions.middleware)

SessionDriver (and so AsyncSessionDriver) is Sendable, so a stored property must be too; an actor reference is. With a time to live of 2 seconds (only for the demonstration) the same cookie jar gave:

visit 1, session new
visit 2, session 2x75pa
          (3 seconds pass)
visit 1, session new

The third request carried a valid cookie, but the record had expired, so the middleware started a fresh session. The driver is single-process like .memory; a version shared by several instances would keep the same shape and talk to a database or Redis from inside the actor or directly from the async driver methods.

Using the session in handlers

req.session.data is a SessionData: a string-keyed dictionary of strings. There is no way to store an Int, a UUID or a Codable struct directly; convert at the edge, or encode a small JSON string. Session is a Sendable class whose properties are protected by locks, so reading it inside an async handler is fine in Swift 6.

// SessionRoutes.swift
import Vapor

func sessionRoutes(_ app: Application) {
    app.get("visits") { req -> String in
        let visits = Int(req.session.data["visits"] ?? "0") ?? 0
        req.session.data["visits"] = String(visits + 1)
        return "visit \(visits + 1), session \(req.session.id?.string.prefix(6) ?? "new")\n"
    }

    app.post("books", ":bookID", "viewed") { req async throws -> HTTPStatus in
        let bookID = try req.parameters.require("bookID")
        var recent = req.session.data["recent"]?.split(separator: ",").map(String.init) ?? []
        recent.removeAll { $0 == bookID }
        recent.insert(bookID, at: 0)
        req.session.data["recent"] = recent.prefix(5).joined(separator: ",")
        return .noContent
    }

    app.get("books", "recent") { req -> [String] in
        req.session.data["recent"]?.split(separator: ",").map(String.init) ?? []
    }

    app.post("logout") { req -> Response in
        req.session.destroy()
        return req.redirect(to: "/", redirectType: .normal)
    }
}

Run against the memory driver with a cookie jar:

$ curl -s -b jar.txt -c jar.txt localhost:8090/visits
visit 1, session new
$ curl -s -b jar.txt -c jar.txt localhost:8090/visits
visit 2, session SeJTQ9
$ curl -s -b jar.txt -X POST localhost:8090/books/b1/viewed -o /dev/null
$ curl -s -b jar.txt -X POST localhost:8090/books/b2/viewed -o /dev/null
$ curl -s -b jar.txt localhost:8090/books/recent
["b2","b1"]
$ curl -si -b jar.txt -c jar.txt -X POST localhost:8090/logout
HTTP/1.1 303 See Other
location: /
set-cookie: bookshelf-session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax
$ curl -s -b jar.txt localhost:8090/visits
visit 1, session new

The first response says session new because the ID is only assigned when the middleware calls createSession on the way out. After the logout the cookie is expired and the driver record is deleted, so the next visit starts again at 1.

Points to remember:

  • destroy() does not clear data; it marks the session invalid so the middleware deletes the record and expires the cookie at the end of the request. Do not read req.session.data after it expecting an empty bag.

  • Mutations are saved by the middleware after the handler returns. Anything that reads the driver directly in the same request sees the old data.

  • Two parallel requests from one browser each restore the same data, and the last response to be written wins. Keep session data small and write-rarely (for example the user ID and a flash message), and keep counters and carts that need atomic updates in a database or Redis.

  • Log out with a POST (the example does), never a GET, or a link or an image tag on another site can log the user out.

Rotating the session ID

If an attacker can plant a known session ID in a victim’s browser before login, and the server keeps that ID after login, the attacker shares the authenticated session: session fixation. The defence is to issue a new ID at every privilege change (login, role change) and delete the old record. The id of Session is settable. Clearing it makes the middleware call createSession instead of updateSession, which yields a new ID with the same data; the old record is deleted explicitly:

extension Request {
    /// Issue a new session ID while keeping the data (call it right after login).
    func rotateSession() async throws {
        let oldID = session.id
        session.id = nil                     // the middleware will call createSession(...)
        if let oldID {
            try await application.sessions.driver.deleteSession(oldID, for: self).get()
        }
    }
}

Captured, with two requests that share a jar, the second one calling the rotate route:

$ curl -si -b jar.txt -c jar.txt -X POST localhost:8090/session/rotate
HTTP/1.1 204 No Content
set-cookie: bookshelf-session=PiUUXrDyUMoj9U+vVA9yjwbgrbQjKtvY53xebafI7js=; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax
$ curl -s -b jar.txt localhost:8090/visits
visit 3, session PiUUXr

The counter continued (the data moved) under a new ID. A forged ID that the driver does not know is never adopted; a request with Cookie: bookshelf-session=forged received a brand-new ID and visit 1, which already defeats the simplest fixation attempt. The login flow of Session Authentication and Web Login calls this helper after the user is logged in.

Plain cookies

Sessions are one use of cookies. Preferences that are not secret and need no server state (theme, language, a cookie consent choice) can live in a cookie directly. req.cookies reads the request’s Cookie header as HTTPCookies, and response.cookies writes Set-Cookie:

func cookieRoutes(_ app: Application) {
    app.get("prefs") { req -> String in
        let theme = req.cookies["theme"]?.string ?? "light"
        return "theme=\(theme)\n"
    }

    app.post("prefs", "theme", ":value") { req throws -> Response in
        let value = try req.parameters.require("value")
        guard ["light", "dark"].contains(value) else { throw Abort(.badRequest, reason: "Unknown theme") }
        let response = req.redirect(to: "/prefs", redirectType: .normal)
        response.cookies["theme"] = HTTPCookies.Value(
            string: value, maxAge: 60 * 60 * 24 * 365, path: "/",
            isSecure: req.application.environment == .production, isHTTPOnly: false, sameSite: .lax)
        return response
    }

    app.post("prefs", "reset") { req -> Response in
        let response = req.redirect(to: "/prefs", redirectType: .normal)
        response.cookies["theme"] = .expired
        return response
    }
}
$ curl -si -X POST localhost:8090/prefs/theme/dark
HTTP/1.1 303 See Other
location: /prefs
set-cookie: theme=dark; Max-Age=31536000; Path=/; SameSite=Lax
$ curl -s -H 'Cookie: theme=dark' localhost:8090/prefs
theme=dark
$ curl -si -X POST localhost:8090/prefs/theme/neon | head -1
HTTP/1.1 400 Bad Request
$ curl -si -X POST localhost:8090/prefs/reset | grep -i set-cookie
set-cookie: theme=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax

HTTPCookies.Value.expired is an empty value with an expiry in 1970; to delete a cookie the name, path and domain must match those it was set with, which is why a dedicated helper that sets and clears a cookie in one place beats scattering literals.

A plain cookie is not signed or encrypted by Vapor. The client can read and edit it, so treat its value as untrusted input (the example validates against an allow list) and never put anything trusted in it, such as a role or a user ID. If you need a tamper-proof value, either keep it in the session or sign it yourself (an HMAC with a key from configuration, or a JWT, see JSON Web Tokens (JWT)). Cookies are also limited to about 4 KB each and are sent with every request to their path, which is another reason the session cookie holds only an ID.

Cookie notices: a cookie that is strictly necessary to provide the service (the session cookie of a logged-in user) is commonly treated as exempt from consent rules in the EU, while analytics and preference cookies usually are not. That is a legal question, not a Vapor feature; the framework does not show a banner or block cookies.

Testing

The session cookie round trip can be tested without a browser (see Testing). app.testing().test returns the response, whose Set-Cookie headers can be read back with res.headers.setCookie, and the next request sends the value in req.headers.cookie. The suite below passed (two tests, 0.32 s):

import Testing
import VaporTesting
@testable import App

@Suite struct SessionTests {
    @Test func counterSurvivesWithCookie() async throws {
        let app = try await Application.make(.testing)
        do {
            try await configure(app)
            var cookie = ""
            try await app.testing().test(.GET, "visits") { res async in
                #expect(res.body.string.hasPrefix("visit 1"))
                cookie = res.headers.setCookie?["bookshelf-session"]?.string ?? ""
                #expect(!cookie.isEmpty)
            }
            try await app.testing().test(.GET, "visits", beforeRequest: { req async in
                req.headers.cookie = ["bookshelf-session": .init(string: cookie)]
            }, afterResponse: { res async in
                #expect(res.body.string.hasPrefix("visit 2"))
            })
            try await app.testing().test(.GET, "visits") { res async in
                #expect(res.body.string.hasPrefix("visit 1"))   // no cookie, new session
            }
        } catch { try await app.asyncShutdown(); throw error }
        try await app.asyncShutdown()
    }

    @Test func ttlExpires() async throws {
        let store = TTLSessionStore(timeToLive: 0.2)
        let id = await store.create(["k": "v"])
        #expect(await store.read(id)?["k"] == "v")
        try await Task.sleep(for: .milliseconds(300))
        #expect(await store.read(id) == nil)
    }
}

The test application runs in the .testing environment, which is why the listing in the configuration section leaves Secure off there; with Secure on, a real HTTP client would also refuse to send the cookie back over plain HTTP, but the in-process test client would not notice. Test the production attributes by asserting on the Set-Cookie header of an app created with the .production environment.

Common errors and pitfalls

  • assertionFailure("No SessionsMiddleware detected.") in debug builds, or a session that never persists in release builds: the route is not behind the middleware. Add it globally or to the group that contains the route.

  • Changing the configuration after registering the middleware. The middleware has copied the old values. Configure first.

  • Everyone is logged out after each deployment, or users flip between logged in and anonymous. The memory driver with a restart or with more than one instance.

  • Changes made before an exception are lost. The middleware saves the session only when the inner responder succeeds (in the 4.122.2 source the saving step is chained onto the successful result). A handler that throws, for example an Abort, produces its error response through ErrorMiddleware (registered outside, by default) and neither updates the driver nor sets the cookie. Do not rely on a flash message or a counter written just before a throw.

  • The cookie is not stored by the browser. Secure over plain HTTP, SameSite=None without Secure, or a Domain that does not match the host.

  • A forged or stale cookie value. Unknown IDs are ignored and replaced; never trust a cookie value as an identity without looking it up.

  • Sessions forever. The memory, Fluent and default Redis drivers never expire records. Plan expiry (Redis SETEX, a custom driver, or a pruning job).

  • Data race warnings in Swift 6 when sharing state in a custom driver. Keep mutable state inside an actor or a lock-protected Sendable class; do not capture a var in the @Sendable factory closure.

Version notes and outdated material

  • The Vapor documentation states that the default cookie name is vapor_session. The 4.122.2 source sets vapor-session (hyphen), and the first cookie of an unconfigured app would carry that name. The page sets its own name, so nothing here depends on it, but do not hard-code either spelling in a client.

  • Material from the Vapor 3 era configures sessions through that version’s service and config system, which no longer exists; Vapor 4 uses app.sessions and app.middleware.use(app.sessions.middleware). Verify any older snippet against the 4.122 API before copying it.

  • Vapor 5 (5.0.0-beta.4, not released) is a major version. The SessionDriver and AsyncSessionDriver signatures shown here are for 4.x, and Vapor 5 removes EventLoopFuture from its public API, so check What’s New, Upgrading and Vapor 5 before moving.