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 |
|
Vapor itself ( |
Hashes, MACs, encryption, signatures, key agreement |
|
SwiftCrypto, re-exported by |
Random bytes and tokens |
|
Vapor (the array extension), SwiftCrypto, the standard library |
Compare secrets without leaking timing |
|
Vapor, SwiftCrypto |
Second factor with an authenticator app |
|
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.hashaccepts 4 through 31 and throwsBcryptError.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 with cost 12. This is what an application has when it never calls |
|
Bcrypt with the given cost. Throws |
|
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 |
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 |
|---|---|
|
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. |
|
On |
|
The configured hasher itself, without the request wrapper. Useful in code that has only an |
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)
Source: Vapor docs: Crypto, Bcrypt
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:
-
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. -
Hash off the event loop.
req.password.async.hashmoves the 400 ms of work to the thread pool. While it runs, the event loop serves other requests. -
Never return the model.
Useris notContent;PublicUseris the response, sopasswordHashcannot leak into JSON, a template or a log line. Making the model itselfContentis a common mistake in tutorials. -
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. -
One answer for every failure. Unknown e-mail and wrong password both produce
401 Invalid e-mail or password.. -
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.
-
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:
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) }
}
}
}
Source: vapor/vapor 4.122.2: BcryptHasher.swift (the built-in provider this follows); OWASP: Password Storage Cheat Sheet
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.testingenvironment as inconfigurePasswords; 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 withapp.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 -> 5line 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 |
|
Message authentication |
|
Authenticated encryption |
|
Public-key cryptography |
|
Key derivation |
|
Legacy, interoperability only |
|
Keys |
|
Source: Vapor docs: Crypto; apple/swift-crypto
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
Source: Vapor docs: Crypto
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
}
}
Source: Vapor docs: Crypto; Apple: CryptoKit HMAC
Points to take from it:
-
Use
isValidAuthenticationCode, which compares in constant time.Array(mac) == providedor 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 withsecureCompareor 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 decodingContent. -
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 withbody: .collect(maxSize:)(see File Uploads and Streaming). -
The route is
async throwsand 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
sealchoose 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. -
ChaChaPolyhas 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)
Source: Vapor docs: Crypto; Apple: CryptoKit
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, onCollection where Element: Equatable) compares all elements without exiting early, and returnsfalsefor different lengths. Use it for digests, tokens and API keys held as strings or byte arrays. -
HMAC.isValidAuthenticationCodeand the Bcryptverifydo 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
Environmentare 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
-
OTPDigestis.sha1,.sha256or.sha512;OTPDigitsis.six,.sevenor.eight.intervaldefaults to 30 seconds and must be greater than 0 (apreconditioncrashes the process otherwise). -
generate(counter:range:)andgenerate(time:range:)return2 * range + 1codes 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:)andTOTP.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:
-
Enrol. After the password step succeeds, call
TOTPEnrolment.start(). Showuri(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 withFieldCipherand store it with anenabled = falseflag. -
Confirm. The user types the first code.
verifyaccepts the previous, current and next 30-second step. On success setenabled = trueand store the returned step aslastAcceptedStep. -
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). -
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
otpauthURI 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;
rangeis 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.verifywas 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 callingBcrypt.hash(_:salt:)with a hand-made salt; do not. -
Login is slow and the server stalls under load.
req.password.hashorverifycalled synchronously in a handler. Switch toreq.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.usewas 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
Contentmodel or a log of the full user. Use a public DTO. -
Insecure.MD5or SHA-1 used for a security purpose. Replace with HMAC-SHA256 or a password hash. -
secureCompareskipped. A==on tokens, signatures, API keys. -
TOTP.initcrashes.intervalof 0. -
Data race diagnostics for a custom hasher. Make the type
Sendable; keep the pepper in aSymmetricKey(which isSendable) 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.passwordsandreq.password,Bcryptis a global value (the type behind it is still calledBCryptDigest, and older snippets spell the globalBCrypt), 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==; usesecureCompareor an HMAC comparison. -
No password policy. Older examples accept any password. See the rules above.
Version notes and outdated material
-
Bcrypt,app.passwords,req.passwordandreq.password.asyncexist throughout Vapor 4 (the documented default cost of 12 matches the 4.122.2 source).app.password(singular) is the application-level accessor;app.passwordsselects the provider. -
Application.Passwords.Provider.plaintextis 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 directoriesSources/Vapor/Passwords,Sources/Vapor/BcryptandSources/Vapor/Securityare absent too, while the package still depends on swift-crypto (from: "5.0.0"), so theCryptoprimitives 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.