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.auth has no user: redirect to the login page for a browser, answer 401 for 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:

  1. SessionsMiddleware (global) restores req.session from the cookie, or starts an empty one.

  2. CSRFMiddleware (group) checks the origin and compares the _csrf form field with the token in the session. It is the middleware of the Security Hardening page, which also defines req.csrfToken; the forms page uses the same one.

  3. The credentials authenticator decodes the form, loads the user, verifies the password and, on success, calls req.auth.login(user).

  4. The handler checks req.auth, stores the user in the session, rotates the session ID and redirects.

  5. On the way out, SessionsMiddleware writes the session to the driver and sets the cookie.

For a later request to GET /account:

  1. SessionsMiddleware restores the session.

  2. The session authenticator finds the user ID in the session data, loads the user and calls req.auth.login(user).

  3. User.redirectMiddleware lets the request through if req.auth has a user and answers 303 See Other to the login page if not.

  4. The handler runs.

sequenceDiagram participant B as Browser participant S as SessionsMiddleware participant C as CSRFMiddleware participant A as LoginAuthenticator participant H as login handler participant D as Session driver B->>S: POST /login with Cookie bookshelf-session=old S->>D: readSession(old) D-->>S: session data holding the CSRF token S->>C: next C->>C: compare the _csrf field with the session token C->>A: next A->>A: find the user by email, verify bcrypt on the thread pool A->>H: req.auth holds the user H->>H: session.authenticate(user), rotateSession(), new CSRF token H-->>S: 303 See Other to the next page S->>D: deleteSession(old) was called, createSession(data) S-->>B: Set-Cookie bookshelf-session=new

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

Authenticatable

An empty marker protocol. A type that conforms can be stored in req.auth, which keeps at most one instance per type.

req.auth.login(_:), get, require, has, logout

The per-request cache. require throws Abort(.unauthorized) when nothing is there. All of it is per request: req.auth.logout(User.self) forgets the user for the rest of this request and, when the session authenticator is on the route, makes it remove the user from the session at the end (see below). It does not delete the session record.

SessionAuthenticatable

Declares sessionID, the value stored in the session for this user. It must be LosslessStringConvertible (a UUID, a String, an Int) and unique and stable. The session data key is built from the type name, _UserSession for User.

ModelSessionAuthenticatable

A Fluent shortcut for models whose SessionID is their ID type. It implements sessionID as id (and crashes with fatalError for an unsaved model with no ID, so save before logging in).

User.sessionAuthenticator() and User.asyncSessionAuthenticator()

Middleware that loads the user by ID, the first with futures, the second with async/await. The optional argument is the DatabaseID.

SessionAuthenticator and AsyncSessionAuthenticator

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 authenticate(sessionID:for:).

ModelCredentialsAuthenticatable

A Fluent shortcut for the login step. Requires usernameKey, passwordHashKey and verify(password:).

User.credentialsAuthenticator() and User.asyncCredentialsAuthenticator()

Middleware that decodes the request into ModelCredentials (fields username and password), looks the user up by usernameKey and calls verify. Failure is silent: req.auth stays empty and the request continues, so the handler must check.

CredentialsAuthenticator and AsyncCredentialsAuthenticator

The protocols behind them, with your own Credentials type (a Content). This is how the page below adds the e-mail normalisation and the timing defence.

User.redirectMiddleware(path:) and redirectMiddleware(makePath:)

Answers 303 See Other to a path when req.auth has no User. The closure form receives the request, so the original URL can go into a next parameter.

User.guardMiddleware()

Throws Abort(.unauthorized, reason: "User not authenticated.") when req.auth has no User. For APIs and fetch calls, where a redirect to an HTML page is useless.

req.session.authenticate(_:), unauthenticate(_:), authenticated(_:)

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 authenticate itself.

What AsyncSessionAuthenticator.respond does, from the 4.122.2 source, in order:

  1. If req.auth already has a user (an earlier authenticator found one), call the next responder and do nothing else.

  2. If the request has a session and the session holds an ID for this type, call authenticate(sessionID:for:), which for the Fluent version is User.find(id) and req.auth.login(user) when found. A user that was deleted since simply does not authenticate; the stale ID stays in the session.

  3. Call the next responder (the rest of the chain and the handler).

  4. After the response is produced: if req.auth has 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
}

Points worth knowing:

  • \.$email is a key path to the property wrapper, not to the String. That is why the key path type is KeyPath<User, Field<String>>. Field is Fluent’s short name for the @Field wrapper.

  • ModelCredentialsAuthenticatable does not know about bcrypt (see Passwords and Crypto). verify(password:) is yours, and it is the only place a hashing algorithm is named. Using Bcrypt.verify directly is fine here because the built-in authenticator calls verify itself; 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 the sessionID of 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 Sendable is a Swift 6 problem for any code that captures it in an @Sendable closure. The canonical models are final class ... @unchecked Sendable, as in the Fluent pages of this section; see the concurrency notes at the end.

  • normalizeEmail and safeNext are 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. normalizeEmail runs first and the validator checks its result. Vapor’s Validator<String>.email accepts only a lower-case final domain label (the 4.122.2 regular expression writes it as [a-z0-9]), so Bob@Example.TEST is 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 an async overload, so the handler awaits it directly. The synchronous req.password.hash would 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 with app.passwords.use(.bcrypt(cost:)), see configure.swift below.

  • The unique index decides. Looking the e-mail up first and inserting afterwards races with a second registration of the same address. The users.email column has a unique constraint (canonical migration), the insert fails with a constraint error, and the handler catches DatabaseError.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 a 200 or 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 303 to 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)

Three limitations of the built-ins, found in the 4.122.2 source and confirmed by the page’s tests:

  1. No normalisation. The e-mail is compared as typed (the table above).

  2. Bcrypt work on the caller’s thread. verify(password:) is synchronous. The async authenticator is an async function 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.

  3. 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.verify runs 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 next field 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()
}

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:

  1. The login route is outside the session authenticator. The usual documentation example puts sessionAuthenticator() and credentialsAuthenticator() on the same group and then reads req.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 into req.auth, so a login attempt with wrong credentials still "succeeds" as the old user. Keeping the session authenticator off POST /login means req.auth holds a user only if the credentials were right. The handler then writes the session itself with req.session.authenticate(user).

  2. One message for every failure: "Invalid email or password.", with status 422, whether the address is unknown or the password is wrong.

  3. A new session ID and a new CSRF token on success (the session fixation section).

  4. A safe destination. The next field carries where the user was going. safeNext accepts 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.example as 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.example sends a freshly authenticated user to an attacker’s page that looks like yours.

  5. 303 See Other. The redirect after a successful POST must make the browser use GET, so redirectType: .normal (which is 303), never 307, which would repeat the POST with 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 a GET is remembered (replaying a POST after login is not possible with a redirect);

  • the URL is percent-encoded with a set that escapes & and = (CharacterSet.urlQueryValue in the listing above; .urlQueryAllowed leaves them alone and would split a URL that has its own query string);

  • the middleware answers 303 and 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)
}

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
  • POST only. A GET /logout link 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 a 404). The form carries the CSRF token; without it the request is a 403.

  • 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 code req.session.destroy() followed by req.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 as login does.

  • Other devices stay logged in. Each browser has its own session. Vapor’s SessionDriver has 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)
        }
    }
}

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 [UInt8].random(count:), base64url encoded (43 characters, 256 bits). Not a counter, not a UUID of a known version, not derived from the user.

The database stores a digest, not the token

SHA256.hash of the token, as hex, in token_hash with a unique index. Someone who reads the table (a backup, an SQL injection, a support export) cannot use it. A fast hash is correct here because the input has 256 bits of entropy; bcrypt is for human-chosen passwords.

It expires

expires_at is 30 minutes after issue, checked in the same query that looks the token up.

It is single use

used_at is set when the password is changed, and the lookup requires used_at to be empty. The user’s other open tokens are deleted. Asking for a new link deletes the older one, so there is one live link per user.

The answer is the same for every address

POST /password/forgot returns the same page whether or not the address has an account, and sends the mail after the response is built, so the response time is not the mail time.

The link uses a configured origin

The base URL comes from APP_BASE_URL, never from the Host or X-Forwarded-Host header of the request: an attacker who can set the header would otherwise get your mail, with a valid token, pointing at their server.

The token page leaks nothing

The form is sent with Referrer-Policy: no-referrer and Cache-Control: no-store, and loads no third-party resources, so the token is not passed on in a Referer header.

Failure is one message

A token that is unknown, expired or already used gets the same 422 page.

flowchart TD A["POST /password/forgot"] --> B{"account for the address?"} B -- yes --> C["delete older tokens, store SHA-256 of a new one, expires in 30 minutes"] C --> D["mail the link after the response"] B -- no --> E["do nothing"] D --> F["the same 200 page"] E --> F F --> G["user opens the link and posts a new password"] G --> H{"hash found, unused and not expired?"} H -- yes --> I["set the password, mark the token used, delete the rest, redirect to login"] H -- no --> J["422 with one generic message"]

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

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 UPDATE is only available through SQLKit) and its update() 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 conditional UPDATE ... WHERE used_at IS NULL through SQLKit with RETURNING (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 RouteLoggingMiddleware logs the method and the path of every request at info level, and the path contains the token (the test log shows GET /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 Task after 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) and AsyncMiddleware (async) coexist in Vapor 4. User.redirectMiddleware and guardMiddleware return the future-based Middleware; the async session and credentials authenticators are AsyncMiddleware`s. All can be mixed in one group, and Vapor adapts them. The closure given to `redirectMiddleware(makePath:) is @Sendable, so it can capture only Sendable values.

  • CSRFMiddleware and the controllers are structs of Sendable fields. WebAuthController stores an any AsyncAuthenticator; AsyncAuthenticator is a Sendable protocol, 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 only Sendable values (any Mailer, Mail, Logger), copied out of the request first. Capturing req itself in a task that outlives the response is a bug even when it compiles.

  • req.password.async.hash and verify have async overloads (next to the EventLoopFuture ones), awaited directly. The work runs on a NIOThreadPool thread, so neither the event loop nor a cooperative-pool thread is blocked for the length of the hash.

  • Reading and writing req.session.data in 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, and req.session.authenticate(user) makes it permanent.

  • req.auth.require(User.self) throws 401 on a route that has the session authenticator. The session cookie did not arrive (a Secure cookie on plain HTTP, a different host or Domain, or the route is outside SessionsMiddleware), 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 username and password for ModelCredentials, with username holding the e-mail. A field called email is not decoded, credentials fail to decode, and the failure is silent.

  • fatalError: Cannot persist unsaved model to session. A user that was never saved has no id. Save it first (in register, save comes before signIn).

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

  • redirectMiddleware sends a loop. The login page itself is inside the redirect group. Keep GET /login and GET /register outside 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_at with the application’s Date()).

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:)) and req.redirect(to:redirectType:). The Async authenticators and req.password.async exist since Vapor 4.x async/await support; the future-based ones remain for source compatibility.

  • ModelSessionAuthenticatable requires SessionID == IDValue (the model’s ID), which is why the user’s UUID is the session value. For a different identifier, conform to SessionAuthenticatable and write the authenticator.

  • Vapor 2 and Vapor 3 had their own authentication APIs (auth-provider in Vapor 2, the vapor/auth package in Vapor 3, with protocols such as PasswordAuthenticatable, TokenAuthenticatable and BCryptDigest hashing). Vapor 4 folded authentication into the core package and the Fluent conveniences into the fluent package. A snippet that imports a separate authentication module is from the older versions and must be rewritten with req.auth, the protocols above and app.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 ModelSessionAuthenticatable does.

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 a next or redirect parameter 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 Auth module and its middleware are replaced by the authenticators here.

  • None of the books can mention the Swift 6 points of this page: Sendable models, @Sendable closures in redirectMiddleware(makePath:), and thread-pool hashing with async/await.