Microservices and an API Gateway
|
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 JWT 5.1.2, Fluent 4.13.0,
AsyncHTTPClient 1.36.2 and swift-nio 2.104.0, checked on 2026-10-17. Every Swift listing on this page was compiled in
the
Swift 6 language mode with zero warnings in one scratch package; the gateway, users and catalog services were started as
three processes and exercised with curl, the service authentication was covered by a VaporTesting suite, and the
output
shown is captured from those runs. The mutual TLS listing and the Compose and Docker files were only compiled or
validated
(docker compose config); no certificates were issued and no image was built for this page. Vapor 5 is at 5.0.0-beta.4
and is
not released yet; the design below uses only Application, middleware, Client and JWT, which have counterparts there,
but
re-check the signatures on upgrade. The page extends the Bookshelf service of the earlier pages: it splits
Bookshelf into a users service and a catalogue service behind a gateway. Swift itself is covered by the
Swift Reference, and async functions, Sendable and actors by
Async/Await and Tasks and
Actors, Isolation and Sendable. The same topic in
Rust
is in the Axum section; the language-neutral reasoning behind the split is in
Choosing Monolith or
Microservices
and API Gateway and Backends for Frontends.
Monolith first
A Vapor application is one process with one Application, one connection pool per database and one deployable. For most
teams and most products that is the right size for a long time: a call between two parts of it is a function call, a
transaction can span any two tables, one swift test exercises everything, and one image is deployed. Splitting it
costs a
network between the parts, a partial-failure mode for every call that crosses it, data that can no longer be joined, a
second and third thing to deploy, monitor and version, and distributed transactions you can no longer have (see
Why Not Distributed
Transactions).
The usual advice, which this page follows, is to start as a well-structured monolith and to split along a seam that has
proved itself:
-
Different scaling or availability needs. The catalogue is read thousands of times per write and can be cached aggressively; the users service handles logins and must stay up when the catalogue is being redeployed.
-
Different change rates or owners. Two teams that keep blocking each other’s releases are a better reason than a diagram.
-
A different security boundary. Password hashes and tokens in a small service with no public routes of its own shrinks what a bug in the catalogue can reach.
-
A different runtime. A service that needs another language, library or hardware.
Reasons that do not justify a split: "microservices are modern", a folder structure that feels too large (use SwiftPM targets instead, see Project Structure and SwiftPM), or hoping that a boundary will force discipline that a module boundary would give you for free. A modular monolith (one deployable, strict module boundaries, no cross-module database access) keeps the option open; the monolith to microservices migration path describes how to cut one out later.
Splitting Bookshelf
The split used on this page has three deployables and two databases. Clients talk only to the gateway; the services are not reachable from outside the private network.
service token (aud users)"| U["users service"] G -->|"relay and compose
service token (aud catalog)"| K["catalog service"] K -->|"reviewer names
service token (aud users)"| U U --> UD[("users database")] K --> KD[("catalog database")]
The responsibilities are drawn so that every piece of data has exactly one owner:
| Service | Owns | Does not own |
|---|---|---|
gateway |
Nothing persistent. The public routes, the user-token check, the mapping of public paths to services, and response composition. |
Any business rule. Rules in the gateway cannot be tested without it and tie every service to it. |
users |
Accounts, password hashes, the signing key for user tokens, display names. |
Books and reviews. |
catalog |
Books and reviews. A review stores the author’s |
User records: it asks the users service for a display name. |
The scratch package for this page builds one executable whose role comes from the SERVICE_NAME environment variable.
That
keeps the page’s listings in one place and makes a three-process demo possible with a single binary. In a real
repository
the shared pieces (service-token claims, the upstream client, DTOs) go in a library target and each service is its own
executable target with its own configure, so a service cannot import another’s models by accident; see
Project Structure and SwiftPM. The selection is made in
configure:
import Vapor
/// One binary, three roles. The role and the peer URLs come from the environment, as they would from Compose.
func configure(_ app: Application) async throws {
let role = Environment.get("SERVICE_NAME") ?? "gateway"
let usersURL = Environment.get("USERS_URL") ?? "http://127.0.0.1:8081"
let catalogURL = Environment.get("CATALOG_URL") ?? "http://127.0.0.1:8082"
try await addServiceKey(to: app)
switch role {
case "users":
app.serviceIdentity = ServiceIdentity(name: "users", allowedCallers: ["gateway", "catalog"])
try await usersRoutes(app)
case "catalog":
app.serviceIdentity = ServiceIdentity(name: "catalog", allowedCallers: ["gateway"])
// The catalog may retry its read of the users service, but only briefly: the gateway above it waits 2 seconds.
catalogRoutes(app, users: UpstreamClient(name: "users", baseURL: usersURL, timeout: .milliseconds(400), attempts: 2))
default:
app.serviceIdentity = ServiceIdentity(name: "gateway", allowedCallers: [])
// One layer retries (the one next to the failing dependency); the gateway does not retry on top of it.
gatewayRoutes(app, users: UpstreamClient(name: "users", baseURL: usersURL, timeout: .seconds(1)),
catalog: UpstreamClient(name: "catalog", baseURL: catalogURL, timeout: .seconds(2)))
}
app.get("healthz") { _ in "ok" }
}
Source: Vapor docs: Environment
The peer URLs and the secret are configuration, read as described in Configuration and Environment. Everything below is that one binary.
One database per service
The rule that makes a microservice a microservice is that no other service reads or writes its tables. If the catalog
could run SELECT against the users table, the two would deploy and migrate together and the split would only be a
network hop in the middle of a monolith. So each service has its own database (a separate database with separate
credentials is enough; a separate server is better), its own models and its own migrations.
import Fluent
import FluentSQLiteDriver
import Vapor
/// The users service's table. Nothing outside the users service opens this database.
final class UserRow: Model, @unchecked Sendable {
static let schema = "users"
@ID(key: .id) var id: UUID?
@Field(key: "email") var email: String
@Field(key: "display_name") var displayName: String
@Field(key: "password_hash") var passwordHash: String
init() {}
}
/// The catalog service's review table. `user_id` is a plain value, not `@Parent`: the users table is in another
/// database, so there is no foreign key and no join. The catalog asks the users service when it needs a name.
final class ReviewRow: Model, @unchecked Sendable {
static let schema = "reviews"
@ID(key: .id) var id: UUID?
@Field(key: "isbn") var isbn: String
@Field(key: "user_id") var userID: UUID
@Field(key: "stars") var stars: Int
init() {}
}
struct CreateUsers: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(UserRow.schema)
.id()
.field("email", .string, .required)
.field("display_name", .string, .required)
.field("password_hash", .string, .required)
.unique(on: "email")
.create()
}
func revert(on database: any Database) async throws { try await database.schema(UserRow.schema).delete() }
}
/// Each service connects to its own database, named by its own environment variable. In Compose these point at
/// different containers (or different databases on one server with different credentials).
func configureUsersDatabase(_ app: Application) async throws {
app.databases.use(.sqlite(.file(Environment.get("USERS_DB_FILE") ?? "users.sqlite")), as: .sqlite)
app.migrations.add(CreateUsers())
try await app.autoMigrate()
}
Source: Vapor docs: Fluent overview
Three consequences are worth stating plainly:
-
No foreign key and no join across services.
ReviewRow.userIDis aUUIDfield, not a@Parent, because there is nouserstable in the catalog database to point at. To show a reviewer’s name the catalog calls the users service (thereviewsroute below); the cost is a call per distinct user (or one batch call), and the answer can be cached for a short time. -
No transaction across services. "Create the account and the welcome review" is two writes in two databases. Either make it idempotent and retry, or model it as a saga and publish the second step reliably, as described in Sagas and Transactional Outbox. Messaging between the services (queues rather than calls) is covered in Messaging Patterns in Practice and, for Vapor’s own jobs, in Queues and Background Jobs.
-
Each service migrates its own schema with
autoMigrateorswift run App migrate --yes(see Migrations and Schema) at its own pace. Two services that must be deployed in lockstep are one service in disguise. Make changes between services backwards compatible (add a field before you require it) so any order of deployment works.
The runnable demo keeps its records in an actor (UserStore), so the three processes need no database server; the
Fluent
models above compile against the same package and show what replaces the actor.
Service-to-service authentication
Inside the private network a request still needs to prove who sent it. Without that, any process that can reach the catalog (a compromised container, a misconfigured proxy, a developer’s laptop on a flat network) can call its internal routes. The options, from cheapest to strongest:
| Option | What it gives | Costs |
|---|---|---|
Network isolation only |
Services are reachable only from the private network (Compose networks, Kubernetes network policies). |
A single breach inside the network reaches everything. Fine as an extra layer, not as the only one. |
A shared API key |
Trivial to add. |
One secret for everything, no caller identity, no expiry, hard to rotate. |
Service tokens (signed JWTs) |
Each call carries a short-lived token naming the caller ( |
A signing key to distribute and rotate; an HMAC key shared by all services means any service can mint a token for any other, so use an asymmetric key per caller when that matters (see the JWT page for ES256 keys and JWKS). |
Mutual TLS |
The transport itself authenticates both sides with certificates from an internal CA (RFC 8705 describes binding tokens to such certificates). |
A certificate authority, certificate issuance and renewal, a service mesh or tooling to automate it. |
The page uses service tokens because they are easy to run and to test, and shows mutual TLS after them. The claims are
deliberately few: who issued the token, who is calling, who it is for, and a lifetime of one minute. The middleware
refuses a token that
is invalid, expired, addressed to another service (the aud check) or issued for a caller not on the service’s
allow-list.
import JWT
import Vapor
/// The claims of a service-to-service token: who is calling (`iss`, `sub`), which service it is meant for (`aud`),
/// and a lifetime of one minute.
struct ServiceClaims: JWTPayload {
enum CodingKeys: String, CodingKey {
case issuer = "iss", subject = "sub", audience = "aud", expiration = "exp"
}
var issuer: IssuerClaim
var subject: SubjectClaim
var audience: AudienceClaim
var expiration: ExpirationClaim
init(from caller: String, to callee: String, now: Date = .now) {
issuer = IssuerClaim(value: "bookshelf-services")
subject = SubjectClaim(value: caller)
audience = AudienceClaim(value: [callee])
expiration = ExpirationClaim(value: now.addingTimeInterval(60))
}
func verify(using algorithm: some JWTAlgorithm) async throws {
try expiration.verifyNotExpired()
guard issuer.value == "bookshelf-services" else {
throw JWTError.claimVerificationFailure(failedClaim: issuer, reason: "unexpected issuer")
}
}
}
/// Which service this process is. It is configuration, never something a request can change.
struct ServiceIdentity: Sendable {
let name: String
/// The only callers allowed to reach this service's `/internal` routes.
let allowedCallers: Set<String>
}
extension Application {
private struct IdentityKey: StorageKey { typealias Value = ServiceIdentity }
var serviceIdentity: ServiceIdentity {
get { storage[IdentityKey.self]! }
set { storage[IdentityKey.self] = newValue }
}
}
let serviceKeyID: JWKIdentifier = "service-tokens"
extension Request {
/// Mints a short-lived token addressed to one callee. Signing an HMAC takes microseconds, so there is no
/// caching: a stolen token is useful for at most a minute and only against the service it names.
func serviceToken(for callee: String) async throws -> String {
try await jwt.sign(ServiceClaims(from: application.serviceIdentity.name, to: callee), kid: serviceKeyID)
}
}
/// Rejects every request that does not carry a valid service token addressed to this service from an allowed caller.
struct ServiceAuthMiddleware: AsyncMiddleware {
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
guard let bearer = request.headers.bearerAuthorization else { throw Abort(.unauthorized) }
let identity = request.application.serviceIdentity
let claims: ServiceClaims
do {
claims = try await request.jwt.verify(bearer.token, as: ServiceClaims.self)
try claims.audience.verifyIntendedAudience(includes: identity.name)
} catch {
request.logger.warning("service token rejected", metadata: ["reason": "\(error)"])
throw Abort(.unauthorized)
}
guard identity.allowedCallers.contains(claims.subject.value) else { throw Abort(.forbidden) }
request.logger[metadataKey: "caller"] = "\(claims.subject.value)"
return try await next.respond(to: request)
}
}
/// Reads the service signing secret. Production must provide it; development falls back to an obvious placeholder.
func addServiceKey(to app: Application) async throws {
let secret: String
if let fromEnvironment = Environment.get("SERVICE_JWT_SECRET") {
secret = fromEnvironment
} else if app.environment == .production {
throw Abort(.internalServerError, reason: "SERVICE_JWT_SECRET is required")
} else {
secret = "change-me-change-me-change-me-32"
}
guard secret.utf8.count >= 32 else { throw Abort(.internalServerError, reason: "SERVICE_JWT_SECRET is too short") }
await app.jwt.keys.add(hmac: HMACKey(from: secret), digestAlgorithm: .sha256, kid: serviceKeyID)
}
Source: Vapor docs: Authentication
Design notes:
-
Audience-bound. A token minted for
usersis useless againstcatalog, so a compromised catalog cannot replay the tokens it receives from the gateway against the users service. -
Minted per call. An HMAC signature takes microseconds, so there is nothing to cache, and a leaked token is good for at most a minute against one service. The jwt page has the details of signing,
kidand key rotation; here the service key has akidofservice-tokensso the users service can hold a second, separate key for user tokens in the sameapp.jwt.keys. -
Two kinds of token, two keys. User tokens are signed with a key only the users service holds. The gateway never sees it and cannot forge a user token; it asks the users service (introspection, below).
-
The secret is configuration.
SERVICE_JWT_SECRETis required in production, at least 32 bytes, and has an obvious placeholder in development. The value belongs in a secret store, never in the repository or the image.
Verified behaviour
The suite (set up as described on Testing) builds the catalog service in-process and sends it tokens a gateway, a stranger and an expired clock would produce:
import Testing
import VaporTesting
import JWT
@testable import App
@Suite("Service-to-service authentication")
struct ServiceAuthTests {
/// A catalog service, as `configure` builds it, for requests made in-process.
func withCatalog(_ body: (Application) async throws -> Void) async throws {
let app = try await Application.make(.testing)
do {
await app.jwt.keys.add(hmac: HMACKey(from: "change-me-change-me-change-me-32"), digestAlgorithm: .sha256, kid: serviceKeyID)
app.serviceIdentity = ServiceIdentity(name: "catalog", allowedCallers: ["gateway"])
catalogRoutes(app, users: UpstreamClient(name: "users", baseURL: "http://127.0.0.1:1"))
try await body(app)
} catch {
try await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
func token(app: Application, from caller: String, to callee: String, ttl: TimeInterval = 60) async throws -> String {
var claims = ServiceClaims(from: caller, to: callee)
claims.expiration = ExpirationClaim(value: .now.addingTimeInterval(ttl))
return try await app.jwt.keys.sign(claims, kid: serviceKeyID)
}
@Test("a token from an allowed caller addressed to this service is accepted")
func accepted() async throws {
try await withCatalog { app in
let token = try await token(app: app, from: "gateway", to: "catalog")
try await app.test(.GET, "internal/books/9780441013593", headers: ["Authorization": "Bearer \(token)"]) { res async in
#expect(res.status == .ok)
}
}
}
@Test("no token, a token for another service, an expired token and a stranger are all refused")
func refused() async throws {
try await withCatalog { app in
let forUsers = try await token(app: app, from: "gateway", to: "users")
let expired = try await token(app: app, from: "gateway", to: "catalog", ttl: -5)
let stranger = try await token(app: app, from: "mailer", to: "catalog")
try await app.test(.GET, "internal/books/9780441013593") { res async in #expect(res.status == .unauthorized) }
try await app.test(.GET, "internal/books/9780441013593", headers: ["Authorization": "Bearer \(forUsers)"]) { res async in
#expect(res.status == .unauthorized) // wrong audience
}
try await app.test(.GET, "internal/books/9780441013593", headers: ["Authorization": "Bearer \(expired)"]) { res async in
#expect(res.status == .unauthorized)
}
try await app.test(.GET, "internal/books/9780441013593", headers: ["Authorization": "Bearer \(stranger)"]) { res async in
#expect(res.status == .forbidden) // valid token, caller not on the allow-list
}
}
}
}
Source: Vapor docs: Testing
✔ Test "a token from an allowed caller addressed to this service is accepted" passed
✔ Test "no token, a token for another service, an expired token and a stranger are all refused" passed
✔ Test run with 2 tests in 1 suite passed
With the three processes running, a user token presented straight to the catalog is refused as well, because it is not a service token:
$ curl -si localhost:8082/internal/books/9780441013593 -H "authorization: Bearer $USER_TOKEN" | head -1
HTTP/1.1 401 Unauthorized
$ curl -si localhost:8082/internal/books/9780441013593 | head -1
HTTP/1.1 401 Unauthorized
Mutual TLS
With mutual TLS the connection itself is authenticated, so the application needs no token middleware to answer "who is
calling". Vapor’s server and AsyncHTTPClient both take a TLSConfiguration from NIOSSL, and the pieces are the same on
both ends: a certificate chain, a private key, the internal CA as the only trust root, and full verification so a peer
without a certificate from that CA is rejected during the handshake.
import AsyncHTTPClient
import NIOSSL
import Vapor
/// Mutual TLS between services: this service presents its certificate and demands one from every caller, and its own
/// outgoing client presents the same certificate and trusts only the private CA. The three paths are placeholders for
/// files an orchestrator mounts (for example Docker or Kubernetes secrets); no key material belongs in the repository.
func configureMutualTLS(_ app: Application) throws {
let certificate = "/run/secrets/service.crt"
let key = "/run/secrets/service.key"
let authority = "/run/secrets/internal-ca.crt"
let privateKey = NIOSSLPrivateKeySource.privateKey(try NIOSSLPrivateKey(file: key, format: .pem))
let chain = try NIOSSLCertificate.fromPEMFile(certificate).map { NIOSSLCertificateSource.certificate($0) }
// Server side: refuse any caller whose certificate does not chain to the internal CA.
var server = TLSConfiguration.makeServerConfiguration(certificateChain: chain, privateKey: privateKey)
server.trustRoots = .file(authority)
server.certificateVerification = .fullVerification
app.http.server.configuration.tlsConfiguration = server
// Client side: present the certificate to the other services and trust only the internal CA.
var client = TLSConfiguration.makeClientConfiguration()
client.certificateChain = chain
client.privateKey = privateKey
client.trustRoots = .file(authority)
app.http.client.configuration.tlsConfiguration = client
}
Source: Vapor docs: Server
This listing was compiled but not run: no CA or certificates were issued for this page. The usual way to get certificates without writing the issuance yourself is a service mesh or an internal CA service that mounts short-lived certificates into each container. Mutual TLS identifies the machine or workload; you still need to put the caller’s identity in the application (the certificate’s subject) if you want per-caller authorization, which is why many systems use both.
The API gateway
The gateway is the only thing clients can reach. It exists so that clients know one address and one authentication scheme, so that services can change shape behind it, and so that cross-cutting rules (authentication, rate limits, request ids, CORS) are applied once. What it should do is small:
-
authenticate the user and turn the answer into a trusted identity for the services;
-
route a public path to the right service, passing a conservative set of headers;
-
optionally compose a response from several services so a client makes one request (the backend-for-frontend idea: one gateway per kind of client, each shaped for it; the shape of those responses is the subject of API Design, Versioning and DTOs and, when the contract is written first, of OpenAPI with swift-openapi-generator);
-
add what only the edge knows: request id, rate limiting (see Middleware, Redis & Caching and Security Hardening), CORS, and logging.
What it should not do is own business rules or a database. A gateway that grows rules becomes the monolith you split.
The other half of the pipeline is Vapor’s Client, covered on the HTTP Client
page: one
shared HTTPClient, req.client as a cheap per-request view of it, timeouts per request, and a mapping from transport
failures to status codes. The gateway’s calls go through one small wrapper that adds the service token, the request id,
a per-call
timeout, that mapping and a bounded retry for reads:
import AsyncHTTPClient
import NIOCore
import Vapor
/// A call to another Bookshelf service: a service token for the callee, a timeout, the request id, a mapping from
/// transport failures to 502/504 and a bounded retry for idempotent reads.
struct UpstreamClient: Sendable {
let name: String
let baseURL: String
/// The budget for one attempt. It must be shorter than the timeout of whoever is calling *this* service.
var timeout: TimeAmount = .seconds(2)
/// 1 means "never retry". Retries apply to idempotent methods only.
var attempts = 1
func send(
_ method: HTTPMethod, _ path: String, query: String? = nil,
headers extra: HTTPHeaders = [:], body: ByteBuffer? = nil,
on req: Request
) async throws -> ClientResponse {
let idempotent = method == .GET || method == .HEAD
return try await withRetries(attempts: idempotent ? attempts : 1) {
try await sendOnce(method, path, query: query, headers: extra, body: body, on: req)
}
}
private func sendOnce(
_ method: HTTPMethod, _ path: String, query: String?, headers extra: HTTPHeaders, body: ByteBuffer?,
on req: Request
) async throws -> ClientResponse {
var headers = extra
headers.replaceOrAdd(name: .authorization, value: "Bearer \(try await req.serviceToken(for: name))")
headers.replaceOrAdd(name: "X-Request-ID", value: req.id)
var url = URI(string: baseURL + path)
url.query = query
var outgoing = ClientRequest(method: method, url: url, headers: headers, body: body)
outgoing.timeout = timeout
do {
let response = try await req.client.send(outgoing)
if response.status.code >= 500 { throw Abort(.badGateway, reason: "\(name) answered \(response.status.code)") }
return response
} catch let error as HTTPClientError where error == .deadlineExceeded || error == .readTimeout || error == .connectTimeout {
throw Abort(.gatewayTimeout, reason: "\(name) did not answer in time")
} catch let error as any AbortError {
throw error
} catch {
throw Abort(.badGateway, reason: "\(name) is unreachable")
}
}
}
/// Retries an idempotent operation with exponential backoff and jitter, only for failures that may be transient.
func withRetries<Result: Sendable>(
attempts: Int, initialDelay: Duration = .milliseconds(100), _ operation: @Sendable () async throws -> Result
) async throws -> Result {
var delay = initialDelay
for attempt in 1...attempts {
do {
return try await operation()
} catch let error as Abort where attempt < attempts && [.badGateway, .gatewayTimeout].contains(error.status) {
try await Task.sleep(for: delay * Double.random(in: 0.5...1.5))
delay *= 2
}
}
preconditionFailure("unreachable: the last attempt either returns or throws")
}
Source: Vapor docs: Client
The gateway itself has three parts. Authentication is a middleware that takes the bearer token and asks the users
service,
which owns the key, whether it is good (token introspection). The answer is cached for 30 seconds in an actor, so a
client making
ten requests a second causes a call to the users service every 30 seconds rather than ten a second; the price is that a
revoked
token keeps working for up to that long, which is the knob to turn. The alternative that needs no call at all is to
verify
the token locally with the users service’s public key, published as a JWKS document (see
JWT); it is faster and has no runtime dependency, but revocation then waits for the token
to expire.
Relaying copies a conservative set of headers, drops the user’s Authorization header (the service gets a service
token
instead), adds the identity the gateway established as X-User-Id and X-User-Role, and never forwards an X-User-Id
the client sent.
Composition calls two services concurrently with async let and merges the answers.
import NIOCore
import Vapor
/// A user the gateway has authenticated. The gateway, not the browser, decides who this is.
struct GatewayUser: Sendable { let id: String; let role: String }
/// Remembers recent introspection answers so a burst of requests with one token asks the users service once.
/// The cache is short on purpose: it is also how long a revoked token keeps working.
actor IntrospectionCache {
private var entries: [String: (user: GatewayUser, expires: Date)] = [:]
func get(_ token: String) -> GatewayUser? {
guard let entry = entries[token], entry.expires > .now else { return nil }
return entry.user
}
func put(_ token: String, _ user: GatewayUser, ttl: TimeInterval = 30) {
if entries.count > 10_000 { entries.removeAll() } // crude bound; production uses an LRU
entries[token] = (user, .now.addingTimeInterval(ttl))
}
}
struct UserAuthMiddleware: AsyncMiddleware {
let users: UpstreamClient
let cache: IntrospectionCache
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
guard let token = request.headers.bearerAuthorization?.token else { throw Abort(.unauthorized) }
if let cached = await cache.get(token) {
request.storage[GatewayUserKey.self] = cached
return try await next.respond(to: request)
}
let body = ByteBuffer(string: String(decoding: try JSONEncoder().encode(IntrospectRequest(token: token)), as: UTF8.self))
let response = try await users.send(
.POST, "/internal/introspect", headers: ["Content-Type": "application/json"], body: body, on: request)
let result = try response.content.decode(Introspection.self)
guard result.active, let id = result.userID, let role = result.role else { throw Abort(.unauthorized) }
let user = GatewayUser(id: id, role: role)
await cache.put(token, user)
request.storage[GatewayUserKey.self] = user
return try await next.respond(to: request)
}
}
struct GatewayUserKey: StorageKey { typealias Value = GatewayUser }
extension Request {
var gatewayUser: GatewayUser? { storage[GatewayUserKey.self] }
}
/// Request headers passed to a service. The user's `Authorization` header is deliberately absent: the service
/// receives a service token instead, plus the identity the gateway established.
private let passedRequestHeaders: [HTTPHeaders.Name] = [
.accept, .acceptLanguage, .contentType, .ifNoneMatch, HTTPHeaders.Name("traceparent"), HTTPHeaders.Name("tracestate"),
]
private let hopByHop: Set<String> = [
"connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding",
"upgrade", "content-length", "date",
]
/// Relays `/api/<prefix>/**` to `<service>/internal/<prefix>/**`.
func relay(_ routes: some RoutesBuilder, prefix: String, to service: UpstreamClient) {
for method in [HTTPMethod.GET, .POST, .PUT, .DELETE] {
routes.on(method, "api", PathComponent(stringLiteral: prefix), "**", body: .collect(maxSize: "256kb")) { req async throws -> Response in
let segments = req.parameters.getCatchall()
guard !segments.contains(where: { $0 == ".." || $0 == "." || $0.contains("/") }) else { throw Abort(.badRequest) }
let path = segments.compactMap { $0.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) }.joined(separator: "/")
var headers = HTTPHeaders()
for name in passedRequestHeaders { for value in req.headers[name] { headers.add(name: name, value: value) } }
if let user = req.gatewayUser {
headers.replaceOrAdd(name: "X-User-Id", value: user.id)
headers.replaceOrAdd(name: "X-User-Role", value: user.role)
}
let upstream = try await service.send(
req.method, "/internal/\(prefix)/\(path)", query: req.url.query,
headers: headers, body: req.body.data, on: req)
var responseHeaders = HTTPHeaders()
for (name, value) in upstream.headers where !hopByHop.contains(name.lowercased()) {
responseHeaders.add(name: name, value: value)
}
return Response(status: upstream.status, headers: responseHeaders,
body: upstream.body.map { Response.Body(buffer: $0) } ?? .empty)
}
}
}
/// One screen, two services: the gateway composes the answer so the mobile app makes one request.
struct BookOverview: Content { let book: Book; let reviews: [ReviewView]; let viewer: String }
func gatewayRoutes(_ app: Application, users: UpstreamClient, catalog: UpstreamClient) {
// Public: logging in is the one route that needs no user token. Only /auth/login is exposed, not all of /auth.
app.post("api", "auth", "login") { req async throws -> Response in
let upstream = try await users.send(
.POST, "/auth/login", headers: ["Content-Type": "application/json"], body: req.body.data, on: req)
return Response(status: upstream.status, headers: ["Content-Type": "application/json"],
body: upstream.body.map { Response.Body(buffer: $0) } ?? .empty)
}
let protected = app.grouped(UserAuthMiddleware(users: users, cache: IntrospectionCache()))
protected.get("api", "books", ":isbn", "overview") { req async throws -> BookOverview in
let isbn = try req.parameters.require("isbn")
let headers: HTTPHeaders = ["X-User-Id": req.gatewayUser?.id ?? ""]
async let bookResponse = catalog.send(.GET, "/internal/books/\(isbn)", headers: headers, on: req)
async let reviewsResponse = catalog.send(.GET, "/internal/books/\(isbn)/reviews", headers: headers, on: req)
let (bookReply, reviewsReply) = try await (bookResponse, reviewsResponse)
guard bookReply.status == .ok else { throw Abort(bookReply.status) }
return BookOverview(
book: try bookReply.content.decode(Book.self),
reviews: try reviewsReply.content.decode([ReviewView].self),
viewer: req.gatewayUser?.id ?? "")
}
relay(protected, prefix: "books", to: catalog)
}
Source: Vapor docs: Routing
Points that matter in this listing:
-
The services trust
X-User-Idonly because the caller is authenticated as the gateway. A service must never accept those headers from anything but a verified service token; here every/internalroute sits behindServiceAuthMiddleware. -
Only
/api/auth/loginis public. The route is registered by name rather than by prefix, so adding an/internal/introspectroute to the users service never exposes it. Every other route in the gateway is behindUserAuthMiddleware. -
The path stays inside the service. The relay decodes the catch-all segments, refuses
.and.., and re-encodes the rest before joining them to a fixed/internal/books/prefix; the base URL is configuration, never taken from the request. The same pattern, and the reasoning about hop-by-hop headers, forwarding headers and server-side request forgery, is explained on the HTTP Client page. -
Composition fails as a whole, degradation happens below it. The overview needs the book and the reviews, so a failure of either is a failure of the screen; showing a placeholder reviewer name when the users service is slow is a decision the catalog makes about its own data (next section).
The users and catalog services, whose routes the gateway calls:
import JWT
import Vapor
let userKeyID: JWKIdentifier = "user-tokens"
/// What the users service puts in the access tokens it issues. Only the users service holds this key.
struct UserToken: JWTPayload {
enum CodingKeys: String, CodingKey { case subject = "sub", audience = "aud", expiration = "exp", role }
var subject: SubjectClaim
var audience: AudienceClaim
var expiration: ExpirationClaim
var role: String
func verify(using algorithm: some JWTAlgorithm) async throws {
try expiration.verifyNotExpired()
try audience.verifyIntendedAudience(includes: "bookshelf-api")
}
}
struct LoginRequest: Content { let email: String; let password: String }
struct LoginResponse: Content { let accessToken: String; let expiresIn: Int }
struct IntrospectRequest: Content { let token: String }
struct Introspection: Content { let active: Bool; let userID: String?; let role: String? }
struct PublicUser: Content { let id: String; let displayName: String }
/// The users service owns its data. Here the "database" is an actor; the section on per-service databases shows
/// the Fluent version. No other service reads these records: they ask over HTTP.
actor UserStore {
struct Record { let id: String; let email: String; let passwordHash: String; let displayName: String; let role: String }
private var byEmail: [String: Record] = [:]
private var byID: [String: Record] = [:]
init() throws {
// A cheap cost factor keeps the demo fast; production uses the default cost.
let records = [
Record(id: "u-1", email: "reader@example.test", passwordHash: try Bcrypt.hash("change-me", cost: 4),
displayName: "Ada Reader", role: "member"),
Record(id: "u-2", email: "editor@example.test", passwordHash: try Bcrypt.hash("change-me", cost: 4),
displayName: "Ed Editor", role: "editor"),
]
for record in records { byEmail[record.email] = record; byID[record.id] = record }
}
func authenticate(email: String, password: String) -> Record? {
guard let record = byEmail[email], (try? Bcrypt.verify(password, created: record.passwordHash)) == true else {
return nil
}
return record
}
func user(id: String) -> Record? { byID[id] }
}
extension Application {
private struct UsersKey: StorageKey { typealias Value = UserStore }
var users: UserStore {
get { storage[UsersKey.self]! }
set { storage[UsersKey.self] = newValue }
}
}
struct MissingSecret: Error {
let variable: String
}
func usersRoutes(_ app: Application) async throws {
// Production must provide USER_JWT_SECRET; development and tests use an obvious 32-byte placeholder.
let secret: String
if let fromEnvironment = Environment.get("USER_JWT_SECRET") {
secret = fromEnvironment
} else if app.environment == .production {
throw MissingSecret(variable: "USER_JWT_SECRET")
} else {
secret = "change-me-too-change-me-too-32by"
}
await app.jwt.keys.add(hmac: HMACKey(from: secret), digestAlgorithm: .sha256, kid: userKeyID)
app.users = try UserStore()
// Public through the gateway: log in and receive a user access token.
app.post("auth", "login") { req async throws -> LoginResponse in
let login = try req.content.decode(LoginRequest.self)
guard let user = await req.application.users.authenticate(email: login.email, password: login.password) else {
throw Abort(.unauthorized, reason: "Invalid credentials.")
}
let claims = UserToken(
subject: .init(value: user.id), audience: .init(value: ["bookshelf-api"]),
expiration: .init(value: .now.addingTimeInterval(15 * 60)), role: user.role)
return LoginResponse(accessToken: try await req.jwt.sign(claims, kid: userKeyID), expiresIn: 900)
}
// Internal: only other services, with a service token, reach these routes.
let internalRoutes = app.grouped("internal").grouped(ServiceAuthMiddleware())
// Token introspection: the gateway asks the owner of the keys whether a user token is good.
internalRoutes.post("introspect") { req async throws -> Introspection in
let body = try req.content.decode(IntrospectRequest.self)
guard let claims = try? await req.jwt.verify(body.token, as: UserToken.self) else {
return Introspection(active: false, userID: nil, role: nil)
}
return Introspection(active: true, userID: claims.subject.value, role: claims.role)
}
internalRoutes.get("users", ":id") { req async throws -> PublicUser in
let id = try req.parameters.require("id")
guard let user = await req.application.users.user(id: id) else { throw Abort(.notFound) }
return PublicUser(id: user.id, displayName: user.displayName)
}
}
Source: Vapor docs: Authentication
import Vapor
struct Book: Content, Sendable { let isbn: String; let title: String; let authors: [String] }
struct ReviewView: Content, Sendable { let userID: String; let reviewer: String; let stars: Int; let text: String }
/// The catalog service's own data (books and reviews). Reviews hold only a `userID`: the user record is not
/// here, it belongs to the users service.
struct CatalogData: Sendable {
let books: [String: Book]
let reviews: [String: [(userID: String, stars: Int, text: String)]]
static let seed = CatalogData(
books: [
"9780441013593": Book(isbn: "9780441013593", title: "Dune", authors: ["Frank Herbert"]),
"9780261103573": Book(isbn: "9780261103573", title: "The Fellowship of the Ring", authors: ["J. R. R. Tolkien"]),
],
reviews: [
"9780441013593": [("u-1", 5, "A classic."), ("u-2", 4, "Dense but rewarding.")],
])
}
extension Application {
private struct CatalogKey: StorageKey { typealias Value = CatalogData }
var catalogData: CatalogData {
get { storage[CatalogKey.self] ?? .seed }
set { storage[CatalogKey.self] = newValue }
}
}
func catalogRoutes(_ app: Application, users: UpstreamClient) {
let internalRoutes = app.grouped("internal").grouped(ServiceAuthMiddleware())
internalRoutes.get("books", ":isbn") { req async throws -> Book in
let isbn = try req.parameters.require("isbn")
guard let book = req.application.catalogData.books[isbn] else { throw Abort(.notFound) }
return book
}
// The catalog resolves reviewer names by asking the users service. The calls run concurrently; if one fails
// the review is returned with a placeholder name instead of failing the whole page.
internalRoutes.get("books", ":isbn", "reviews") { req async throws -> [ReviewView] in
let isbn = try req.parameters.require("isbn")
guard req.application.catalogData.books[isbn] != nil else { throw Abort(.notFound) }
let rows = req.application.catalogData.reviews[isbn] ?? []
return try await withThrowingTaskGroup(of: (Int, ReviewView).self) { group in
for (index, row) in rows.enumerated() {
group.addTask {
var name = "Unknown reader"
if let response = try? await users.send(.GET, "/internal/users/\(row.userID)", on: req),
response.status == .ok, let user = try? response.content.decode(PublicUser.self) {
name = user.displayName
}
return (index, ReviewView(userID: row.userID, reviewer: name, stars: row.stars, text: row.text))
}
}
var results: [(Int, ReviewView)] = []
for try await result in group { results.append(result) }
return results.sorted { $0.0 < $1.0 }.map(\.1)
}
}
}
Source: Vapor docs: Routing
The catalog resolves reviewer names by asking the users service, concurrently, and falls back to a placeholder when that call fails. That is a deliberate choice about which failures should reach the user. The result of a login and of the screen, captured from the three running processes:
$ curl -s -X POST localhost:9080/api/auth/login -H 'content-type: application/json' \
-d '{"email":"reader@example.test","password":"change-me"}'
{"expiresIn":900,"accessToken":"eyJhbGciOi..."}
$ curl -si localhost:9080/api/books/9780441013593/overview -H "authorization: Bearer $TOKEN"
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
{"viewer":"u-1","book":{"isbn":"9780441013593","authors":["Frank Herbert"],"title":"Dune"},"reviews":[{"stars":5,"userID":"u-1","text":"A classic.","reviewer":"Ada Reader"},{"stars":4,"userID":"u-2","text":"Dense but rewarding.","reviewer":"Ed Editor"}]}
$ curl -si localhost:9080/api/books/9780441013593 -H "authorization: Bearer $TOKEN" # relayed
HTTP/1.1 200 OK
{"authors":["Frank Herbert"],"isbn":"9780441013593","title":"Dune"}
$ curl -si localhost:9080/api/books/9780441013593/overview | head -1 # no token
HTTP/1.1 401 Unauthorized
$ curl -si 'localhost:9080/api/books/a/%2E%2E/b' -H "authorization: Bearer $TOKEN" | head -1
HTTP/1.1 400 Bad Request
$ curl -si localhost:9080/api/books/0000/overview -H "authorization: Bearer $TOKEN" | head -1
HTTP/1.1 404 Not Found
The default password change-me and the example.test addresses are demo placeholders, seeded in code for the scratch
package only.
Vapor as the gateway, or something else
Building the gateway in Vapor makes sense when it has logic only your code can express (user introspection, composition, a backend for one client) and the team already ships Swift. For plain routing, TLS termination, rate limiting and caching, a dedicated proxy does the same job with less code and more operational tooling:
| Choice | Strength | Watch for |
|---|---|---|
Vapor gateway (this page) |
Same language, models and tests as the services; composition and per-client shaping are ordinary code. |
You own timeouts, body streaming, header handling and upgrades (WebSockets); each is code to write and test. |
Mature reverse proxy: TLS, buffering, caching, rate limiting, load balancing. |
Authentication beyond a subrequest and composition need another layer. |
|
Dynamic configuration for containers and clusters, retries, circuit breaking, tracing, mutual TLS. |
A control plane to run and learn. |
|
Kong or a cloud API gateway |
Plugins for keys, quotas, OAuth and analytics; managed scaling. |
Vendor behaviour and limits; lock-in. |
A common arrangement is a proxy at the edge for TLS and coarse limits, and a Vapor backend-for-frontend behind it for the parts that are application code.
Timeouts, retries and failure budgets
Every call to another service is a place where latency and partial failure enter your server. Four rules, each visible in the listings above:
-
Every call has a timeout, and the callee’s must be shorter than the caller’s. The gateway waits 2 seconds for the catalog; the catalog waits 400 milliseconds, twice, for the users service. If it were the other way round, the gateway would give up while the catalog was still working and the work would be wasted.
-
A refused connection is not fast. The first version of this example used the default 2 second timeout and three attempts against a stopped users service, and the gateway answered after 6.4 seconds with a 504: the refused connection did not surface as an error until the request’s deadline passed, so a dead service cost the full timeout of every attempt (the cause was not traced further; the connection pool appears to keep retrying within the request’s deadline). Hence the per-attempt budget in
UpstreamClient, and a small attempt count. -
Retry in one layer only. If the gateway retried three times and the catalog retried three times, one slow user lookup would become nine. The catalog (next to the failing dependency) retries, with exponential backoff and jitter; the gateway does not. Only idempotent methods are retried (
GET,HEAD); a retriedPOSTneeds an idempotency key the service honours. -
Decide what degrades and what fails. A missing reviewer name is cosmetic, so the catalog returns the review with a placeholder; a missing book is the screen, so the gateway returns the error.
What the system did when parts were stopped, measured with curl -w against the three running processes (the 504 case
suspended the catalog
process with SIGSTOP, so it accepted the connection and never answered):
| Situation | Status | Time | Result |
|---|---|---|---|
All services up |
200 |
0.02 s |
reviews with names |
Users service stopped, token already cached |
200 |
0.96 s |
reviews with |
Users service stopped, token not cached |
502 |
1.0 s |
gateway cannot introspect: |
Catalog suspended |
504 |
2.0 s |
|
Catalog resumed |
200 |
0.003 s |
recovers on the next request, no restart |
Vapor does not ship a circuit breaker. When a dependency is down for a while, stop calling it for a period instead of
paying a
timeout on every request; the patterns (circuit breaker, bulkhead, time limiter, retry with backoff) are described in
the site’s
Circuit Breaker, Retry,
Time Limiter and Bulkhead pages, and a
small breaker
is an actor that counts consecutive failures and rejects calls until a cool-down passes. The AWS Builders' Library
article in the references explains why
backoff needs jitter.
Identifying one request across services
A user reports a slow screen; the evidence is spread over three logs. The wrapper adds X-Request-ID to every outgoing
call from
the caller’s request id, and the services' logs carry that id in their metadata (visible in the catalog’s log lines as
request-id and, after authentication, caller). For distributed traces (see
Tracing, Metrics & OpenTelemetry and
Logging), forward traceparent and tracestate explicitly, as
the allow-list in the relay does; the HTTP Client page explains why Vapor’s own
Client does not inject them
by itself in 4.122.2.
Local orchestration with Docker Compose
Compose runs the five containers (gateway, two services, two databases) on one private network and publishes only the
gateway’s port.
The services and databases have no ports: entry, so the host and the outside world cannot reach them; they find each
other by service name.
name: bookshelf
x-service: &service
build: .
restart: unless-stopped
environment: &service-env
SERVICE_JWT_SECRET: ${SERVICE_JWT_SECRET:?set SERVICE_JWT_SECRET in .env}
LOG_LEVEL: info
services:
gateway:
<<: *service
environment:
<<: *service-env
SERVICE_NAME: gateway
USERS_URL: http://users:8080
CATALOG_URL: http://catalog:8080
ports:
- "9080:8080"
depends_on:
users: { condition: service_healthy }
catalog: { condition: service_healthy }
users:
<<: *service
environment:
<<: *service-env
SERVICE_NAME: users
USER_JWT_SECRET: ${USER_JWT_SECRET:?set USER_JWT_SECRET in .env}
DATABASE_URL: postgres://users:${USERS_DB_PASSWORD:?set USERS_DB_PASSWORD}@users-db:5432/users
depends_on:
users-db: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf \"GET /healthz HTTP/1.0\\r\\n\\r\\n\" >&3 && grep -q ok <&3'"]
interval: 5s
retries: 10
catalog:
<<: *service
environment:
<<: *service-env
SERVICE_NAME: catalog
USERS_URL: http://users:8080
DATABASE_URL: postgres://catalog:${CATALOG_DB_PASSWORD:?set CATALOG_DB_PASSWORD}@catalog-db:5432/catalog
depends_on:
catalog-db: { condition: service_healthy }
healthcheck:
test: ["CMD-SHELL", "bash -c 'exec 3<>/dev/tcp/127.0.0.1/8080 && printf \"GET /healthz HTTP/1.0\\r\\n\\r\\n\" >&3 && grep -q ok <&3'"]
interval: 5s
retries: 10
users-db:
image: postgres:17
environment:
POSTGRES_USER: users
POSTGRES_PASSWORD: ${USERS_DB_PASSWORD:?set USERS_DB_PASSWORD}
POSTGRES_DB: users
volumes: ["users-data:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U users"]
interval: 5s
retries: 10
catalog-db:
image: postgres:17
environment:
POSTGRES_USER: catalog
POSTGRES_PASSWORD: ${CATALOG_DB_PASSWORD:?set CATALOG_DB_PASSWORD}
POSTGRES_DB: catalog
volumes: ["catalog-data:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U catalog"]
interval: 5s
retries: 10
volumes:
users-data:
catalog-data:
Notes on the file:
-
Secrets come from the environment.
${SERVICE_JWT_SECRET:?...}makes Compose refuse to start when the value is missing, instead of silently running with an empty secret. Put the real values in an untracked.envfile (never commit it) or use Composesecrets:. -
depends_onwithservice_healthystarts the gateway only after the services answer/healthzand the services only after their databases accept connections. The/healthzroute is the last line ofconfigure. A health check must not depend on the other services, or one outage turns every service unhealthy at once. -
One image, a role per container. All three containers use the same image and differ in
SERVICE_NAME. With one executable target per service they would use separate Dockerfiles. -
Databases. The Compose file points the services at separate PostgreSQL servers through
DATABASE_URL; the Fluent driver setup for PostgreSQL is on the Fluent Overview and Drivers page. The scratch package of this page uses SQLite and in-memory stores, so it does not readDATABASE_URL.
The Dockerfile follows the shape in the Vapor Docker documentation (a build
stage on the Swift image, a runtime stage that runs as a non-root user with a statically linked
Swift standard library); pin the swift image tag to the toolchain your package needs, and check on Docker Hub that it
exists before building:
# syntax=docker/dockerfile:1
FROM swift:6.3-noble AS build
WORKDIR /build
COPY Package.swift Package.resolved ./
RUN swift package resolve
COPY Sources ./Sources
RUN swift build -c release --static-swift-stdlib && \
mkdir -p /staging && cp "$(swift build -c release --show-bin-path)/App" /staging/App
FROM ubuntu:noble
RUN useradd --system --create-home --shell /usr/sbin/nologin vapor
WORKDIR /app
COPY --from=build --chown=vapor:vapor /staging/App ./App
USER vapor
EXPOSE 8080
ENTRYPOINT ["./App"]
CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"]
Source: Vapor docs: Docker
docker compose config accepted the Compose file; the image was not built here.
Deployment with Docker and
Deployment Targets cover
production images, reverse proxies and orchestrators in depth; the gateway’s ports: mapping is where a TLS-terminating
proxy attaches in front.
Without Docker, the same system runs as three processes, which is how the output above was produced:
$ SERVICE_NAME=users .build/debug/App serve --port 8081 &
$ SERVICE_NAME=catalog .build/debug/App serve --port 8082 &
$ SERVICE_NAME=gateway .build/debug/App serve --port 9080 &
Swift 6 and concurrency notes
-
UpstreamClientis aSendablestruct of two strings, aTimeAmountand anInt; it is created once inconfigureand captured by route closures, which Swift 6 requires to beSendable. -
Shared mutable state lives in actors:
UserStoreandIntrospectionCache. A class with a lock would also work, but an actor makes the compiler enforce that every access is `await`ed. -
async letin the gateway runs the two catalog calls concurrently and cancels the unfinished call when the other one throws; the catalog’swithThrowingTaskGroupdoes the same for the reviewer lookups and keeps the result order by sorting on the index, because tasks finish in any order. -
Child tasks capture the
Request. In 4.122.2 that compiles in the Swift 6 language mode with no warning; the child tasks only read request state and usereq.client, which is the supported use. -
Environment.getis read once duringconfigure, not per request.
Common errors and pitfalls
-
Shared database. "Just this one join" between services ends the independence of both. Call the other service or keep a copy of the few fields you need, updated from events.
-
Trusting a header from the network.
X-User-Idis only meaningful behind a verified service token (or mutual TLS). The gateway must not forward the client’s own copy. -
The same secret for user tokens and service tokens. A service that can mint service tokens must not be able to mint user tokens. Keep them under different keys, and check
aud. -
No timeout. The default client timeouts are generous; a call without its own
timeoutholds a connection for as long as the upstream cares to take. Set it on every call and keep the chain of timeouts decreasing. -
Retries at every layer. Multiplied attempts turn a brownout into an outage; retry in one place, with backoff and jitter, and only idempotent calls.
-
A health check that calls dependencies. It makes a single failure cascade into every service being restarted.
-
Fan-out per item. One call per review row is an N+1 across the network; batch the lookup or cache the names.
-
Compose
.envcommitted. Treat it like any secret file. -
Splitting too early. If you cannot say which database each table belongs to, you are not ready to split.
Version notes
-
The Vapor, JWT, Fluent and AsyncHTTPClient versions are those in the first paragraph. Fluent 4.13.0 and JWT 5.1.2 are the releases this page was compiled against; the
JWTKeyCollectionAPI is the one of JWT 5 (theJWTSignersof JWT 4 are gone). -
Vapor 5 is at 5.0.0-beta.4 and not released. The shape of this design (middleware, a client, JWT verification,
async let) carries over; verify signatures against the beta before porting. -
The Swift tools version of the scratch package is 6.3, matching the official template. Containers need a Swift image at least that new.
Where the books are outdated
-
Condon et al. (Server-Side Swift with Vapor, Kodeco), chapters 35 and 36, describe splitting an application into services with a database per service, validating a token by sending it to a users service, an API gateway that relays with
Client, and orchestrating the containers with Compose. The architecture is still sound and this page keeps all four ideas. What changed is the code:Clientand routes areasync throwsin 4.122 rather than futures chained withflatMap; the book’s relay copies headers wholesale, which this page replaces with an allow-list; the token check is cached and can be replaced by local verification against a JWKS; and every call here has a timeout and a deliberate retry policy, where the book’s version trusts the network. -
Yeung (Packt), chapter 10, presents the API gateway and backends-for-frontends in Vapor 3 and passes service-to-service calls over plain HTTP between trusted hosts. That is no longer a safe default: authenticate each service call (service tokens or mutual TLS) and set timeouts.
-
Neither book covers Swift 6 data-race safety: shared state such as the introspection cache must be an actor or otherwise
Sendable.
References
-
microservices.io: API Gateway pattern and Database per service
-
Google SRE Book: Addressing Cascading Failures and AWS Builders' Library: Timeouts, retries and backoff with jitter
-
RFC 8705: OAuth 2.0 Mutual-TLS Client Authentication and Certificate-Bound Access Tokens
-
Kodeco: Server-Side Swift with Vapor (Condon et al.)