Session Authentication and Web Login
|
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
fluent-sqlite-driver 4.9.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 whole flow was run against the in-memory SQLite database with curl
and a cookie jar, and a VaporTesting suite of 10 tests passes; the output shown is captured from those runs. The
sessions are held by the in-memory driver, so nothing on this page was run against a Redis or SQL session store (the
Sessions and Cookies page covers the drivers), and no mail was sent: the mailer writes
to the log. Vapor 5 is at 5.0.0-beta.4 and is not released yet; where it differs, that is noted.
The page builds the login half of the Bookshelf service: a visitor registers, logs in, sees a protected page, logs
out, and can reset a forgotten password. It uses the canonical User model (email, passwordHash, role) of the earlier
pages (see Fluent Models). The only additions are page-local: a
password_reset_tokens table with a PasswordResetToken model, and a few small helpers (a mailer, an HTML wrapper).
Everything else is Vapor’s own API, so the same code works with any Model that has a UUID.
What a login session is, and what Vapor adds
A session (see the Sessions and Cookies page) is a server-side bag of data found through an unguessable cookie. Session authentication stores one thing in that bag: the identifier of the user who logged in. Vapor does it with three cooperating pieces:
-
a credentials step that checks an email and a password once, at login;
-
a session step that on every later request reads the stored identifier, loads the user and puts it into
req.auth; -
a guard step that decides what happens to a request when
req.authhas no user: redirect to the login page for a browser, answer401for an API.
Everything else (the pages, the redirects, the password reset) is application code. Vapor does not generate a login page, a registration flow, a CSRF defence or a password-reset flow. This page writes them, and says at each step what Vapor does and what it leaves to you.
The official documentation recommends sessions for front-end web applications that serve HTML to browsers, and stateless token authentication for APIs. The server-rendered web applications page builds the forms this page protects. The token-based half (bearer tokens, issuing them behind Basic authentication, one backend serving both) is the subject of Fluent Authentication and API Tokens, and Authentication gives the overview of all the authenticators.
Where it runs in the request
Authenticators are ordinary middleware (see the Middleware page for the onion
model), so the order in which they are registered is the order in which they run. For a request to POST /login in this
application:
-
SessionsMiddleware(global) restoresreq.sessionfrom the cookie, or starts an empty one. -
CSRFMiddleware(group) checks the origin and compares the_csrfform field with the token in the session. It is the middleware of the Security Hardening page, which also definesreq.csrfToken; the forms page uses the same one. -
The credentials authenticator decodes the form, loads the user, verifies the password and, on success, calls
req.auth.login(user). -
The handler checks
req.auth, stores the user in the session, rotates the session ID and redirects. -
On the way out,
SessionsMiddlewarewrites the session to the driver and sets the cookie.
For a later request to GET /account:
-
SessionsMiddlewarerestores the session. -
The session authenticator finds the user ID in the session data, loads the user and calls
req.auth.login(user). -
User.redirectMiddlewarelets the request through ifreq.authhas a user and answers303 See Otherto the login page if not. -
The handler runs.
Two details of the picture matter later. The login route does not have the session authenticator in front of the credentials authenticator (the reason is in the login section), and the session ID in the last two lines is a different one from the first line (the section on session fixation).
The building blocks
The names below are all in Vapor 4.122 or in the fluent package that adds the Fluent conveniences; the async variants
are the ones to use in new code.
| Type or method | What it does |
|---|---|
|
An empty marker protocol. A type that conforms can be stored in |
|
The per-request cache. |
|
Declares |
|
A Fluent shortcut for models whose |
|
Middleware that loads the user by ID, the first with futures, the second with async/await. The optional argument is the |
|
The protocols behind them, for a user that is not a Fluent model (an in-memory user, a user fetched from another service). You implement |
|
A Fluent shortcut for the login step. Requires |
|
Middleware that decodes the request into |
|
The protocols behind them, with your own |
|
Answers |
|
Throws |
|
Write, remove and read the user’s ID in the session data. The session authenticator calls the first two for you after the response; the login handler on this page calls |
What AsyncSessionAuthenticator.respond does, from the 4.122.2 source, in order:
-
If
req.authalready has a user (an earlier authenticator found one), call the next responder and do nothing else. -
If the request has a session and the session holds an ID for this type, call
authenticate(sessionID:for:), which for the Fluent version isUser.find(id)andreq.auth.login(user)when found. A user that was deleted since simply does not authenticate; the stale ID stays in the session. -
Call the next responder (the rest of the chain and the handler).
-
After the response is produced: if
req.authhas a user, write its ID to the session (session.authenticate); otherwise, if the request has a session, remove the ID (session.unauthenticate).
Step 4 is the reason the same middleware both logs users in and out, and also the reason an authenticator placed on a route outside the route that logs in cannot see the change made by the handler until that response is on its way out. It also means the session authenticator does one database query per request that carries a session cookie.
Note what it does not do: it never changes the session ID. Rotating the ID at login is your job (see below).
Making the User model log in
Two empty-looking conformances and one extra method are all the model needs:
// UserAuth.swift
import Fluent
import Foundation
import Vapor
/// Log in with the session cookie: the session stores the user's UUID.
extension User: ModelSessionAuthenticatable {}
/// Log in with a form: the "username" is the e-mail address.
extension User: ModelCredentialsAuthenticatable {
static var usernameKey: KeyPath<User, Field<String>> { \User.$email }
static var passwordHashKey: KeyPath<User, Field<String>> { \User.$passwordHash }
func verify(password: String) throws -> Bool {
try Bcrypt.verify(password, created: passwordHash)
}
}
extension CharacterSet {
/// Characters that may appear unescaped inside ONE query parameter value (so "&" and "=" are escaped).
static let urlQueryValue: CharacterSet = {
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~/")
return allowed
}()
}
/// E-mail addresses are compared in one canonical form: trimmed and lower case.
func normalizeEmail(_ raw: String) -> String {
raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
/// Only local paths are accepted as a post-login destination (no open redirect).
func safeNext(_ raw: String?, fallback: String = "/account") -> String {
guard let raw, raw.hasPrefix("/"), !raw.hasPrefix("//"),
!raw.contains("\\"), !raw.contains("\r"), !raw.contains("\n") else { return fallback }
return raw
}
Source: Vapor docs: Authentication, Session; vapor/fluent 4.13.0: Fluent+Sessions.swift; vapor/fluent 4.13.0: ModelCredentialsAuthenticatable.swift
Points worth knowing:
-
\.$emailis a key path to the property wrapper, not to theString. That is why the key path type isKeyPath<User, Field<String>>.Fieldis Fluent’s short name for the@Fieldwrapper. -
ModelCredentialsAuthenticatabledoes not know about bcrypt (see Passwords and Crypto).verify(password:)is yours, and it is the only place a hashing algorithm is named. UsingBcrypt.verifydirectly is fine here because the built-in authenticator callsverifyitself; the next section shows why you may want to avoid it. -
The session ID is the model’s
UUID, never the e-mail. The official documentation’s example uses the e-mail as thesessionIDof a plain struct to keep the example short. An identifier that can change (an e-mail address) or be reused (a deleted account’s address registered again) lets an old session authenticate someone else. -
A model type that is not
Sendableis a Swift 6 problem for any code that captures it in an@Sendableclosure. The canonical models arefinal class ... @unchecked Sendable, as in the Fluent pages of this section; see the concurrency notes at the end. -
normalizeEmailandsafeNextare used by the login code below. E-mail addresses are compared in one canonical form (trimmed, lower case) at registration, login and reset. The built-in credentials authenticator compares exactly what was typed, so "Reader@Example.test" would not match the stored "reader@example.test" on a case-sensitive database such as SQLite or PostgreSQL, but would on MySQL with its default collation; normalising removes the difference.
Registration
Registration is a form, a validation, a hash and an insert. The routes, the registration handler and the others of this
page are collected in one RouteCollection:
// WebAuthController.swift
import Fluent
import Vapor
struct RegisterForm: Content {
var email: String
var password: String
var passwordConfirmation: String
enum CodingKeys: String, CodingKey {
case email, password
case passwordConfirmation = "password_confirmation"
}
}
struct WebAuthController: RouteCollection {
/// The authenticator that turns the login form into `req.auth`.
let loginAuthenticator: any AsyncAuthenticator
/// The site's own origin, for the CSRF middleware's origin check.
let allowedOrigins: Set<String>
func boot(routes: any RoutesBuilder) throws {
// Every route below is inside SessionsMiddleware (registered globally) and CSRFMiddleware.
let web = routes.grouped(CSRFMiddleware(allowedOrigins: allowedOrigins))
// Routes that may or may not have a user: the session authenticator fills req.auth when the session has one.
let maybeUser = web.grouped(User.asyncSessionAuthenticator())
maybeUser.get("register", use: registerForm)
maybeUser.get("login", use: loginForm)
// Login and registration do NOT use the session authenticator: the handler decides who is logged in.
web.post("register", use: register)
web.grouped(loginAuthenticator).post("login", use: login)
web.post("logout", use: logout)
// Routes that need a user: anonymous visitors are redirected to the login page.
let protected = maybeUser.grouped(User.redirectMiddleware { req in
guard req.method == .GET else { return "/login" }
return "/login?next=" + (req.url.string.addingPercentEncoding(withAllowedCharacters: .urlQueryValue) ?? "")
})
protected.get("account", use: account)
}
// MARK: Pages
func registerForm(_ req: Request) async throws -> Response {
if req.auth.has(User.self) { return req.redirect(to: "/account", redirectType: .normal) }
return registerPage(req)
}
func loginForm(_ req: Request) async throws -> Response {
if req.auth.has(User.self) { return req.redirect(to: "/account", redirectType: .normal) }
return loginPage(req, next: req.query[String.self, at: "next"])
}
func account(_ req: Request) async throws -> Response {
let user = try req.auth.require(User.self)
return HTML.page("Your account", req, "<p>Role: \(HTML.escape(user.role.rawValue))</p>")
}
// MARK: Registration
func register(_ req: Request) async throws -> Response {
let form = try req.content.decode(RegisterForm.self)
let email = normalizeEmail(form.email) // validate what will be stored, not what was typed
var problems: [String] = []
if Validator<String>.email.validate(email).isFailure { problems.append("Enter a valid email address.") }
if form.password.utf8.count < 12 { problems.append("Use at least 12 characters.") }
if form.password.utf8.count > 72 { problems.append("Use at most 72 bytes (bcrypt ignores the rest).") }
if form.password != form.passwordConfirmation { problems.append("The two passwords differ.") }
guard problems.isEmpty else { return registerPage(req, email: form.email, errors: problems, status: .unprocessableEntity) }
let hash = try await req.password.async.hash(form.password)
let user = User(email: email, passwordHash: hash, role: .reader)
do {
try await user.save(on: req.db)
} catch let error as any DatabaseError where error.isConstraintFailure {
// The unique index on users.email decides, so two simultaneous registrations cannot both win.
return registerPage(req, email: form.email, errors: ["That address cannot be registered."], status: .unprocessableEntity)
}
try signIn(user, on: req)
try await req.rotateSession()
return req.redirect(to: "/account", redirectType: .normal)
}
// MARK: Login and logout
func login(_ req: Request) async throws -> Response {
let next = try? req.content.get(String?.self, at: "next")
guard let user = req.auth.get(User.self) else {
// The same message for an unknown address and a wrong password.
return loginPage(req, next: next, errors: ["Invalid email or password."], status: .unprocessableEntity)
}
try signIn(user, on: req)
try await req.rotateSession()
return req.redirect(to: safeNext(next), redirectType: .normal)
}
func logout(_ req: Request) async throws -> Response {
req.auth.logout(User.self)
req.session.destroy() // deletes the server-side record and expires the cookie
return req.redirect(to: "/login", redirectType: .normal)
}
/// Marks the session as belonging to `user` and starts a new CSRF token (the old one was handed out anonymously).
private func signIn(_ user: User, on req: Request) throws {
req.auth.login(user)
req.session.authenticate(user)
req.session.data[CSRFMiddleware.sessionKey] = nil
}
// MARK: Forms
private func loginPage(_ req: Request, next: String?, errors: [String] = [], status: HTTPStatus = .ok) -> Response {
HTML.page("Log in", req, status: status, """
\(HTML.errors(errors))
<form method="post" action="/login">
<input type="hidden" name="_csrf" value="\(req.csrfToken)">
<input type="hidden" name="next" value="\(HTML.escape(safeNext(next)))">
<label>Email <input type="email" name="username" autocomplete="username" required></label>
<label>Password <input type="password" name="password" autocomplete="current-password" required></label>
<button>Log in</button>
</form>
<p><a href="/password/forgot">Forgot your password?</a></p>
""")
}
private func registerPage(_ req: Request, email: String = "", errors: [String] = [], status: HTTPStatus = .ok) -> Response {
HTML.page("Register", req, status: status, """
\(HTML.errors(errors))
<form method="post" action="/register">
<input type="hidden" name="_csrf" value="\(req.csrfToken)">
<label>Email <input type="email" name="email" value="\(HTML.escape(email))" autocomplete="username" required></label>
<label>Password <input type="password" name="password" minlength="12" autocomplete="new-password" required></label>
<label>Repeat password <input type="password" name="password_confirmation" minlength="12" autocomplete="new-password" required></label>
<button>Create account</button>
</form>
""")
}
}
The pieces that matter for registration:
-
Validate what is stored.
normalizeEmailruns first and the validator checks its result. Vapor’sValidator<String>.emailaccepts only a lower-case final domain label (the 4.122.2 regular expression writes it as[a-z0-9]), soBob@Example.TESTis rejected as typed and accepted after lower-casing. Validating the raw input would reject valid addresses typed with capitals. -
Passwords are limited to 72 bytes, not only to a minimum. bcrypt ignores everything after the 72nd byte; the suite shows that a hash made from 72 letters "a" followed by "X" verifies for 72 letters "a" followed by "Y". The form rejects longer passwords instead of silently truncating them. This also means the limit is in bytes (
password.utf8.count), not in characters. A multi-word passphrase of 12 to 72 bytes is the target, and no composition rules (digits, symbols) are imposed, following current NIST SP 800-63B guidance of length over complexity. Checking a list of breached passwords is a further step that is not shown. -
Hash on the thread pool.
req.password.async.hash(_:)runs bcrypt on Vapor’s thread pool; it has anasyncoverload, so the handler awaits it directly. The synchronousreq.password.hashwould occupy the thread that runs the handler for the whole of the hash (a noticeable fraction of a second at the default cost of 12, depending on the hardware). The cost is configured withapp.passwords.use(.bcrypt(cost:)), seeconfigure.swiftbelow. -
The unique index decides. Looking the e-mail up first and inserting afterwards races with a second registration of the same address. The
users.emailcolumn has a unique constraint (canonical migration), the insert fails with a constraint error, and the handler catchesDatabaseError.isConstraintFailure. The response says "That address cannot be registered." Registration cannot hide whether an address is taken without an e-mail confirmation step (send a link to the address and only create the account when it is opened); the page leaves that out and says so rather than pretending the message is leak-free. -
Form errors are a
422. The form is re-rendered with the same status the forms page uses, never a200or a redirect that loses the input. The password is never echoed back. -
Logged in on success. The new user is logged in the same way a returning one is (next section), and the response is a
303to the account page.
Login
The handler is short because the work is in the authenticator in front of it.
The built-in credentials authenticator
User.asyncCredentialsAuthenticator() is enough for a first version, and the page’s tests run the whole flow with it as
well as with the custom one below. A form that posts the fields username and password (the names come from
ModelCredentials) is decoded, the user is looked up with filter(usernameKey == username) and verify(password:)
runs. On success req.auth.login(user) is called. On any failure nothing is called and the handler runs with an empty
req.auth, so the handler is where login is decided:
// the login route, in WebAuthController.boot
web.grouped(User.asyncCredentialsAuthenticator()).post("login", use: login)
Source: Vapor docs: Authentication
Three limitations of the built-ins, found in the 4.122.2 source and confirmed by the page’s tests:
-
No normalisation. The e-mail is compared as typed (the table above).
-
Bcrypt work on the caller’s thread.
verify(password:)is synchronous. The async authenticator is anasyncfunction and the work runs wherever that function runs, which in a Vapor handler is normally a thread of Swift’s cooperative pool rather than an event loop; a login still holds that thread for the duration of the hash, and a burst of logins can occupy all of them. -
A timing difference reveals which addresses exist. An unknown e-mail returns at once; a known one then spends the bcrypt time. Rate limiting, which every login form needs, reduces the value of this but does not remove it.
The sync User.credentialsAuthenticator() is the same with futures, and it additionally calls
request.body.collect(max: nil) before decoding, which on a route registered with a streaming body strategy lifts the
usual body-size limit. Prefer the async one.
A hardened authenticator
AsyncCredentialsAuthenticator is the protocol the built-in one conforms to, so replacing it is a small struct:
// LoginAuthenticator.swift
import Fluent
import Vapor
struct LoginCredentials: Content {
var username: String
var password: String
var next: String?
}
/// Like `User.asyncCredentialsAuthenticator()`, with three differences: the e-mail is normalised,
/// the bcrypt work runs on the thread pool, and an unknown e-mail costs the same as a wrong password.
struct LoginAuthenticator: AsyncCredentialsAuthenticator {
typealias Credentials = LoginCredentials
/// A bcrypt hash made with the configured cost, verified when the e-mail is unknown.
let dummyHash: String
func authenticate(credentials: LoginCredentials, for req: Request) async throws {
let email = normalizeEmail(credentials.username)
let user = try await User.query(on: req.db).filter(\.$email == email).first()
let hash = user?.passwordHash ?? dummyHash
let matches = try await req.password.async.verify(credentials.password, created: hash)
if matches, let user {
req.auth.login(user)
}
}
}
-
The e-mail is normalised, so it matches what registration stored.
-
req.password.async.verifyruns bcrypt on the thread pool. -
If the e-mail is unknown, the verification still runs, against a dummy hash made once at start-up with the configured cost. The attacker sees a response after the same amount of hashing either way. (This page checked that the unknown-address path and the wrong-password path return the same status and body; the equal timing follows from the equal work and was not benchmarked.)
-
The
nextfield rides along in the credentials so the handler can find it; it is not used for authentication.
The routes file creates the dummy hash and passes the authenticator to the controller; the other lines of the application are in the next listing.
// routes.swift
import Vapor
func routes(_ app: Application) throws {
let dummyHash = try app.password.hash("a password nobody has")
// The public origin: e-mailed links are built from it, and the CSRF middleware accepts only it.
let baseURL = Environment.get("APP_BASE_URL") ?? "http://localhost:8080"
try app.register(collection: WebAuthController(loginAuthenticator: LoginAuthenticator(dummyHash: dummyHash),
allowedOrigins: [baseURL]))
try app.register(collection: PasswordResetController(baseURL: baseURL))
}
import Fluent
import FluentSQLiteDriver
import Vapor
func configure(_ app: Application) async throws {
app.databases.use(.sqlite(.memory), as: .sqlite)
app.migrations.add(CreateBookshelf())
app.migrations.add(CreatePasswordResetToken())
// Cost 12 is the default; the tests lower it to keep the suite fast.
app.passwords.use(.bcrypt(cost: app.environment == .testing ? 4 : 12))
configureSessions(app)
try routes(app)
try await app.autoMigrate()
}
Source: Vapor docs: Routing; Vapor docs: Passwords
app.password.hash (singular) is the synchronous hasher of the application, fine for one hash at start-up. The tests
use cost 4 so that a suite with dozens of hashes takes a second; never ship that, and measure the cost on the production
hardware (OWASP’s Password Storage Cheat Sheet gives current minimums for bcrypt).
What the login handler does
Read the handler in WebAuthController again with this in mind:
-
The login route is outside the session authenticator. The usual documentation example puts
sessionAuthenticator()andcredentialsAuthenticator()on the same group and then readsreq.auth.require(User.self)in the handler. That has a flaw: if a user is already logged in, the session authenticator has already put them intoreq.auth, so a login attempt with wrong credentials still "succeeds" as the old user. Keeping the session authenticator offPOST /loginmeansreq.authholds a user only if the credentials were right. The handler then writes the session itself withreq.session.authenticate(user). -
One message for every failure: "Invalid email or password.", with status
422, whether the address is unknown or the password is wrong. -
A new session ID and a new CSRF token on success (the session fixation section).
-
A safe destination. The
nextfield carries where the user was going.safeNextaccepts only a local absolute path: it must start with/and not with//(a protocol-relative URL) and must not contain a backslash or a line break (browsers treat/\evil.exampleas a host, and a line break could split a header). Everything else falls back to/account. Without this check, the login page is an open redirect: a link to/login?next=https://evil.examplesends a freshly authenticated user to an attacker’s page that looks like yours. -
303 See Other. The redirect after a successfulPOSTmust make the browser useGET, soredirectType: .normal(which is303), never307, which would repeat thePOSTwith the password.
Captured from the running application with curl and a cookie jar (the session cookies are shortened here; they are
random values from a local run with the in-memory driver). The first request is to a protected page:
$ curl -si localhost:8091/account
HTTP/1.1 303 See Other
location: /login?next=/account
set-cookie: bookshelf-session=Gq0LP3...; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax
Then a wrong password, and a correct one on the same session. The cookie value changes on the successful login:
$ curl -si -b jar.txt -c jar.txt -X POST localhost:8091/login --data-urlencode "_csrf=$T" \
--data-urlencode "username=reader@example.test" --data-urlencode "password=wrong wrong wrong"
HTTP/1.1 422 Unprocessable Entity
<main><h1>Log in</h1><ul role="alert"><li>Invalid email or password.</li></ul>
$ grep bookshelf jar.txt | awk '{print "before:", substr($7,1,8)}'
before: M+Hej0jK
$ curl -si -b jar.txt -c jar.txt -X POST localhost:8091/login --data-urlencode "_csrf=$T" \
--data-urlencode "username=reader@example.test" --data-urlencode "password=correct horse battery" \
--data-urlencode "next=/account"
HTTP/1.1 303 See Other
location: /account
set-cookie: bookshelf-session=PVxpoA...; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax
$ grep bookshelf jar.txt | awk '{print "after: ", substr($7,1,8)}'
after: PVxpoAjM
$T is the _csrf token read from the login form. Anonymous visitors get a session cookie from their first request
(the CSRF middleware reads the token on every request, which creates a session), so pages that must stay cookie-free,
such as health checks, are registered outside the group.
Protecting routes
redirectMiddleware is for pages a browser asks for:
-
the closure form turns the URL that was asked for into
next. Only aGETis remembered (replaying aPOSTafter login is not possible with a redirect); -
the URL is percent-encoded with a set that escapes
&and=(CharacterSet.urlQueryValuein the listing above;.urlQueryAllowedleaves them alone and would split a URL that has its own query string); -
the middleware answers
303and the protected handler is not run.
An API under the same prefix (fetch calls from the page, a mobile app) should use User.guardMiddleware() instead and
answer 401; a redirect to an HTML login page is not an answer a JSON client can use. A route group can mix the two
with a different guard per group:
// pages: redirect to the login form
let pages = maybeUser.grouped(User.redirectMiddleware(path: "/login"))
// JSON endpoints: 401 with the error body
let api = maybeUser.grouped(User.guardMiddleware())
Neither middleware decides what the user may do; they only check that there is one. Roles, ownership checks and the
difference between 401 and 403 belong to Authorization: Roles, Ownership and
Policies, and a stricter variant of the checks on cookies and headers to
Security Hardening.
Logout
Logging out is destroying the session, not forgetting the user for one request:
func logout(_ req: Request) async throws -> Response {
req.auth.logout(User.self)
req.session.destroy() // deletes the server-side record and expires the cookie
return req.redirect(to: "/login", redirectType: .normal)
}
Source: Vapor docs: Sessions
session.destroy() marks the session invalid; SessionsMiddleware then calls the driver’s deleteSession and sends an
expired cookie. Captured:
$ curl -si -b jar.txt -c jar.txt -X POST localhost:8091/logout --data-urlencode "_csrf=$T"
HTTP/1.1 303 See Other
location: /login
set-cookie: bookshelf-session=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/; SameSite=Lax
-
POSTonly. AGET /logoutlink can be fired by an<img src>on any web page and logs the user out, so the route does not exist (the tests assert a404). The form carries the CSRF token; without it the request is a403. -
destroy()ends the session; it does not start a new one. After it the response carries an expired cookie, and the next page the browser requests starts an anonymous session. The codereq.session.destroy()followed byreq.session.authenticate(user)(to "log in again in a new session") would not work: the session is invalid, so nothing is saved and no cookie is set. To change identity inside one request, rotate the ID aslogindoes. -
Other devices stay logged in. Each browser has its own session. Vapor’s
SessionDriverhas no way to find the sessions of a user, so "log out everywhere" needs your own index (a table of session ID and user ID written at login) or a per-user version number that the session authenticator compares; this is the same revocation problem the token pages of this section solve with a token table. The browser-session side of OAuth and OpenID Connect is in Logout and Session Management.
Session fixation and the CSRF token
Session fixation is an attack on the moment of login. The attacker obtains an anonymous session ID from your site (just by opening the login page), plants it in the victim’s browser (through a sub-domain cookie, a vulnerable page or a shared computer) and waits. If the server keeps that ID when the victim logs in, the attacker’s copy of the cookie is now an authenticated session.
Vapor does not defend against this for you. The session authenticator stores the user ID in the existing session. What
does help is that SessionsMiddleware never adopts an unknown ID (the sessions page
shows a forged cookie getting a fresh ID), but an ID the attacker obtained from the server is a real one. The defence is
to issue a new ID whenever the privilege level changes, and delete the old record. The rotateSession helper of the
Sessions and Cookies page does it:
// SessionSupport.swift
import Vapor
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()
}
}
}
Setting session.id = nil makes the middleware call createSession (a new ID with the same data) instead of
updateSession, and the old record is deleted by hand. signIn in the controller also clears the CSRF token
(session.data[CSRFMiddleware.sessionKey]), because the token was handed to an anonymous visitor before login and an
attacker who planted the ID may know it.
The next page creates a new one. Rotation also applies after a password change, a role change and an e-mail change.
The tests assert the behaviour with two cooperating clients (one holds the planted ID):
// Browser.swift
import Foundation
import Testing
import VaporTesting
/// A tiny browser: one cookie jar, form posts with the CSRF token, no redirects followed.
struct Browser {
let app: Application
var cookie: String?
struct Reply { var status: HTTPStatus; var body: String; var location: String?; var setCookie: String? }
mutating func send(_ method: HTTPMethod, _ path: String, form: [(String, String)]? = nil, cookieOverride: String? = nil) async throws -> Reply {
var headers = HTTPHeaders()
if let c = cookieOverride ?? cookie { headers.add(name: .cookie, value: "bookshelf-session=\(c)") }
var buffer: ByteBuffer?
if let form {
headers.contentType = .urlEncodedForm
var allowed = CharacterSet.alphanumerics
allowed.insert(charactersIn: "-._~")
buffer = ByteBuffer(string: form.map { "\($0.0)=\($0.1.addingPercentEncoding(withAllowedCharacters: allowed) ?? "")" }.joined(separator: "&"))
}
var reply = Reply(status: .ok, body: "", location: nil, setCookie: nil)
try await app.testing().test(method, path, headers: headers, body: buffer) { res async in
reply = Reply(status: res.status, body: res.body.string,
location: res.headers.first(name: .location),
setCookie: res.headers.setCookie?["bookshelf-session"]?.string)
}
if cookieOverride == nil, let c = reply.setCookie { cookie = c }
return reply
}
/// GET a page and read the CSRF token out of its first form.
mutating func token(_ path: String) async throws -> String {
let reply = try await send(.GET, path)
let marker = #"name="_csrf" value=""#
guard let start = reply.body.range(of: marker),
let end = reply.body[start.upperBound...].firstIndex(of: "\"") else { return "" }
return String(reply.body[start.upperBound..<end])
}
}
// LoginFlowTests.swift
import Fluent
import FluentSQLiteDriver
import Testing
import VaporTesting
@testable import App
@Suite(.serialized)
struct LoginFlowTests {
/// An application with the Bookshelf schema, an in-memory database and cheap bcrypt.
func withApp(_ body: (Application) async throws -> Void) async throws {
let app = try await Application.make(.testing)
do {
app.databases.use(.sqlite(.memory), as: .sqlite)
app.migrations.add(CreateBookshelf())
app.migrations.add(CreatePasswordResetToken())
app.passwords.use(.bcrypt(cost: 4))
configureSessions(app)
try routes(app)
try await app.autoMigrate()
try await body(app)
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
@Test func loginIssuesANewSessionIDAndTheOldOneIsWorthless() async throws {
try await withApp { app in
// Create an account, then start over as an anonymous visitor.
var signup = Browser(app: app)
let t0 = try await signup.token("/register")
_ = try await signup.send(.POST, "/register", form: [
("_csrf", t0), ("email", "carol@example.test"),
("password", "correct horse battery"), ("password_confirmation", "correct horse battery"),
])
// The anonymous visitor gets a session (and a CSRF token) just by opening the login page.
var victim = Browser(app: app)
let token = try await victim.token("/login")
let planted = try #require(victim.cookie)
let reply = try await victim.send(.POST, "/login", form: [
("_csrf", token), ("username", "carol@example.test"), ("password", "correct horse battery"),
])
#expect(reply.status == .seeOther)
#expect(victim.cookie != planted) // a new ID was issued
var attacker = Browser(app: app, cookie: planted) // someone who knew the old ID
#expect(try await attacker.send(.GET, "/account").status == .seeOther)
#expect(try await victim.send(.GET, "/account").status == .ok)
}
}
@Test func logoutIsPostOnlyAndNeedsTheToken() async throws {
try await withApp { app in
var b = Browser(app: app)
let t = try await b.token("/register")
_ = try await b.send(.POST, "/register", form: [
("_csrf", t), ("email", "dan@example.test"),
("password", "correct horse battery"), ("password_confirmation", "correct horse battery"),
])
#expect(try await b.send(.GET, "/logout").status == .notFound) // no GET route
#expect(try await b.send(.POST, "/logout", form: []).status == .forbidden) // no token
let fresh = try await b.token("/account")
#expect(try await b.send(.POST, "/logout", form: [("_csrf", fresh)]).status == .seeOther)
#expect(try await b.send(.GET, "/account").status == .seeOther)
}
}
}
Source: Vapor docs: Testing
Browser is a small cookie jar around app.testing().test (see Testing); it does not
follow redirects, so each response status is asserted. The full test file of the scratch package also covers the
redirect with next, the open-redirect inputs //evil.example.test/x, https://evil.example.test and
/\evil.example.test, both authenticators, validation errors and the duplicate address. With Secure off (the
.testing environment, see the sessions page) the in-process client cannot detect a missing Secure attribute; assert
the attributes in a test that builds the app with the .production environment.
Password reset
A reset link is a password for an hour. The flow therefore has strict rules, which are the point of this section:
| Rule | In the code |
|---|---|
The token is random and long |
32 bytes from |
The database stores a digest, not the token |
|
It expires |
|
It is single use |
|
The answer is the same for every address |
|
The link uses a configured origin |
The base URL comes from |
The token page leaks nothing |
The form is sent with |
Failure is one message |
A token that is unknown, expired or already used gets the same |
The model, its migration and the helpers that create and hash a token:
// PasswordReset.swift
import Fluent
import Vapor
/// Page-local addition to the Bookshelf model: one row per issued reset link.
final class PasswordResetToken: Model, @unchecked Sendable {
static let schema = "password_reset_tokens"
@ID(key: .id) var id: UUID?
@Parent(key: "user_id") var user: User
@Field(key: "token_hash") var tokenHash: String // SHA-256 of the token, never the token
@Field(key: "expires_at") var expiresAt: Date
@OptionalField(key: "used_at") var usedAt: Date?
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
init(userID: User.IDValue, tokenHash: String, expiresAt: Date) {
self.$user.id = userID
self.tokenHash = tokenHash
self.expiresAt = expiresAt
}
/// Lifetime of a reset link.
static let timeToLive: TimeInterval = 30 * 60
/// 32 random bytes, base64url: the value that goes into the e-mail.
static func makeToken() -> String {
Data([UInt8].random(count: 32)).base64EncodedString()
.replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
/// The value that goes into the database.
static func digest(of token: String) -> String {
SHA256.hash(data: Data(token.utf8)).hex
}
}
struct CreatePasswordResetToken: AsyncMigration {
func prepare(on db: any Database) async throws {
try await db.schema("password_reset_tokens")
.id()
.field("user_id", .uuid, .required, .references("users", "id", onDelete: .cascade))
.field("token_hash", .string, .required)
.field("expires_at", .datetime, .required)
.field("used_at", .datetime)
.field("created_at", .datetime)
.unique(on: "token_hash")
.create()
}
func revert(on db: any Database) async throws {
try await db.schema("password_reset_tokens").delete()
}
}
The mail is behind a protocol so a real sender (an SMTP client, a provider’s HTTP API called with the req.client of
HTTP Client) replaces the log without changing the controller;
Sending Email covers sending. The development version writes the message to the log,
which includes the link, so never use it in production, and the tests use an actor that keeps the messages:
// Mailer.swift
import Vapor
struct Mail: Sendable, Equatable {
var to: String
var subject: String
var body: String
}
protocol Mailer: Sendable {
func send(_ mail: Mail) async throws
}
/// Development mailer: writes the message to the log. A real one calls an SMTP server or a provider's API.
struct LogMailer: Mailer {
let logger: Logger
func send(_ mail: Mail) async throws {
logger.notice("mail to \(mail.to): \(mail.subject)\n\(mail.body)")
}
}
/// Test mailer: keeps the messages so a test can read the link.
actor OutboxMailer: Mailer {
private(set) var sent: [Mail] = []
func send(_ mail: Mail) async throws { sent.append(mail) }
}
extension Application {
private struct MailerKey: StorageKey { typealias Value = any Mailer }
var mailer: any Mailer {
get { storage[MailerKey.self] ?? LogMailer(logger: logger) }
set { storage[MailerKey.self] = newValue }
}
}
Source: Vapor docs: Services and storage
And the controller with the four routes:
// PasswordResetController.swift
import Fluent
import Vapor
struct PasswordResetController: RouteCollection {
/// The public origin used in e-mailed links. Never build it from the Host header of the request.
let baseURL: String
func boot(routes: any RoutesBuilder) throws {
let web = routes.grouped(CSRFMiddleware(allowedOrigins: [baseURL]))
web.get("password", "forgot", use: forgotForm)
web.post("password", "forgot", use: forgot)
web.get("password", "reset", ":token", use: resetForm)
web.post("password", "reset", use: reset)
}
func forgotForm(_ req: Request) async throws -> Response {
HTML.page("Reset your password", req, """
<form method="post" action="/password/forgot">
<input type="hidden" name="_csrf" value="\(req.csrfToken)">
<label>Email <input type="email" name="email" autocomplete="username" required></label>
<button>Send the link</button>
</form>
""")
}
struct ForgotForm: Content { var email: String }
/// Always answers the same way, whether or not the address has an account.
func forgot(_ req: Request) async throws -> Response {
let form = try req.content.decode(ForgotForm.self)
let email = normalizeEmail(form.email)
if let user = try await User.query(on: req.db).filter(\.$email == email).first() {
let userID = try user.requireID()
let token = PasswordResetToken.makeToken()
try await req.db.transaction { db in
// One live link per user: a new request replaces the previous one.
try await PasswordResetToken.query(on: db).filter(\.$user.$id == userID).delete()
try await PasswordResetToken(
userID: userID,
tokenHash: PasswordResetToken.digest(of: token),
expiresAt: Date().addingTimeInterval(PasswordResetToken.timeToLive)
).save(on: db)
}
let mail = Mail(
to: user.email,
subject: "Reset your Bookshelf password",
body: "Use this link within 30 minutes: \(baseURL)/password/reset/\(token)\nIf you did not ask for it, ignore this message."
)
let mailer = req.application.mailer
let logger = req.logger
// Sent after the response, so the response time does not tell whether the address exists.
Task {
do { try await mailer.send(mail) } catch { logger.error("could not send the reset mail: \(error)") }
}
}
return HTML.page("Check your email", req, "<p>If that address has an account, we have sent a link to reset the password.</p>")
}
func resetForm(_ req: Request) async throws -> Response {
let token = try req.parameters.require("token")
let response = HTML.page("Choose a new password", req, """
<form method="post" action="/password/reset">
<input type="hidden" name="_csrf" value="\(req.csrfToken)">
<input type="hidden" name="token" value="\(HTML.escape(token))">
<label>New password <input type="password" name="password" minlength="12" autocomplete="new-password" required></label>
<label>Repeat password <input type="password" name="password_confirmation" minlength="12" autocomplete="new-password" required></label>
<button>Change the password</button>
</form>
""")
// The token is in the URL: keep it out of Referer headers and out of caches.
response.headers.replaceOrAdd(name: "Referrer-Policy", value: "no-referrer")
return response
}
struct ResetForm: Content {
var token: String
var password: String
var passwordConfirmation: String
enum CodingKeys: String, CodingKey {
case token, password
case passwordConfirmation = "password_confirmation"
}
}
func reset(_ req: Request) async throws -> Response {
let form = try req.content.decode(ResetForm.self)
guard (12...72).contains(form.password.utf8.count), form.password == form.passwordConfirmation else {
return HTML.page("Choose a new password", req, status: .unprocessableEntity,
"<p>The passwords must match and have 12 to 72 bytes. Request a new link if this one was already used.</p>")
}
let newHash = try await req.password.async.hash(form.password)
let tokenHash = PasswordResetToken.digest(of: form.token)
let changed = try await req.db.transaction { db -> Bool in
// Unused and unexpired, or nothing: one generic failure for every other case.
guard let record = try await PasswordResetToken.query(on: db)
.filter(\.$tokenHash == tokenHash)
.filter(\.$usedAt == nil)
.filter(\.$expiresAt > Date())
.first()
else { return false }
let user = try await record.$user.get(on: db)
user.passwordHash = newHash
try await user.save(on: db)
record.usedAt = Date()
try await record.save(on: db)
try await PasswordResetToken.query(on: db).filter(\.$user.$id == user.requireID()).filter(\.$usedAt == nil).delete()
return true
}
guard changed else {
return HTML.page("Link expired", req, status: .unprocessableEntity,
"<p>This link is invalid, has expired or was already used. <a href=\"/password/forgot\">Request a new one</a>.</p>")
}
return req.redirect(to: "/login", redirectType: .normal)
}
}
Captured from the running application. The two forgot requests return identical bodies; the log shows that only one
mail was sent (the token in the link is a throwaway value from a local run):
$ curl -s -b jar2.txt -c jar2.txt -X POST localhost:8091/password/forgot --data-urlencode "_csrf=$T" --data-urlencode "email=reader@example.test"
<main><h1>Check your email</h1><p>If that address has an account, we have sent a link to reset the password.</p></main>
$ curl -s -b jar2.txt -c jar2.txt -X POST localhost:8091/password/forgot --data-urlencode "_csrf=$T" --data-urlencode "email=nobody@example.test"
<main><h1>Check your email</h1><p>If that address has an account, we have sent a link to reset the password.</p></main>
[ NOTICE ] mail to reader@example.test: Reset your Bookshelf password
Use this link within 30 minutes: http://localhost:8080/password/reset/ZqfF4b...
If you did not ask for it, ignore this message.
$ curl -si localhost:8091/password/reset/ZqfF4b...
HTTP/1.1 200 OK
Cache-Control: no-store
Referrer-Policy: no-referrer
$ curl -si -X POST localhost:8091/password/reset ... # new password, first use
HTTP/1.1 303 See Other
location: /login
$ curl -si -X POST localhost:8091/password/reset ... # the same link again
HTTP/1.1 422 Unprocessable Entity
<main><h1>Link expired</h1><p>This link is invalid, has expired or was already used. ...
What the code does not solve, stated plainly:
-
Two simultaneous submissions of one link. The lookup and the update are in one transaction, but Fluent 4.13 has no row-lock API (
SELECT ... FOR UPDATEis only available through SQLKit) and itsupdate()does not report how many rows changed. Under concurrency, two requests that carry the same valid token can both pass the lookup, and the last password wins. The attacker who could exploit that already holds the token. A strict guarantee needs a conditionalUPDATE ... WHERE used_at IS NULLthrough SQLKit withRETURNING(PostgreSQL, SQLite, MariaDB) or an affected-row count. That variant was not written or run for this page. -
Existing sessions survive a reset. A user who resets a password because an attacker is logged in stays in that session unless something revokes it. See the logout section: you need an index of sessions by user, or a per-user version number stored in the session.
-
The link is in the access log. Vapor’s default
RouteLoggingMiddlewarelogs the method and the path of every request at info level, and the path contains the token (the test log showsGET /password/reset/<token>). The query string is not logged by Vapor, but reverse proxies log it. Redact the path in the proxy’s log format, shorten the lifetime, or log at a higher level. Single use and the hash in the database limit the damage of a leaked log; they do not remove it. -
Rate limits. Neither the login, the registration nor the forgot-password route is throttled. Without a limit, passwords can be guessed and the mail endpoint can be used to flood a mailbox. Limits by address and by client IP belong in a middleware backed by Redis or a database, and in the proxy; the Security Hardening page collects them. Account lockout turns into a denial of service for the real owner, so prefer growing delays and a CAPTCHA or proof of work after repeated failures.
-
Sending the mail inside the request. The page starts an unstructured
Taskafter storing the token, which is lost if the process stops at that moment. A queue (the queues package, see Queues & Background Jobs) retries and survives restarts. -
Second factors and passkeys. Password reset is an account-recovery path, so it must be at least as strong as the login it recovers. The factors and passwordless alternatives are described in Authentication Methods, 2FA and Passwordless.
Swift 6 concurrency notes
-
Middleware(futures) andAsyncMiddleware(async) coexist in Vapor 4.User.redirectMiddlewareandguardMiddlewarereturn the future-basedMiddleware; the async session and credentials authenticators areAsyncMiddleware`s. All can be mixed in one group, and Vapor adapts them. The closure given to `redirectMiddleware(makePath:)is@Sendable, so it can capture onlySendablevalues. -
CSRFMiddlewareand the controllers are structs ofSendablefields.WebAuthControllerstores anany AsyncAuthenticator;AsyncAuthenticatoris aSendableprotocol, which is what the strict-concurrency checks require. -
The Fluent models are
final class ... @unchecked Sendable. The handlers never share a model between requests; the user a request loads is used by that request alone. -
The unstructured
Task { ... }that sends the reset mail captures onlySendablevalues (any Mailer,Mail,Logger), copied out of the request first. Capturingreqitself in a task that outlives the response is a bug even when it compiles. -
req.password.async.hashandverifyhaveasyncoverloads (next to theEventLoopFutureones), awaited directly. The work runs on aNIOThreadPoolthread, so neither the event loop nor a cooperative-pool thread is blocked for the length of the hash. -
Reading and writing
req.session.datain a handler is safe; the session object is locked internally. Two requests of one browser that run in parallel each work on their own copy, and the last response to be saved wins (see the sessions page).
Common errors and pitfalls
-
The user is logged in only on the next request. Expected. The session authenticator writes the ID after the response; in the login handler
req.auth.get(User.self)is the user from the credentials step, andreq.session.authenticate(user)makes it permanent. -
req.auth.require(User.self)throws401on a route that has the session authenticator. The session cookie did not arrive (aSecurecookie on plain HTTP, a different host orDomain, or the route is outsideSessionsMiddleware), the session was lost (memory driver after a restart or behind two instances), or the user row is gone. -
The login form always fails. The form fields must be called
usernameandpasswordforModelCredentials, withusernameholding the e-mail. A field calledemailis not decoded,credentialsfail to decode, and the failure is silent. -
fatalError: Cannot persist unsaved model to session.A user that was never saved has noid. Save it first (inregister,savecomes beforesignIn). -
Every login attempt as an already logged-in user succeeds. The session authenticator is on the login route (the section on the login handler).
-
403 Missing or invalid CSRF token.on login or logout. The form lacks the hidden field, the cookie was not sent, or the session was replaced between rendering and posting (a login in another tab rotates the ID and clears the token, so a form opened before it is stale). -
Valid addresses with capitals are rejected, or logins fail for them. Validate and compare the normalised address, as above.
-
Passwords longer than 72 bytes are accepted but only the first 72 bytes matter. Limit the length or pre-hash with care; do not pre-hash without a design (a hash of the password used as the password makes the hash the secret and breaks with NUL bytes in some libraries).
-
Everyone is logged out after a deployment. The memory driver. Use the Fluent or Redis driver, and set an expiry on the server side (the sessions page).
-
redirectMiddlewaresends a loop. The login page itself is inside the redirect group. KeepGET /loginandGET /registeroutside it. -
The reset link is correct but reported as expired. The 30 minute time to live is too short for slow mail delivery, or the clocks involved disagree (the query compares the stored
expires_atwith the application’sDate()).
Version notes and outdated material
-
This page uses the Vapor 4.122 APIs:
AsyncSessionAuthenticator,AsyncCredentialsAuthenticator,app.sessions,req.session,req.password.async,app.passwords.use(.bcrypt(cost:))andreq.redirect(to:redirectType:). TheAsyncauthenticators andreq.password.asyncexist since Vapor 4.x async/await support; the future-based ones remain for source compatibility. -
ModelSessionAuthenticatablerequiresSessionID == IDValue(the model’s ID), which is why the user’s UUID is the session value. For a different identifier, conform toSessionAuthenticatableand write the authenticator. -
Vapor 2 and Vapor 3 had their own authentication APIs (
auth-providerin Vapor 2, thevapor/authpackage in Vapor 3, with protocols such asPasswordAuthenticatable,TokenAuthenticatableandBCryptDigesthashing). Vapor 4 folded authentication into the core package and the Fluent conveniences into thefluentpackage. A snippet that imports a separate authentication module is from the older versions and must be rewritten withreq.auth, the protocols above andapp.grouped(...). -
Vapor 5 (5.0.0-beta.4, not released) is a major version. The session and authentication signatures shown here are for 4.x; read What’s New, Upgrading and Vapor 5 for what changes before moving.
-
The official documentation’s session example stores the e-mail as the session ID of a struct and builds the user from it without a database lookup. That keeps the example short, but a deleted or changed user stays logged in; look the user up on every request as
ModelSessionAuthenticatabledoes.
Where the books are outdated
The books cover these topics with Vapor 3 and early Vapor 4 code. In summary: the vapor/auth and auth-provider
packages (PasswordAuthenticatable, authSessionsMiddleware, BCryptDigest) are replaced by the built-in
ModelCredentialsAuthenticatable, ModelSessionAuthenticatable, req.auth, Bcrypt and req.password;
EventLoopFuture chains are replaced by async handlers and the Async authenticators; and forms without CSRF
protection, and logout over GET, are errata not to reproduce.
-
Condon et al. (Kodeco), chapters 18 and 20, and Yeung, chapters 8 and 9. They cover password hashing and HTTP Basic login for an API, then cookies, sessions, a web login and logout with Leaf, and redirect middleware. The architecture carries over (credentials authenticator, session authenticator, redirect middleware, guard). The code uses the future-based Vapor 3 and early Vapor 4 signatures; today’s async variants are shown above. Look in particular for a logout that is a link (
GET), a login that keeps the session ID it started with, error messages that distinguish an unknown user from a wrong password, and anextorredirectparameter that is trusted. Each of those needs the fix described on this page. -
Condon, chapter 24 (the final edition), password reset. A reset token is described with a unique e-mail and a transactional-mail provider. Check any copy against the rules of the table above: the token should be stored as a digest, expire, be single use, and the response must not reveal whether the address exists.
-
Patel, chapter 10. Password and session authentication for the web and bearer tokens for apps on one backend, on Vapor 2. The split is still the right design (cookie sessions for browsers, tokens for apps; see Fluent Authentication and API Tokens). The Vapor 2
Authmodule and its middleware are replaced by the authenticators here. -
None of the books can mention the Swift 6 points of this page:
Sendablemodels,@Sendableclosures inredirectMiddleware(makePath:), and thread-pool hashing with async/await.
References
-
NIST SP 800-63B: Authentication and Authenticator Management
-
Password authentication in Axum (the same concepts in Rust)