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:
-
SwiftNIO accepts the request; Vapor builds the
Requestand the responder chain starts. -
SessionsMiddlewarelooks 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. -
The route handler runs. The first access of
req.sessioncreates an empty session if none was restored. -
On the way out, the middleware inspects the session. If it is still valid, it calls
createSession(no ID yet) orupdateSession(an ID exists) and sets the cookie on the response. If the handler calledsession.destroy(), it callsdeleteSessionand sets an expired cookie.
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.
Registering the middleware and configuring the cookie
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|---|---|---|---|
|
a dictionary in the process |
no |
none; entries are never removed except by |
|
table |
yes |
none; the table has no timestamp column |
|
key |
yes (as durable as the Redis setup) |
none by default; a custom delegate can set |
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 cleardata; it marks the session invalid so the middleware deletes the record and expires the cookie at the end of the request. Do not readreq.session.dataafter 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 aGET, 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)
}
}
Source: Vapor docs: Testing
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 throughErrorMiddleware(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.
Secureover plain HTTP,SameSite=NonewithoutSecure, or aDomainthat 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
Sendableclass; do not capture avarin the@Sendablefactory closure.
Version notes and outdated material
-
The Vapor documentation states that the default cookie name is
vapor_session. The 4.122.2 source setsvapor-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.sessionsandapp.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
SessionDriverandAsyncSessionDriversignatures shown here are for 4.x, and Vapor 5 removesEventLoopFuturefrom its public API, so check What’s New, Upgrading and Vapor 5 before moving.
References
-
Sessions and cookies in Axum (the same concepts in Rust)