Passwords and Crypto

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 for the registration example. SwiftPM resolved swift-crypto 4.5.2 for this build. The page was checked on 2026-10-17. Every Swift listing (apart from one excerpt of Vapor’s own PasswordHasher protocol) was compiled in the Swift 6 language mode with zero warnings in a scratch package, the account routes were exercised with curl against a running server and with a VaporTesting suite, and the cryptographic helpers and the one-time password code were covered by tests, including the published RFC 4226 and RFC 6238 test vectors. Output shown below is captured from those runs. Vapor 5 is at 5.0.0-beta.4 and is not released yet. The 4.x APIs described here (Bcrypt, app.passwords, HOTP and TOTP) are not present in the 5.0.0-beta.4 source tree, so this page is a Vapor 4 page; see Version notes and outdated material. The page extends the Bookshelf service of the earlier pages: the User model with email, passwordHash and role is the canonical one.

What Vapor gives you

Three separate tools share this page because they share a mindset: never invent your own cryptography, use the boring well-reviewed primitive, and keep every secret out of the source.

Need Vapor 4 API Comes from

Store a user password

app.passwords, req.password, Bcrypt

Vapor itself (Sources/Vapor/Passwords, Sources/Vapor/Bcrypt, a bundled C bcrypt implementation)

Hashes, MACs, encryption, signatures, key agreement

SHA256, HMAC, AES.GCM, ChaChaPoly, P256, Curve25519, SymmetricKey, Insecure.SHA1, Insecure.MD5

SwiftCrypto, re-exported by import Vapor

Random bytes and tokens

[UInt8].random(count:), SymmetricKey(size:), SystemRandomNumberGenerator

Vapor (the array extension), SwiftCrypto, the standard library

Compare secrets without leaking timing

secureCompare(to:), HMAC.isValidAuthenticationCode

Vapor, SwiftCrypto

Second factor with an authenticator app

HOTP, TOTP

Vapor 4 only; removed in the Vapor 5 beta

Passwords are the case where the framework picks for you: you do not choose bcrypt over SHA-256 by reading a blog post, you call req.password.hash(_:). The rest of the section builds on it: the Authentication, Session Authentication and Web Login and Fluent Authentication and API Tokens pages call these functions, and this page is where the details live.

Password hashing

Why a slow, salted hash

A database leak is a matter of when, not if. What the attacker finds in the users table decides whether they get passwords or only an expensive puzzle. Three properties make a stored value useful against that:

  • One way. Nothing is encrypted, so there is no key to steal. Login hashes the submitted password again and compares.

  • Salted. Each password gets its own random salt, stored inside the hash string. Two users with the same password have different hashes, and precomputed tables are useless.

  • Slow on purpose. A general-purpose hash such as SHA-256 is built to be fast; an attacker with a GPU tests billions of guesses per second. A password hash has a tunable work factor, so one guess costs a noticeable fraction of a second for the server and, multiplied by billions of guesses, an unaffordable amount for the attacker.

A plain SHA256.hash(data:) of the password, salted or not, is therefore the wrong tool even though it compiles and "hashes" the password. Some tutorials show it as a first step before introducing bcrypt; do not copy it into a service.

Bcrypt in a few lines

Vapor’s hasher is bcrypt, implemented in C inside the Vapor package (CVaporBcrypt), exposed as the global Bcrypt value of type BCryptDigest. A hash is one 60-character string:

$2b$04$oAI59BheG8MZV.2kY5AWPOoXyyjzq74/hKpfS7sCJPT1UaDnLUJ5u
 |   |  |                     |
 |   |  |                     +-- 31 characters of hash
 |   |  +-- 22 characters of salt (bcrypt's own base64 alphabet)
 |   +-- cost: 2 to the power 04 rounds of the key schedule
 +-- algorithm revision 2b

This digest was produced by the test run below with a throwaway password and the cheapest permitted cost (4); it is not a credential. Because algorithm, cost and salt travel inside the string, verify needs only the stored string and the submitted password, and different hashes of the same password never compare equal as strings.

Three limits of bcrypt matter in practice:

  • Cost is a logarithm. Each +1 doubles the time. Vapor’s default is 12; Bcrypt.hash accepts 4 through 31 and throws BcryptError.invalidCost ("Cost should be between 4 and 31") outside that range.

  • Only the first 72 bytes are used. Bytes after the 72nd are ignored.

  • The C implementation sees a NUL-terminated string. A password containing a NUL byte is cut at it.

Both of the last two were verified on 4.122.2 with a throwaway test:

LIMIT plain bcrypt, 73-byte passwords that differ only in the last byte verify as equal: true
NUL plain bcrypt, hashed "abc\0def", verified with "abc": true

Both are why the registration code below refuses passwords over 72 bytes (a limit on bytes, not characters: three emoji already take 12 bytes), and why a custom hasher that pre-hashes the password is a legitimate option (see A custom hasher with a pepper).

Choosing the hasher and the cost

app.passwords selects the hasher the whole application uses. The default, set in Application.init, is .bcrypt with cost 12. The listing below sets it explicitly, reads the cost from an environment variable so that it can be raised without a release, and uses the cheapest cost in tests so that a suite creating many users stays fast.

// PasswordSetup.swift
import Vapor

@discardableResult
func configurePasswords(_ app: Application) -> Int {
    switch app.environment {
    case .testing:
        // Cost 4 is the minimum Bcrypt accepts. Never use it outside tests.
        app.passwords.use(.bcrypt(cost: 4))
        return 4
    default:
        // The cost is a tuning knob: read it from the environment so it can be raised without a release.
        let cost = Environment.get("BCRYPT_COST").flatMap(Int.init) ?? 12
        app.passwords.use(.bcrypt(cost: cost))
        return cost
    }
}

The providers that ship with Vapor 4.122.2:

Provider Meaning

.bcrypt

Bcrypt with cost 12. This is what an application has when it never calls app.passwords.use.

.bcrypt(cost:)

Bcrypt with the given cost. Throws BcryptError.invalidCost at the first hash if the cost is outside 4 through 31.

.plaintext

Stores and compares the password as it is. It exists only so that a test needs no CPU time. The Vapor documentation says it is insecure; guard it with case .testing as shown in the docs, or better, use .bcrypt(cost: 4) as above so the test also exercises the real code path.

Picking the production cost is a measurement, not a rule of thumb. The usual advice is to pick the largest cost whose verification time is acceptable on your slowest production machine under load: a few hundred milliseconds is common, and the OWASP Password Storage Cheat Sheet puts the floor for bcrypt at a work factor of 10. Measured on the machine used for this page (Apple silicon laptop, one hash of a short password, Bcrypt.hash(_, cost:)):

Cost Release build Debug build

4

1.5 ms

2.8 ms

8

24 ms

38 ms

10

0.10 s

0.47 s

11

0.21 s

not measured

12

0.38 to 0.40 s

2.5 s

13

0.69 to 0.89 s

5.2 s

14

1.3 to 1.7 s

not measured

The debug column is a warning to anyone who benchmarks with swift run: the C code is compiled without optimisation, and hashing at cost 12 is about six times slower than in a release build. Always measure with -c release and with the hardware you deploy to.

OWASP lists bcrypt as the choice for legacy systems when Argon2id or scrypt are unavailable. Vapor 4.122.2 does not ship an Argon2 or scrypt hasher. PasswordHasher is the extension point (see the custom hasher section), but implementing a memory-hard algorithm yourself is exactly the kind of cryptography this page tells you not to write: if you need one, use an audited package. Bcrypt at an adequate cost remains a sound, widely deployed default.

Hashing and verifying: req.password

req.password and app.password (singular, an accessor over the configured hasher) both conform to PasswordHasher:

public protocol PasswordHasher: Sendable {
    func hash<Password>(_ password: Password) throws -> [UInt8] where Password: DataProtocol
    func verify<Password, Digest>(_ password: Password, created digest: Digest) throws -> Bool
        where Password: DataProtocol, Digest: DataProtocol
}
// plus String convenience overloads: hash(_:) -> String and verify(_:created:) -> Bool

and give you three ways to run the work:

Call Where it runs

try req.password.hash("secret")

Synchronously, on the calling thread. On a route handler that is an event loop thread; bcrypt at cost 12 blocks it for about 0.4 seconds, and with it every other connection that loop serves. Fine at start-up (a seeding command), wrong in a handler.

try await req.password.async.hash("secret")

On app.threadPool, hopping back to the request’s event loop for the result. This is what every handler should use.

req.password.sync / app.password.sync

The configured hasher itself, without the request wrapper. Useful in code that has only an Application.

async returns an AsyncPasswordHasher. Its base methods return EventLoopFuture values, and AsyncPasswordHasher+Concurrency.swift adds async throws overloads of hash and verify (for String and for byte collections), so try await req.password.async.hash(...) needs no .get() and handlers stay in async/await style. The pool is app.threadPool, which by default is NIO’s shared blocking pool, sized to the number of cores and shared with file IO (see Static Files and File I/O). A login storm therefore uses all cores for hashing, which is the point of a work factor, so treat the login and registration routes as the ones to rate-limit (see Security Hardening).

Swift 6 notes. PasswordHasher is Sendable, so a custom hasher must be a struct or a final class whose stored properties are Sendable. The password and digest generics of the async methods require Sendable because they cross to another thread; String and [UInt8] satisfy that. Application.Passwords.Provider takes a @Sendable closure, so do not capture a mutable local variable in a custom provider.

If no hasher has ever been configured, which only happens if something removed the default, the first use calls fatalError("No password verifier configured. Configure with app.passwords.use(...)"); call app.passwords.use before the first hash.

The Bcrypt value directly

Outside a request there is no req.password. Bcrypt works anywhere and ignores app.passwords:

import Vapor

let digest = try Bcrypt.hash("a throwaway password", cost: 12)   // cost defaults to 12
let ok = try Bcrypt.verify("a throwaway password", created: digest)

verify re-derives the hash with the salt and cost read from the digest and compares the checksum with secureCompare (constant time). A malformed digest does not return false; it throws BcryptError.invalidHash ("Invalid hash formatting"). That distinction matters for login: a user row whose passwordHash is empty, because the account was created through some other flow, makes the login route answer 500 unless you handle it.

Prefer req.password in routes so the hasher can be swapped in one place (tests, a peppered variant, a migration to a new algorithm).

Registration and login

The complete example for the Bookshelf service. It shows the password-specific decisions; the session or token that a real login returns is the subject of Authentication and Authorization.

// AccountRoutes.swift
import Fluent
import Vapor

struct RegisterRequest: Content, Validatable {
    var email: String
    var password: String

    static func validations(_ validations: inout Validations) {
        validations.add("email", as: String.self, is: .email)
        // Characters, not composition rules. The byte limit Bcrypt needs is checked in PasswordPolicy.
        validations.add("password", as: String.self, is: .count(12...))
    }
}

enum PasswordPolicy {
    /// Bcrypt only reads the first 72 bytes, so refuse longer passwords instead of silently truncating them.
    static func check(_ password: String) throws {
        guard password.utf8.count <= 72 else {
            throw Abort(.badRequest, reason: "The password must be at most 72 bytes long.")
        }
    }
}

struct LoginRequest: Content {
    var email: String
    var password: String
}

/// What the API returns about a user. The password hash never leaves the server.
struct PublicUser: Content {
    var id: UUID?
    var email: String
    var role: UserRole
}

extension User {
    var asPublic: PublicUser { PublicUser(id: id, email: email, role: role) }
}

enum BcryptHash {
    /// Reads the cost from a hash such as "$2b$12$..." (nil for anything else).
    static func cost(of digest: String) -> Int? {
        let parts = digest.split(separator: "$", omittingEmptySubsequences: true)
        guard parts.count == 3, parts[0].hasPrefix("2"), let cost = Int(parts[1]) else { return nil }
        return cost
    }
}

func accountRoutes(_ app: Application, cost: Int) throws {
    // Verified against when the e-mail is unknown, so both outcomes take about the same time.
    let decoyDigest = try Bcrypt.hash("decoy-password-never-matches", cost: cost)

    app.post("register") { req async throws -> Response in
        try RegisterRequest.validate(content: req)
        let body = try req.content.decode(RegisterRequest.self)
        try PasswordPolicy.check(body.password)
        let email = body.email.lowercased()

        // Runs on the thread pool; the event loop keeps serving other requests.
        let digest = try await req.password.async.hash(body.password)

        let user = User(email: email, passwordHash: digest)
        do {
            try await user.save(on: req.db)
        } catch let error as any DatabaseError where error.isConstraintFailure {
            throw Abort(.conflict, reason: "That e-mail address is already registered.")
        }
        return try await user.asPublic.encodeResponse(status: .created, for: req)
    }

    app.post("login") { req async throws -> PublicUser in
        let body = try req.content.decode(LoginRequest.self)
        let user = try await User.query(on: req.db)
            .filter(\.$email == body.email.lowercased())
            .first()

        // One generic error for "no such user" and "wrong password".
        guard let user else {
            _ = try await req.password.async.verify(body.password, created: decoyDigest)
            throw Abort(.unauthorized, reason: "Invalid e-mail or password.")
        }
        guard try await req.password.async.verify(body.password, created: user.passwordHash) else {
            throw Abort(.unauthorized, reason: "Invalid e-mail or password.")
        }

        // The password is in memory right now, so this is the moment to upgrade an old cost.
        if let stored = BcryptHash.cost(of: user.passwordHash), stored < cost {
            user.passwordHash = try await req.password.async.hash(body.password)
            try await user.save(on: req.db)
            req.logger.info("Rehashed password", metadata: ["from": "\(stored)", "to": "\(cost)"])
        }
        return user.asPublic
    }
}

The decisions in that listing, in the order a request meets them:

  1. The routes sit behind no authentication, but behind the error and logging middleware. A request first goes through Vapor’s middleware chain (see the Middleware page), then RegisterRequest.validate(content:) rejects a bad e-mail or a short password with a 400 before any expensive work happens.

  2. Hash off the event loop. req.password.async.hash moves the 400 ms of work to the thread pool. While it runs, the event loop serves other requests.

  3. Never return the model. User is not Content; PublicUser is the response, so passwordHash cannot leak into JSON, a template or a log line. Making the model itself Content is a common mistake in tutorials.

  4. Database uniqueness decides duplicates. The canonical migration puts a unique constraint on email, so two simultaneous registrations cannot both succeed; the code translates the constraint failure to 409 instead of checking first (a check followed by an insert has a race). The e-mail is lower-cased on both routes so that casing cannot create two accounts. Note that returning 409 tells a visitor that an address is registered; services where that is sensitive should answer 202 and send an e-mail either way.

  5. One answer for every failure. Unknown e-mail and wrong password both produce 401 Invalid e-mail or password..

  6. Same cost for both failures. When the e-mail is unknown, the code still verifies the submitted password against a decoy digest created at start-up with the same cost. Without this, "no such user" returns after microseconds and "wrong password" after 0.4 seconds, and the timing difference tells an attacker which addresses have accounts.

  7. Rehash on login. The cost stored in the digest is parsed ($2b$12$...); if the configured cost has been raised since the account was created, the plaintext password, which is in memory only now, is hashed again and saved. This upgrades active users silently; accounts that never log in keep their old hash.

Captured from the test run (swift test, in-memory SQLite, bcrypt cost 4):

REGISTER 201 {"role":"reader","id":"61BB0EE6-F945-4029-BD81-EEDDCDF62D8B","email":"ada@example.com"}
DUPLICATE 409 {"error":true,"reason":"That e-mail address is already registered."}
SHORT 400 {"error":true,"reason":"password is less than minimum of 12 character(s)"}
LONG 400 {"error":true,"reason":"The password must be at most 72 bytes long."}
LOGIN 200 {"role":"reader","id":"61BB0EE6-F945-4029-BD81-EEDDCDF62D8B","email":"ada@example.com"}
WRONG 401 {"error":true,"reason":"Invalid e-mail or password."}
GHOST 401 {"error":true,"reason":"Invalid e-mail or password."}
REHASH 4 -> 5

and the same service started for real (swift run -c release App serve, default cost 12):

$ curl -s -w ' (%{http_code}, %{time_total}s)\n' -X POST localhost:8093/register \
    -H 'Content-Type: application/json' -d '{"email":"ada@example.com","password":"change-me-please-1"}'
{"role":"reader","id":"CDEEFF0B-D0E8-4523-A7F3-A385D4F54E4B","email":"ada@example.com"} (201, 0.446678s)

# POST /login, right password
{"role":"reader","id":"CDEEFF0B-D0E8-4523-A7F3-A385D4F54E4B","email":"ada@example.com"} (200, 0.424414s)

# POST /login, wrong password
{"reason":"Invalid e-mail or password.","error":true} (401, 0.443866s)

# POST /login, e-mail that does not exist
{"reason":"Invalid e-mail or password.","error":true} (401, 0.471015s)

# POST /register again, with the address in capitals
{"reason":"That e-mail address is already registered.","error":true} (409, 0.480843s)

# POST /register, 5-character password: rejected before any hashing
{"reason":"password is less than minimum of 12 character(s)","error":true} (400, 0.001289s)

The flow, as a diagram:

flowchart TD A[POST /login] --> B[Decode the body] B --> C{User found by e-mail?} C -- no --> D[Verify against the decoy digest on the thread pool] D --> F[401 Invalid e-mail or password] C -- yes --> E[Verify on the thread pool] E -- mismatch --> F E -- match --> G{Stored cost below configured cost?} G -- yes --> H[Hash again and save the user] G -- no --> I[200 PublicUser] H --> I

Password rules

The listing enforces a minimum of 12 characters and, because of bcrypt, a maximum of 72 bytes. That is deliberate. Current guidance (NIST SP 800-63B and the OWASP Authentication Cheat Sheet) is to require length, allow every printable character and spaces, and not to demand mixtures of upper case, digits and symbols or force periodic changes. If you can afford it, also reject passwords found in public breach lists. The Validation page covers Validatable, which the listing uses for the e-mail; do not trim or otherwise normalise the password before hashing.

A custom hasher with a pepper

A pepper is a secret mixed into every password before hashing, kept outside the database (an environment variable or secrets store). A database leak alone then is not enough to test guesses. PasswordHasher plus a Provider extension is all it takes to plug one in. This one computes HMAC-SHA256 of the password under the pepper, base64 encodes the 32-byte result to 44 ASCII characters and feeds that to bcrypt, which also removes the 72-byte and NUL limitations:

// PepperedHasher.swift
import Vapor

/// Bcrypt over HMAC-SHA256(pepper, password), base64 encoded.
/// The pre-hash is always 44 ASCII characters, so Bcrypt's 72-byte limit and its
/// handling of NUL bytes no longer matter, and the server-side pepper is mixed in.
struct PepperedBcryptHasher: PasswordHasher {
    let pepper: SymmetricKey
    let cost: Int

    private func prehash<Password: DataProtocol>(_ password: Password) -> String {
        let mac = HMAC<SHA256>.authenticationCode(for: password, using: self.pepper)
        return Data(mac).base64EncodedString()
    }

    func hash<Password: DataProtocol>(_ password: Password) throws -> [UInt8] {
        Array(try Bcrypt.hash(self.prehash(password), cost: self.cost).utf8)
    }

    func verify<Password: DataProtocol, Digest: DataProtocol>(
        _ password: Password,
        created digest: Digest
    ) throws -> Bool {
        try Bcrypt.verify(self.prehash(password), created: String(decoding: digest, as: UTF8.self))
    }
}

extension Application.Passwords.Provider {
    static func pepperedBcrypt(pepper: SymmetricKey, cost: Int) -> Self {
        .init { app in
            app.passwords.use { _ in PepperedBcryptHasher(pepper: pepper, cost: cost) }
        }
    }
}

Use it with app.passwords.use(.pepperedBcrypt(pepper: key, cost: 12)), where key is built from an environment secret as in the secrets section below. Captured from the test run:

LIMIT peppered, 73-byte passwords that differ in the last byte verify as equal: false
PEPPERED $2b$04$iIStAvejA12G1rNZdrx0fuHEk6q4EPNsNWx1zJEAVLJBIUq9mqDIS

Think before adopting it:

  • OWASP does not recommend pre-hashing in general, because it can introduce null-byte truncation and "password shucking" problems when done carelessly. Base64 output (no NUL bytes) and an HMAC rather than a bare hash avoid both here, which is also the shape OWASP gives when pre-hashing cannot be avoided.

  • A pepper is defence in depth, never a replacement for the salt or the cost. If it leaks it cannot be rotated without forcing password resets, since old hashes cannot be re-derived without the plaintext. Store it in a secrets manager and document who may read it.

  • Changing the hasher invalidates every stored digest. Roll out by recognising the old format (a digest starting with $2b$ from plain bcrypt versus another marker) and rehashing on login, as the login route does for cost.

For most services plain bcrypt with a good cost and a length cap is the right default.

Testing code that hashes

  • Configure .bcrypt(cost: 4) for the .testing environment as in configurePasswords; it keeps the real code path and costs about 3 ms per hash.

  • Create users in tests with try Bcrypt.hash("some throwaway password", cost: 4) and log in through the HTTP route, as the VaporTesting suite for this page does with app.testing().test(.POST, "login", ...).

  • To test the rehash path, seed a user whose digest has a lower cost than the configured one and assert on the stored cost afterwards, as the REHASH 4 -> 5 line above shows.

  • Never commit real hashes, not even "for seeding": a published bcrypt hash of an admin password is an offline guessing target. Seed accounts from environment variables or interactive input (see Authentication), and fail fast in production if the variable is missing.

Cryptography with SwiftCrypto

What import Vapor gives you

Vapor depends on swift-crypto, Apple’s open-source implementation of the CryptoKit API for Linux and other platforms, and re-exports its Crypto module (@_exported import Crypto in Exports.swift). A file that contains import Vapor can therefore use these types without another import; a file in a library target that does not depend on Vapor uses import Crypto. On Apple platforms the module forwards to CryptoKit, so the same code runs on a Mac and in a Linux container.

The Vapor documentation lists what is available:

Group Types

Hash functions

SHA256, SHA384, SHA512

Message authentication

HMAC<H> over any of those hashes

Authenticated encryption

AES.GCM, ChaChaPoly

Public-key cryptography

P256, P384, P521 (signing and key agreement), Curve25519 (Ed25519 signatures and X25519 key agreement)

Key derivation

HKDF<H>, and SharedSecret.hkdfDerivedSymmetricKey (used below)

Legacy, interoperability only

Insecure.SHA1, Insecure.MD5 (the name is a warning)

Keys

SymmetricKey (a secure container that is zeroed on release), the PrivateKey and PublicKey types of each curve

Because these are CryptoKit’s types, Apple’s CryptoKit documentation and WWDC material apply to them unchanged. What follows are the patterns that come up in a web service, each of which is in the scratch package’s test suite.

Hashing and the Insecure namespace

import Vapor

let digest = SHA256.hash(data: Data("hello".utf8))
let hex = digest.map { String(format: "%02x", $0) }.joined()
// 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824

Captured:

SHA256 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
SHA1 aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d
MD5 5d41402abc4b2a76b9719d911017c592

SHA-1 and MD5 are kept for protocols that mandate them (weak ETags, an S3-style Content-MD5 header, legacy checksums). They are broken for security purposes and the Insecure. prefix is there to make a reviewer notice. Never use them for signatures, tokens or passwords.

HMAC and webhook signatures

An HMAC proves that whoever produced a message knew a shared secret. The common use in a service is verifying a webhook sent by a payment or Git provider: they compute HMAC-SHA256(secret, raw body) and send it in a header.

// CryptoHelpers.swift
import Vapor

// MARK: Hashing and HMAC

enum Digests {
    static func sha256Hex(_ text: String) -> String {
        SHA256.hash(data: Data(text.utf8)).map { String(format: "%02x", $0) }.joined()
    }

    /// Hex string to bytes; nil when the input is not valid hex.
    static func bytes(fromHex hex: String) -> [UInt8]? {
        guard hex.utf8.count.isMultiple(of: 2) else { return nil }
        var bytes: [UInt8] = []
        var index = hex.startIndex
        while index < hex.endIndex {
            let next = hex.index(index, offsetBy: 2)
            guard let byte = UInt8(hex[index..<next], radix: 16) else { return nil }
            bytes.append(byte)
            index = next
        }
        return bytes
    }
}

enum Webhook {
    /// Checks "sha256=<hex>" against HMAC-SHA256 of the raw body, in constant time.
    static func isValid(body: some DataProtocol, signatureHeader: String, secret: SymmetricKey) -> Bool {
        guard signatureHeader.hasPrefix("sha256="),
              let provided = Digests.bytes(fromHex: String(signatureHeader.dropFirst("sha256=".count)))
        else { return false }
        return HMAC<SHA256>.isValidAuthenticationCode(provided, authenticating: body, using: secret)
    }

    static func sign(body: some DataProtocol, secret: SymmetricKey) -> String {
        let mac = HMAC<SHA256>.authenticationCode(for: body, using: secret)
        return "sha256=" + Array(mac).hex
    }
}

// MARK: Secrets from the environment

enum Secrets {
    /// A 256-bit key given as base64 in an environment variable. Fails fast when it is missing or wrong.
    static func symmetricKey(named name: String) throws -> SymmetricKey {
        guard let text = Environment.get(name), let data = Data(base64Encoded: text), data.count == 32 else {
            throw Abort(.internalServerError, reason: "\(name) must be 32 bytes, base64 encoded.")
        }
        return SymmetricKey(data: data)
    }
}

// MARK: Random tokens

struct IssuedToken: Sendable {
    /// Shown to the user once (in an e-mail link, say). Never stored.
    let plain: String
    /// What goes in the database: the SHA-256 of the token, hex encoded.
    let digest: String
}

enum Tokens {
    static func make(byteCount: Int = 32) -> IssuedToken {
        let plain = [UInt8].random(count: byteCount).base64String()
            .replacingOccurrences(of: "+", with: "-")
            .replacingOccurrences(of: "/", with: "_")
            .replacingOccurrences(of: "=", with: "")
        return IssuedToken(plain: plain, digest: Digests.sha256Hex(plain))
    }

    /// The submitted token is hashed and the two digests are compared in constant time.
    static func matches(_ submitted: String, storedDigest: String) -> Bool {
        Digests.sha256Hex(submitted).secureCompare(to: storedDigest)
    }
}

// MARK: Authenticated encryption (AES-256-GCM)

struct FieldCipher: Sendable {
    let key: SymmetricKey

    /// Returns base64 of nonce (12 bytes) + ciphertext + tag (16 bytes).
    func seal(_ plaintext: String, context: String) throws -> String {
        let box = try AES.GCM.seal(Data(plaintext.utf8), using: self.key, authenticating: Data(context.utf8))
        guard let combined = box.combined else { throw Abort(.internalServerError) }
        return combined.base64EncodedString()
    }

    func open(_ sealed: String, context: String) throws -> String {
        guard let data = Data(base64Encoded: sealed) else { throw Abort(.badRequest, reason: "Not base64.") }
        let box = try AES.GCM.SealedBox(combined: data)
        let plain = try AES.GCM.open(box, using: self.key, authenticating: Data(context.utf8))
        return String(decoding: plain, as: UTF8.self)
    }
}

// MARK: Routes that exercise the helpers

func cryptoRoutes(_ app: Application) throws {
    app.post("webhooks", "payments") { req async throws -> HTTPStatus in
        guard let header = req.headers.first(name: "X-Signature"),
              let body = req.body.data,
              let secret = Environment.get("WEBHOOK_SECRET")
        else { throw Abort(.unauthorized) }

        let key = SymmetricKey(data: Data(secret.utf8))
        guard Webhook.isValid(body: body.readableBytesView, signatureHeader: header, secret: key) else {
            throw Abort(.unauthorized, reason: "Bad signature.")
        }
        return .noContent
    }
}

Points to take from it:

  • Use isValidAuthenticationCode, which compares in constant time. Array(mac) == provided or comparing hex strings with == exits at the first different byte and leaks, byte by byte, how much of a forged value was right. The same applies to any shared secret in a header (an API key between services): compare with secureCompare or by comparing HMACs, never with ==.

  • HMAC the raw bytes of the body, as received. Decoding to JSON and re-encoding changes the bytes. The route reads req.body.data (already collected, as the request body is by default) rather than decoding Content.

  • Vapor collects a request body in memory only up to app.routes.defaultMaxBodySize (16 kb by default); a larger webhook payload gets 413 unless the route is registered with body: .collect(maxSize:) (see File Uploads and Streaming).

  • The route is async throws and does no blocking work, so no thread-pool hop is needed; HMAC and hashing of a small body take microseconds.

  • The secret comes from the environment (WEBHOOK_SECRET) and the code answers 401 when it is missing instead of falling back to a default. Use the same pattern for every key.

Captured from the test run, with a throwaway secret:

SIGNATURE sha256=ae90139945d9aa2c341bf9946ec2aeeb49f8b23c8a7c7732953362bd522daf60
TAMPERED 401        (the same signature, with one extra space appended to the body)

Random bytes and tokens

[UInt8].random(count:) is a Vapor extension (Array+Random.swift). It fills the array with FixedWidthInteger.random(), which uses Swift’s SystemRandomNumberGenerator, the operating system’s cryptographically secure source. Vapor itself uses it for session identifiers and bcrypt salts. SymmetricKey(size: .bits256) generates a key from the same source. Do not use Int.random(in:) with a seeded generator, UUID().uuidString or the clock for anything an attacker must not guess; a UUIDv4 is random but has 122 bits and is not designed as a bearer secret, and v7 contains a timestamp.

The pattern for any single-use secret (a password-reset or e-mail verification link, an opaque API token) is: generate, show once, store only its hash. Delivering the link is covered by Sending Email.

let issued = Tokens.make()          // 32 random bytes, base64url, 43 characters
// e-mail issued.plain in the link; store issued.digest (64 hex characters) with an expiry
let ok = Tokens.matches(submitted, storedDigest: issued.digest)

(Tokens is in the CryptoHelpers.swift listing above.) The token is already 256 random bits, so a fast SHA-256 is the right hash here: there is nothing to brute-force, unlike a human-chosen password. Storing the plain token would let a database reader log in as anyone with a pending reset, and a token without an expiry column never stops being valid; both mistakes appear in the books this section draws on (see Where the books are outdated). Captured: a token is 43 characters and its digest 64.

AES-GCM and ChaChaPoly: encrypting a stored value

Sometimes a value has to be recoverable, unlike a password: a third-party API token the user gave you, or the TOTP secret in the next section. For that use authenticated encryption. AES.GCM and ChaChaPoly take a 256-bit key, generate a fresh random 96-bit nonce for each seal, and produce ciphertext plus a 128-bit tag; open fails if a single bit was changed. FieldCipher in the listing above stores nonce + ciphertext + tag as one base64 string. The authenticating: argument binds the ciphertext to a context (here user:42:totp): copying a sealed value to another row makes open throw instead of decrypting.

Captured from the run:

SEALED 60               (base64 length for a 16-character value: 12 + 16 + 16 bytes)
opening with another context, another key, or one flipped bit: all three throw
sealing the same value twice gives two different strings: true

Rules that matter more than the API:

  • Never reuse a nonce with the same key. Letting seal choose the nonce (as above) is correct; do not pass your own unless you know the construction.

  • The key never lives in the repository or the database. Build it from an environment variable and generate it once: openssl rand -base64 32. Secrets.symmetricKey(named:) above fails fast if the variable is missing or is not 32 bytes. Keep an identifier next to each sealed value (a key version column) so a key can be rotated.

  • ChaChaPoly has the same shape (ChaChaPoly.seal(data, using: key), ChaChaPoly.open(box, using: key)) and is a good choice where AES hardware support is absent; the test suite round-trips it. Pick one and stay with it.

  • Encryption protects data at rest from a stolen database, not from a compromised server: the key is in the same process.

Signatures and key agreement

Public-key types appear when a third party must verify something without sharing a secret (signed downloads, webhooks you send, client-side encryption), or when two parties derive a key (end-to-end flows). The shapes:

import Vapor

let key = P256.Signing.PrivateKey()                          // generate once, store the PEM or raw bytes as a secret
let signature = try key.signature(for: Data("message".utf8))
let valid = key.publicKey.isValidSignature(signature, for: Data("message".utf8))

let a = Curve25519.KeyAgreement.PrivateKey()
let b = Curve25519.KeyAgreement.PrivateKey()
let shared = try a.sharedSecretFromKeyAgreement(with: b.publicKey)
let derived = shared.hkdfDerivedSymmetricKey(
    using: SHA256.self, salt: Data("salt".utf8), sharedInfo: Data("bookshelf v1".utf8), outputByteCount: 32)

The run showed a P-256 signature of 64 raw bytes (70 as DER, the encoding JWTs do not use), a 65-byte uncompressed public key, a 64-byte Ed25519 signature, and that both sides of the key agreement derive equal keys. Never use a raw shared secret as a key: always pass it through HKDF as above. JSON Web Tokens, the main place a Vapor service signs things, are covered on the JSON Web Tokens (JWT) page with their own library.

Constant-time comparison and secrets in general

  • secureCompare(to:) (Vapor, on Collection where Element: Equatable) compares all elements without exiting early, and returns false for different lengths. Use it for digests, tokens and API keys held as strings or byte arrays.

  • HMAC.isValidAuthenticationCode and the Bcrypt verify do the same internally.

  • Secrets are read from the environment (see the Configuration and Environment page), never given a default, and checked at start-up in production. Keys that appear in Vapor’s Environment are plain strings; convert and validate them once.

One-time passwords: HOTP and TOTP

What they are

A one-time password (OTP) is a short code derived from a secret shared with the user’s authenticator app plus something that changes: a counter (HOTP, RFC 4226) or the clock (TOTP, RFC 6238). The code is the HMAC of the counter, truncated to 6, 7 or 8 digits. It is a second factor ("something you have"): it follows the password check, it does not replace it. The Authentication Methods: Passwordless and 2FA page of the OAuth section places TOTP among the other second factors and explains why 2FA never makes a login passwordless.

The Vapor API

Vapor 4 ships HOTP and TOTP structs in Sources/Vapor/Security/OTP.swift:

let key = SymmetricKey(size: .bits128)
let hotp = HOTP(key: key, digest: .sha1, digits: .six)
let code = hotp.generate(counter: 25)                 // a String such as "755224"
let nearby = hotp.generate(counter: 25, range: 2)     // 5 codes: counters 23 to 27

let totp = TOTP(key: key, digest: .sha1, digits: .six, interval: 30)
let current = totp.generate(time: Date())
let window = totp.generate(time: Date(), range: 1)    // previous, current, next step
  • OTPDigest is .sha1, .sha256 or .sha512; OTPDigits is .six, .seven or .eight. interval defaults to 30 seconds and must be greater than 0 (a precondition crashes the process otherwise).

  • generate(counter:range:) and generate(time:range:) return 2 * range + 1 codes centred on the current one, so a server can tolerate clock drift or a counter that ran ahead. The range must be bigger than 0. The Vapor documentation warns that a wider margin gives an attacker more chances.

  • HOTP.generate(key:digest:digits:counter:) and TOTP.generate(key:digest:digits:interval:time:) are static shortcuts.

  • The output is a String, zero padded to the digit count.

The implementation was checked against the published test vectors, not just against itself. RFC 4226 appendix D gives 755224, 287082 and 359152 for counters 0, 1 and 2 (the RFC’s own test key); RFC 6238 appendix B gives 94287082 (SHA-1), 46119246 (SHA-256) and 90693936 (SHA-512) for Unix time 59 with eight digits and a 30-second step. Captured:

HOTP ["755224", "287082", "359152"]   range:1 around counter 5: ["338314", "254676", "287922"]
RFC SHA1 94287082 SHA256 46119246 SHA512 90693936

(These keys are the RFC’s published test strings and secure nothing.)

Enrolling and verifying

What the library leaves to you is everything around the primitive: the secret, the QR code, storage, the verification window and replay and brute-force protection.

// OTPExamples.swift
import Vapor

struct TOTPEnrolment: Sendable {
    /// 160 random bits, the size RFC 4226 recommends.
    let secret: [UInt8]

    static func start() -> TOTPEnrolment {
        TOTPEnrolment(secret: [UInt8].random(count: 20))
    }

    /// The text authenticator apps expect, unpadded base32.
    var secretBase32: String {
        secret.base32String().replacingOccurrences(of: "=", with: "")
    }

    /// The otpauth URI behind the QR code. SHA-1, 6 digits and 30 s are what authenticator apps support everywhere.
    func uri(issuer: String, account: String) -> String {
        let label = "\(issuer):\(account)"
        var parts = URLComponents()
        parts.scheme = "otpauth"
        parts.host = "totp"
        parts.path = "/" + label
        parts.queryItems = [
            URLQueryItem(name: "secret", value: secretBase32),
            URLQueryItem(name: "issuer", value: issuer),
            URLQueryItem(name: "algorithm", value: "SHA1"),
            URLQueryItem(name: "digits", value: "6"),
            URLQueryItem(name: "period", value: "30"),
        ]
        return parts.string ?? ""
    }

    var totp: TOTP {
        TOTP(key: SymmetricKey(data: secret), digest: .sha1, digits: .six, interval: 30)
    }

    /// Accepts the current 30-second step and one step either side; rejects a step that was already used.
    /// `lastAcceptedStep` is stored per user; the new value is returned on success.
    func verify(_ code: String, now: Date = Date(), lastAcceptedStep: Int?) -> Int? {
        let step = Int(now.timeIntervalSince1970) / 30
        for candidate in (step - 1)...(step + 1) {
            let expected = totp.generate(time: Date(timeIntervalSince1970: TimeInterval(candidate * 30)))
            if expected.secureCompare(to: code) {
                if let last = lastAcceptedStep, candidate <= last { return nil }
                return candidate
            }
        }
        return nil
    }
}

The flow it supports:

  1. Enrol. After the password step succeeds, call TOTPEnrolment.start(). Show uri(issuer:account:) as a QR code (the browser or app renders it; Vapor does not) and the base32 string for manual entry. Seal the secret with FieldCipher and store it with an enabled = false flag.

  2. Confirm. The user types the first code. verify accepts the previous, current and next 30-second step. On success set enabled = true and store the returned step as lastAcceptedStep.

  3. Sign in. After the password check the server asks for the code, decrypts the secret, calls verify(_:lastAcceptedStep:) and stores the new step. A code from a step less than or equal to the stored one is rejected, so one code cannot be used twice (a shoulder surfer or a phishing proxy relays codes within seconds).

  4. Recovery. Issue 8-10 single-use recovery codes at enrolment, stored hashed like password-reset tokens. Without them, a lost phone is a locked account and a support ticket.

Captured behaviour from the test (enrolment): the current code is accepted and returns the step; the same code again is refused; the code of the previous step is accepted; the code from three steps ago is refused; a malformed code is refused.

Pitfalls:

  • A six-digit code has a million values; a 30-second window gives an attacker three of them per attempt. Limit attempts per account (the rate limiting discussion on the Security Hardening page) and lock or slow down after a few failures. This is the biggest practical weakness of OTP.

  • Authenticator apps almost universally support SHA-1, six digits and 30 seconds. The otpauth URI can advertise other parameters, but many apps ignore them, so non-default settings may produce codes the user’s app does not match.

  • The secret is as sensitive as a password: encrypted at rest, never logged, never returned after enrolment.

  • TOTP is phishable: a fake site can ask for the code and replay it at once. Passkeys/WebAuthn are phishing-resistant; the OAuth section’s 2FA page compares them.

  • Clock skew: a server whose clock drifts makes all codes fail. Keep NTP running; range is for the user’s phone, not for fixing the server.

Vapor 5

The 5.0.0-beta.4 source tree (read through the GitHub API) has no Sources/Vapor/Security directory and no OTP.swift. Code that depends on HOTP and TOTP therefore has to bring its own implementation to move to Vapor 5. The algorithm is about fifty lines on top of HMAC (the Vapor source is a readable reference), or use an audited package. Treat the Vapor 4 types as stable but not future-proof.

Common errors and pitfalls

  • BcryptError.invalidCost ("Cost should be between 4 and 31"). A cost outside the range, usually from a mistyped environment variable. Validate the variable at start-up.

  • BcryptError.invalidHash ("Invalid hash formatting") at login. verify was given something that is not a bcrypt string: an empty column, a hash from another algorithm, a password stored in plaintext. Handle it as a failed login and log it.

  • BcryptError.invalidSalt. Only when calling Bcrypt.hash(_:salt:) with a hand-made salt; do not.

  • Login is slow and the server stalls under load. req.password.hash or verify called synchronously in a handler. Switch to req.password.async.

  • Everyone can log in after the password was truncated. A 72-byte limit unnoticed. Cap length or pre-hash.

  • fatalError: No password verifier configured. app.passwords.use was never reached before the first hash.

  • Timing differences reveal accounts. Unknown-user path returns faster than the wrong-password path. Use the decoy digest.

  • Password hash in an API response. A Content model or a log of the full user. Use a public DTO.

  • Insecure.MD5 or SHA-1 used for a security purpose. Replace with HMAC-SHA256 or a password hash.

  • secureCompare skipped. A == on tokens, signatures, API keys.

  • TOTP.init crashes. interval of 0.

  • Data race diagnostics for a custom hasher. Make the type Sendable; keep the pepper in a SymmetricKey (which is Sendable) rather than a mutable global.

  • Tests take minutes. Bcrypt at cost 12 in a test that creates users in a loop.

Where the books are outdated

The books this section draws on predate parts of what is on this page:

  • Early hashing examples. One book first shows SHA-256 for passwords and only then bcrypt. A fast hash is not a password hash; start with req.password.

  • Plaintext tokens. Several books store bearer and password-reset tokens as received. The token example above stores the SHA-256 digest and an expiry instead; a leaked table of tokens is then not a set of working credentials.

  • Published seed hashes and default admin accounts. A seeded administrator with a trivial password, or bcrypt hashes printed in the text for seeding, are copied into real projects. Use environment-supplied secrets and generate hashes at run time.

  • API names from Vapor 3. Material from the Vapor 3 era obtains its hasher through that version’s service container, which no longer exists. In Vapor 4 the hasher is app.passwords and req.password, Bcrypt is a global value (the type behind it is still called BCryptDigest, and older snippets spell the global BCrypt), and the digests and MACs are SwiftCrypto’s. Check any older snippet against the 4.122 API before copying it.

  • Default crypto keys in configuration files. Some books keep signing keys in a JSON file in the repository with a default value. Read every key from the environment and refuse to start without it.

  • == for shared secrets. One book compares a shared-secret header with ==; use secureCompare or an HMAC comparison.

  • No password policy. Older examples accept any password. See the rules above.

Version notes and outdated material

  • Bcrypt, app.passwords, req.password and req.password.async exist throughout Vapor 4 (the documented default cost of 12 matches the 4.122.2 source). app.password (singular) is the application-level accessor; app.passwords selects the provider.

  • Application.Passwords.Provider.plaintext is present in 4.122.2 for tests.

  • OTP.swift (HOTP, TOTP) is in Vapor 4.122.2. In the 5.0.0-beta.4 tree the directories Sources/Vapor/Passwords, Sources/Vapor/Bcrypt and Sources/Vapor/Security are absent too, while the package still depends on swift-crypto (from: "5.0.0"), so the Crypto primitives remain. The beta tree has no password hasher in those places; check the Vapor 5 upgrade notes when it is released, and keep your hashing behind your own small interface to make a change cheap.

  • swift-crypto resolved to 4.5.2 for this build; the current tag at the time of writing is 5.0.1. The APIs on this page are the CryptoKit-compatible ones and did not change between those versions as far as this page uses them.

  • The Vapor documentation page for Passwords does not state that unknown or malformed digests throw rather than return false; the 4.122.2 source does.