OAuth and Sign in with Apple
|
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 (which pulls in jwt-kit 5.7.1),
fluent 4.13.0
and Imperial 2.2.0, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode
with zero
warnings in a scratch package, and the page’s own code was run: the native Apple, Google and Microsoft routes, the Apple
web
flow and the hand-rolled GitHub flow were exercised with curl against a local stub identity provider that serves a
JWKS
and mints tokens signed with a throwaway key (see Testing without the real providers), and the output shown is
captured from those
runs. Nothing on this page was run against the real Apple, Google, Microsoft or GitHub servers: that needs developer
accounts and registered client IDs. The behaviour of those servers is taken from their documentation and from the
library source. Vapor 5 is at 5.0.0-beta.4 and is
not
released yet; where it matters that is noted.
The protocol theory (flows, PKCE, ID tokens versus access tokens, OpenID Connect) is in the OAuth section of this site:
Authorization code and PKCE,
OpenID Connect,
ID tokens versus access tokens,
Social login and federation
and Native and mobile apps (the app side of Sign in with Apple). This
page is the
Vapor side: which Vapor and package APIs verify the tokens, what they leave for you to check, and how the result becomes
a
Bookshelf user. The JWT and JOSE and
Access and refresh tokens
pages cover token design (Bookshelf’s own tokens are in Fluent
Authentication and API Tokens), and the JSON Web Tokens (JWT) page covers
JWTKeyCollection, signing and payloads in depth; this page uses
only what it needs.
What "social login" means to a server
A "Sign in with …" button never gives your server a password. It gives it proof that a third party authenticated someone, in one of two shapes:
-
An ID token: a JWT, signed by the provider, saying "this person, identified by this stable
sub, signed in to this application (aud) at this time". Your server checks the signature against the provider’s published keys (a JWKS), checks the claims, and trusts it. The identity token is the whole proof. -
An authorization code: a one-time value the browser carries back to you. Your server exchanges it, server to server, for tokens (an ID token and/or an access token), authenticating itself with a client secret and, ideally, a PKCE verifier. The exchange is the proof.
Which shape you get depends on the client:
| Situation | What reaches the server | Vapor side |
|---|---|---|
Native iOS or macOS app, Sign in with Apple |
The app asks the system, receives an identity token (and an authorization code), and posts the identity token to your API. |
|
Native app or single-page app, Google or Microsoft |
The app obtains an ID token with the provider’s SDK and posts it. |
|
Browser, Sign in with Apple on your site |
Apple redirects the browser back with an authorization code (a |
Exchange the code at Apple’s token endpoint with a generated ES256 client secret, then verify the returned ID token. |
Browser, GitHub (no ID tokens) or any provider without OpenID Connect |
An authorization code. |
Exchange the code with |
The second row of the table is why the verification helpers take a bearer token or a string: they were designed for an API that a mobile app talks to. The browser rows are the classic OAuth redirect dance, which the helpers do not cover.
Where it runs in the request
None of the code on this page needs middleware, with one exception (the GitHub flow stores state in a session, see
Sessions and Cookies). The routes are
ordinary handlers. In the request lifecycle:
-
SwiftNIO accepts the request, and the middleware chain runs (logging, error handling and, for the GitHub routes,
SessionsMiddleware; see the Middleware page for the onion model). -
The router matches a path such as
POST /auth/appleand calls the handler. -
The handler verifies the proof.
req.jwt.apple.verifyfetches the provider’s JWKS throughreq.clienton the first call (and refreshes it when the cache entry expires), checks the signature and claims, and returns a typed payload. -
The handler resolves a user (Fluent) and issues Bookshelf’s own access token. From here on the provider is out of the picture: later requests carry the Bookshelf token, which the normal JWT bearer authentication (see Authentication and JSON Web Tokens (JWT)) verifies.
-
The response encoder writes the JSON.
That last point is the most important design decision on this page: the provider’s token is an entry ticket, not your
session. A
Google or Apple ID token is valid for about an hour (they differ) and is addressed to your client ID; it has no role,
it cannot be
revoked by you, and its claims are not yours to extend. You verify it once, at login, and then mint your own.
Packages
req.jwt.apple, req.jwt.google and req.jwt.microsoft live in the vapor/jwt package, not in Vapor itself. The
token types
(AppleIdentityToken, GoogleIdentityToken, MicrosoftIdentityToken) are in jwt-kit, which vapor/jwt depends on.
Imperial is
a separate community package.
// Package.swift (excerpt)
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.122.2"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.13.0"),
.package(url: "https://github.com/vapor/jwt.git", from: "5.1.2"),
// Only if you choose Imperial (see below); it pulls in one product per provider.
.package(url: "https://github.com/vapor-community/Imperial.git", from: "2.2.0"),
],
targets: [
.executableTarget(name: "App", dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "JWT", package: "jwt"),
.product(name: "ImperialGitHub", package: "imperial"),
]),
]
With the MemberImportVisibility upcoming feature that the official template enables, every file must import the module
that
defines the members it uses: a file that calls req.db needs import Fluent even though import Vapor is also
present, and one
that calls requireID() too. The compiler says "property 'db' is not available due to missing import of defining module
'Fluent'",
and the fix is the import, nothing else.
Settings, kept out of the code
Every identifier and secret comes from the environment; the fallbacks in the listing are obvious placeholders that fail at the provider rather than work by accident. The endpoints are in a struct so that the tests below can replace them.
// AuthSettings.swift
import JWT
import Vapor
/// Provider endpoints. The defaults are the real ones; tests point them at a stub.
struct ProviderEndpoints: Sendable {
var appleAuthorize = "https://appleid.apple.com/auth/authorize"
var appleToken = "https://appleid.apple.com/auth/token"
var githubAuthorize = "https://github.com/login/oauth/authorize"
var githubToken = "https://github.com/login/oauth/access_token"
var githubAPI = "https://api.github.com"
}
struct AuthSettings: Sendable {
// Sign in with Apple
var appleBundleID: String // audience of identity tokens minted for the native app
var appleServicesID: String // client_id (and audience) of the web flow
var appleTeamID: String // iss of the client-secret JWT
var appleKeyID: String // kid of the .p8 key
// Other providers
var googleClientID: String
var microsoftClientID: String
var githubClientID: String
var githubClientSecret: String
// Where the provider sends the browser back to
var publicBaseURL: String
var endpoints = ProviderEndpoints()
/// Every value comes from the environment. The obvious placeholders are used only in development and
/// testing; anywhere else a missing value stops the application at boot.
static func fromEnvironment(_ environment: Environment) throws -> AuthSettings {
let placeholdersAllowed = environment == .development || environment == .testing
func env(_ key: String, _ placeholder: String) throws -> String {
if let value = Environment.get(key) { return value }
guard placeholdersAllowed else { throw Abort(.internalServerError, reason: "\(key) is not set") }
return placeholder
}
return try AuthSettings(
appleBundleID: env("APPLE_BUNDLE_ID", "com.example.bookshelf"),
appleServicesID: env("APPLE_SERVICES_ID", "com.example.bookshelf.web"),
appleTeamID: env("APPLE_TEAM_ID", "<your-team-id>"),
appleKeyID: env("APPLE_KEY_ID", "<your-key-id>"),
googleClientID: env("GOOGLE_CLIENT_ID", "<your-google-client-id>"),
microsoftClientID: env("MICROSOFT_CLIENT_ID", "<your-microsoft-client-id>"),
githubClientID: env("GITHUB_CLIENT_ID", "<your-github-client-id>"),
githubClientSecret: env("GITHUB_CLIENT_SECRET", "<your-github-client-secret>"),
publicBaseURL: env("PUBLIC_BASE_URL", "http://localhost:8080")
)
}
}
extension Application {
private struct AuthSettingsKey: StorageKey { typealias Value = AuthSettings }
var authSettings: AuthSettings {
get {
guard let settings = storage[AuthSettingsKey.self] else { preconditionFailure("call configureAuthKeys first") }
return settings
}
set { storage[AuthSettingsKey.self] = newValue }
}
}
/// Loads the settings and registers the verification keys. The HMAC secret and the Apple .p8 key come from the
/// environment.
func configureAuthKeys(_ app: Application) async throws {
app.authSettings = try .fromEnvironment(app.environment)
guard let secret = Environment.get("BOOKSHELF_JWT_SECRET") else {
throw Abort(.internalServerError, reason: "BOOKSHELF_JWT_SECRET is not set")
}
await app.jwt.keys.add(hmac: HMACKey(from: secret), digestAlgorithm: .sha256, kid: "bookshelf")
// The Apple client secret is signed with the .p8 key (an EC P-256 private key in PEM form).
if let pem = Environment.get("APPLE_PRIVATE_KEY_PEM") {
let key = try ES256PrivateKey(pem: pem)
await app.jwt.keys.add(ecdsa: key, kid: JWKIdentifier(string: app.authSettings.appleKeyID))
}
}
Source: Vapor docs: Environment; Vapor docs: JWT
Four details are worth knowing:
-
Placeholders never reach production.
fromEnvironmentfalls back to an obvious placeholder only in thedevelopmentandtestingenvironments; anywhere else a missing variable throws, andconfigureAuthKeyslets the error stop the boot. A server that starts with<your-github-client-secret>fails later, at the first login, with a confusing provider error; one that refuses to start points at the missing variable. -
Application.storageis the place to hang aSendablesettings value; the Services, Storage and Dependency Injection page explains why aStorageKeyis preferred over a global. -
HMACKey(from:)with a secret shorter than the digest size (32 bytes for HS256) triggers an assertion in jwt-kit 5.7.1: "Key should be at least as large as the hash output: 32 bytes. This will become a precondition in a future release." The run for this page hit it with a 31 byte test secret. Use a long random value from the environment, and expect a crash, not a warning, in a later release. -
The Apple
.p8file is an EC private key in PKCS#8 PEM form.ES256PrivateKey(pem:)reads it as a string, so it can live in a secret store or an environment variable (a multi-line value) and never needs to be a file in the image.
The identity table and account linking
A provider’s sub is the only stable identifier. E-mail addresses change, are re-assigned and, in Apple’s case, may be
a relay address. So Bookshelf keeps one row per (provider, sub) pointing at a User. That table, and a second one
that records which accounts proved they own their e-mail address, are page-local additions to the canonical
Bookshelf model set; users keeps its email, password_hash and role fields unchanged.
// Federated.swift
import Fluent
import Vapor
/// Page-local addition: links a Bookshelf `User` to an account at an external identity provider.
final class FederatedIdentity: Model, @unchecked Sendable {
static let schema = "federated_identities"
@ID(key: .id) var id: UUID?
@Field(key: "provider") var provider: String // "apple", "google", "microsoft", "github"
@Field(key: "subject") var subject: String // the provider's stable user ID (`sub`)
@Parent(key: "user_id") var user: User
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
init(id: UUID? = nil, provider: String, subject: String, userID: User.IDValue) {
self.id = id
self.provider = provider
self.subject = subject
self.$user.id = userID
}
}
/// Page-local addition: records that a Bookshelf account proved it owns its e-mail address, either through a
/// verification link or because a provider vouched for the address when the account was created.
final class EmailVerification: Model, @unchecked Sendable {
static let schema = "email_verifications"
@ID(key: .id) var id: UUID?
@Parent(key: "user_id") var user: User
@Timestamp(key: "verified_at", on: .create) var verifiedAt: Date?
init() {}
init(userID: User.IDValue) {
self.$user.id = userID
}
}
struct CreateEmailVerification: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(EmailVerification.schema)
.id()
.field("user_id", .uuid, .required, .references(User.schema, "id", onDelete: .cascade))
.field("verified_at", .datetime)
.unique(on: "user_id")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(EmailVerification.schema).delete()
}
}
struct CreateFederatedIdentity: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(FederatedIdentity.schema)
.id()
.field("provider", .string, .required)
.field("subject", .string, .required)
.field("user_id", .uuid, .required, .references(User.schema, "id", onDelete: .cascade))
.field("created_at", .datetime)
.unique(on: "provider", "subject")
.create()
}
func revert(on database: any Database) async throws {
try await database.schema(FederatedIdentity.schema).delete()
}
}
/// What every provider hands us after its token has been verified.
struct ExternalProfile: Sendable {
let provider: String
let subject: String
let email: String?
let emailVerified: Bool
}
/// Finds or creates the Bookshelf user for a verified external profile.
func resolveUser(for profile: ExternalProfile, on database: any Database) async throws -> User {
try await database.transaction { tx in
// 1. Returning user: the (provider, subject) pair is the only stable key.
if let identity = try await FederatedIdentity.query(on: tx)
.filter(\.$provider == profile.provider)
.filter(\.$subject == profile.subject)
.with(\.$user)
.first()
{
return identity.user
}
// 2. Link to an existing account only when both sides vouch for the e-mail address: the provider, and
// the Bookshelf account (otherwise whoever registered the address first, unverified, would get in).
if profile.emailVerified, let email = profile.email?.lowercased(),
let existing = try await User.query(on: tx).filter(\.$email == email).first(),
try await EmailVerification.query(on: tx).filter(\.$user.$id == existing.requireID()).first() != nil
{
try await FederatedIdentity(provider: profile.provider, subject: profile.subject, userID: existing.requireID())
.save(on: tx)
return existing
}
// 3. New user. "!" can never be a bcrypt hash, so password login is impossible for this account.
// An address that already belongs to someone, but could not be linked, must never reach the unique index
// (a 500) or, worse, be linked. Make the user sign in the old way first and link from inside the account.
if let email = profile.email?.lowercased(),
try await User.query(on: tx).filter(\.$email == email).first() != nil
{
throw Abort(.conflict, reason: "An account with this e-mail address exists. Sign in with it first, then link.")
}
let email = profile.email?.lowercased() ?? "\(profile.provider)-\(profile.subject)@users.invalid"
let user = User(email: email, passwordHash: "!external", role: .reader)
try await user.save(on: tx)
try await FederatedIdentity(provider: profile.provider, subject: profile.subject, userID: user.requireID())
.save(on: tx)
if profile.emailVerified, profile.email != nil {
try await EmailVerification(userID: user.requireID()).save(on: tx)
}
return user
}
}
The rules resolveUser encodes, in order:
-
Known identity wins. A returning user is found by (provider,
sub) alone, whatever e-mail the provider shows today. -
Link by e-mail only when both sides vouch for it. The identity is attached to an existing Bookshelf user only when the provider marks the e-mail as verified and that user has an
EmailVerificationrow, written when they opened a verification link or when a provider vouched for the address at sign-up. This is what lets someone who registered with a password, and confirmed their address, later press "Sign in with Google". Either check alone is not enough. An address the provider does not verify can be typed by anyone. A Bookshelf account whose address was never verified may have been registered by an attacker who expects the victim to sign in with a provider later; linking it would put the victim’s provider login into an account the attacker can still enter with the password they chose (account pre-hijacking). -
Refuse the collision. Without this step, an address that already belongs to a user but cannot be linked falls through to "create a user", hits the unique index on
users.emailand surfaces as a 500 (constraintUniqueFailed: UNIQUE constraint failed: users.email, which is what SQLite reports). The listing answers 409 and tells the person to sign in the old way first and link from inside the account. -
Otherwise create a user. The password hash is the constant
!external, which no password check can ever match (it is not a bcrypt hash; see Passwords and Crypto), so the account can only be entered through its provider until the person sets a password. When the provider gives no e-mail, a placeholder on the reserved.invalidtop-level domain is used. When the provider verified the address, anEmailVerificationrow is written too, so a later provider can link to the account.
All of it runs in one database.transaction, and the unique(on: "provider", "subject") constraint backs it: two
simultaneous first
logins for the same new person make one of them fail rather than create two users. A production version catches that
failure and
retries the lookup.
Issuing Bookshelf’s own token
After verification the handler mints a short-lived HS256 JWT. The payload and the signing helper are short; the JWT page of this section covers refresh tokens, key rotation and asymmetric signing.
// Tokens.swift
import Fluent
import JWT
import Vapor
/// The access token this application issues after any login method succeeded.
struct BookshelfToken: JWTPayload {
var sub: SubjectClaim
var exp: ExpirationClaim
var role: String
func verify(using algorithm: some JWTAlgorithm) async throws {
try exp.verifyNotExpired()
}
}
struct AccessTokenResponse: Content {
let accessToken: String
let tokenType: String
let expiresIn: Int
}
func issueAccessToken(for user: User, on req: Request) async throws -> AccessTokenResponse {
let lifetime = 15 * 60
let payload = BookshelfToken(
sub: .init(value: try user.requireID().uuidString),
exp: .init(value: Date().addingTimeInterval(TimeInterval(lifetime))),
role: user.role.rawValue
)
let jwt = try await req.jwt.sign(payload, kid: "bookshelf")
return AccessTokenResponse(accessToken: jwt, tokenType: "Bearer", expiresIn: lifetime)
}
/// Verification failures are the client's problem (401) and their text is not for the client.
func orUnauthorized<T: Sendable>(
_ req: Request,
_ verification: () async throws -> T
) async throws -> T {
do {
return try await verification()
} catch let error as JWTError {
req.logger.info("Rejected identity token", metadata: ["reason": "\(error)"])
throw Abort(.unauthorized, reason: "Invalid identity token")
}
}
Source: Vapor docs: JWT; vapor/jwt-kit 5.7.1
orUnauthorized exists because of what the first run showed. A garbage bearer token produced this response body:
{"reason":"Couldn't decode alg and/or kid from JWT with error: DecodingError.dataCorrupted: Data was corrupted. Debug description: The given data was not valid JSON.. Underlying error: Error Domain=NSCocoaErrorDomain Code=3840 ..."}
and a token for the wrong client answered not intended for <your-google-client-id>, which tells an attacker your
client ID. A
JWTError is a client problem, so it becomes a plain 401 and the detail goes to the log. (The Vapor ErrorMiddleware
sends the
reason of any AbortError to the client in every environment, which is why the translation must happen in the
handler.)
Wiring it together (the order matters: migrations and keys first, then the sessions middleware on the group that needs it, then the routes):
// configure.swift
import Fluent
import FluentSQLiteDriver
import Vapor
func configure(_ app: Application) async throws {
app.databases.use(.sqlite(.memory), as: .sqlite)
app.migrations.add(CreateBookshelf())
app.migrations.add(CreateFederatedIdentity())
app.migrations.add(CreateEmailVerification())
try await app.autoMigrate()
try await configureAuthKeys(app)
// Only the GitHub flow needs a session; everything else is stateless.
let routes = app.grouped(app.sessions.middleware)
registerNativeLogins(app)
registerAppleWeb(app)
registerGitHubFlow(routes)
if Environment.get("GITHUB_CLIENT_ID") != nil { try registerImperial(app) }
}
Sign in with Apple, native flow
On iOS, macOS, tvOS and watchOS the app uses AuthenticationServices and receives an identity token. The app’s side
is on the
native apps page and in the Apple Platforms section of this site. The
server
receives that token and verifies it. A complete iOS client talking to a Vapor server is described in
Full-Stack Swift with iOS Clients.
// NativeLogins.swift
import Crypto
import Fluent
import JWT
import Vapor
struct AppleSignInBody: Content {
let identityToken: String
let nonce: String? // the raw nonce; the app sent its SHA-256 to Apple
}
func sha256Hex(_ string: String) -> String {
SHA256.hash(data: Data(string.utf8)).map { String(format: "%02x", $0) }.joined()
}
func registerNativeLogins(_ routes: any RoutesBuilder) {
let auth = routes.grouped("auth")
// POST /auth/apple { "identityToken": "...", "nonce": "..." }
auth.post("apple") { req async throws -> AccessTokenResponse in
let body = try req.content.decode(AppleSignInBody.self)
let settings = req.application.authSettings
// Signature (Apple JWKS), issuer, expiry and, because we pass it, the audience.
let token = try await orUnauthorized(req) {
try await req.jwt.apple.verify(body.identityToken, applicationIdentifier: settings.appleBundleID)
}
// Replay protection: the token must carry the hash of the nonce this app instance generated.
guard let raw = body.nonce, token.nonce == sha256Hex(raw) else {
throw Abort(.unauthorized, reason: "Nonce mismatch")
}
let profile = ExternalProfile(
provider: "apple",
subject: token.subject.value,
email: token.email,
emailVerified: token.emailVerified?.value ?? false
)
let user = try await resolveUser(for: profile, on: req.db)
return try await issueAccessToken(for: user, on: req)
}
// POST /auth/google Authorization: Bearer <Google ID token>
auth.post("google") { req async throws -> AccessTokenResponse in
let token = try await orUnauthorized(req) {
try await req.jwt.google.verify(applicationIdentifier: req.application.authSettings.googleClientID)
}
let profile = ExternalProfile(
provider: "google",
subject: token.subject.value,
email: token.email,
emailVerified: token.emailVerified?.value ?? false
)
let user = try await resolveUser(for: profile, on: req.db)
return try await issueAccessToken(for: user, on: req)
}
// POST /auth/microsoft Authorization: Bearer <Microsoft ID token>
auth.post("microsoft") { req async throws -> AccessTokenResponse in
let token = try await orUnauthorized(req) {
try await req.jwt.microsoft.verify(applicationIdentifier: req.application.authSettings.microsoftClientID)
}
// Microsoft's `sub` is pairwise per application, so it is a stable key for this app only.
// The token has no e-mail_verified claim, so never link by e-mail here.
let profile = ExternalProfile(
provider: "microsoft",
subject: token.subject.value,
email: token.email,
emailVerified: false
)
let user = try await resolveUser(for: profile, on: req.db)
return try await issueAccessToken(for: user, on: req)
}
}
What verify checks, and what it does not
Reading JWT+Apple.swift and AppleIdentityToken.swift (jwt-kit 5.7.1),
req.jwt.apple.verify(_:applicationIdentifier:) does this:
-
If no token string is passed, it takes the
Authorization: Bearerheader, and answers 401 when it is missing. -
It obtains the keys from
app.jwt.apple.jwks, anEndpointCache<JWKS>that fetcheshttps://appleid.apple.com/auth/keyswithreq.clientand keeps the result (theEndpointCachetype refreshes it when the entry expires). The key is chosen by the token’skid. -
It verifies the signature, then the payload’s own
verify: the issuer must be exactlyhttps://appleid.apple.com, and the token must not be expired. -
Only if an application identifier is available (the argument, or else
app.jwt.apple.applicationIdentifier), it checks that the audience contains it. -
It returns the
AppleIdentityToken.
Everything else is yours: the nonce claim, whether email_verified is true, and, most importantly, the audience when
you have not configured one. If you pass no applicationIdentifier and never set
app.jwt.apple.applicationIdentifier, any identity
token Apple has ever signed for any app passes verify. A malicious developer can obtain a valid token for their own
app and
present it to your API. The check is opt-in; always pass the identifier. (The official page shows
app.jwt.apple.applicationIdentifier = "..." in setup code and then calls verify() without arguments; that is fine,
but a
mistake in setup silently removes the check, so the listing above passes it explicitly at the call site.)
The audience differs by flow: the bundle ID for tokens obtained by a native app and the Services ID for tokens
obtained on the
web. Because app.jwt.apple.applicationIdentifier holds a single value, a service that accepts both passes the right
identifier per
route, as this page does.
The claims
AppleIdentityToken property |
Meaning |
|---|---|
|
Standard claims. |
|
The stable, per-team user identifier. The same person has a different |
|
Optional. Present when the |
|
The nonce the client sent, if any, and whether the device supports one. |
|
|
|
Present for Apple Business or School Manager managed accounts. |
A decoded payload from the stub, to show the shape (this is a throwaway test token for a fictional app signed with a key generated for the run, not an Apple token):
{
"iss": "https://appleid.apple.com",
"aud": "com.example.bookshelf",
"sub": "000111.native",
"iat": 1792256055,
"exp": 1792256655,
"email": "reader@example.com",
"email_verified": "true",
"nonce": "9f2c"
}
Nonce
A replayed identity token is a stolen login. The app generates a random raw nonce, gives Apple its SHA-256 as the
request’s nonce,
and sends the raw value to your server along with the token. Apple copies the hashed value into the token’s nonce
claim; the server
hashes the raw nonce it received and compares. The listing does this with sha256Hex. (SHA256 comes from
swift-crypto’s Crypto module, which is available because Vapor depends on it.) The comparison is a convention between
your app and your server, not something verify knows about,
so it must be in the handler. Store used nonces for the token’s lifetime if you need to defeat replay inside it.
Captured output
Against the stub, with the sub, nonce and e-mail used above:
$ curl -si -X POST $B/auth/apple -H 'content-type: application/json' \
-d '{"identityToken":"<test token>","nonce":"raw-nonce-1"}'
HTTP/1.1 200 OK
{"accessToken":"eyJraWQiOiJib29rc2hlbGYi...","tokenType":"Bearer","expiresIn":900}
# the same token, a different raw nonce
HTTP/1.1 401 Unauthorized
{"error":true,"reason":"Nonce mismatch"}
# a token minted for another app (aud = com.evil.app)
HTTP/1.1 401 Unauthorized
{"reason":"Invalid identity token","error":true}
Apple’s quirks
-
The user’s name is delivered only the first time the person authorizes your app, and only to the client (
ASAuthorizationAppleIDCredential.fullName), never inside the identity token. The app must send it with the first request; the server must store it then. If the first request fails after Apple has already shown the sheet, the name is gone until the person revokes the app in their Apple account settings and signs in again. Treat the first login as the only chance, and make it idempotent. -
The e-mail address can be a private relay address, and the person can change which address is shared. Do not use it as the identity.
-
emailis optional in the token. Do not blank a stored address because a later token lacks one. -
App Review requires an app that creates accounts to offer account deletion, and Apple documents a token revocation endpoint for apps that use Sign in with Apple. When a user deletes their Bookshelf account, revoke their Apple tokens too. The call is a form POST of
client_id,client_secret(the same generated JWT as the web flow below), the token and a type hint to Apple’s/auth/revokeendpoint. Apple’s Sign in with Apple REST API reference lists the parameter names and the acceptedtoken_type_hintvalues.
Sign in with Apple on the web
On a website there is no system sheet, so the flow is the authorization code flow with two Apple-specific requirements: the client secret is a JWT you sign, and the callback is a cross-site form POST.
// AppleWeb.swift
import Fluent
import JWT
import Vapor
/// The client secret Apple wants at its token endpoint: a short-lived ES256 JWT signed with the .p8 key.
struct AppleClientSecret: JWTPayload {
var iss: IssuerClaim // team ID
var iat: IssuedAtClaim
var exp: ExpirationClaim
var aud: AudienceClaim // https://appleid.apple.com
var sub: SubjectClaim // the Services ID
func verify(using algorithm: some JWTAlgorithm) async throws {}
}
/// The `state` we send to Apple. It is signed instead of stored, because Apple's form_post callback is a
/// cross-site POST and a SameSite=Lax session cookie is not sent with it. `browser` binds it to the browser
/// that started the flow: the SHA-256 of a random value kept in a `__Host-` cookie.
struct SignInState: JWTPayload {
var nonce: String
var browser: String
var exp: ExpirationClaim
func verify(using algorithm: some JWTAlgorithm) async throws {
try exp.verifyNotExpired()
}
}
struct AppleTokenResponse: Content {
let idToken: String
let accessToken: String?
let refreshToken: String?
enum CodingKeys: String, CodingKey {
case idToken = "id_token"
case accessToken = "access_token"
case refreshToken = "refresh_token"
}
}
/// What Apple POSTs to the redirect URI (response_mode=form_post).
struct AppleCallbackForm: Content {
let code: String?
let state: String?
let error: String?
}
func appleClientSecret(on req: Request) async throws -> String {
let settings = req.application.authSettings
let now = Date()
let payload = AppleClientSecret(
iss: .init(value: settings.appleTeamID),
iat: .init(value: now),
exp: .init(value: now.addingTimeInterval(5 * 60)), // minutes are enough; Apple allows up to six months
aud: .init(value: "https://appleid.apple.com"),
sub: .init(value: settings.appleServicesID)
)
return try await req.jwt.sign(payload, kid: JWKIdentifier(string: settings.appleKeyID))
}
/// Base64url without padding, as PKCE and JWTs require.
func base64URL(_ data: Data) -> String {
data.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
func randomToken(bytes: Int = 32) -> String {
base64URL(Data([UInt8].random(count: bytes)))
}
/// The browser-binding cookie. `__Host-` makes the browser insist on Secure, Path=/ and no Domain, so a sibling
/// subdomain cannot plant it. SameSite=None is what lets it travel with Apple's cross-site form POST.
let appleBindingCookie = "__Host-apple-signin"
func appleBindingCookieValue(_ value: String, maxAge: Int) -> HTTPCookies.Value {
HTTPCookies.Value(string: value, maxAge: maxAge, path: "/", isSecure: true, isHTTPOnly: true, sameSite: HTTPCookies.SameSitePolicy.none)
}
func registerAppleWeb(_ routes: any RoutesBuilder) {
let apple = routes.grouped("auth", "apple")
// GET /auth/apple/start: send the browser to Apple.
apple.get("start") { req async throws -> Response in
let settings = req.application.authSettings
let nonce = randomToken()
let binding = randomToken()
let state = try await req.jwt.sign(
SignInState(nonce: nonce, browser: sha256Hex(binding), exp: .init(value: Date().addingTimeInterval(10 * 60))),
kid: "bookshelf"
)
var components = URLComponents(string: settings.endpoints.appleAuthorize)!
components.queryItems = [
URLQueryItem(name: "client_id", value: settings.appleServicesID),
URLQueryItem(name: "redirect_uri", value: settings.publicBaseURL + "/auth/apple/callback"),
URLQueryItem(name: "response_type", value: "code"),
URLQueryItem(name: "response_mode", value: "form_post"), // required when asking for name or email
URLQueryItem(name: "scope", value: "name email"),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "nonce", value: nonce),
]
let response = req.redirect(to: components.string!)
response.cookies[appleBindingCookie] = appleBindingCookieValue(binding, maxAge: 10 * 60)
return response
}
// POST /auth/apple/callback: Apple posts a form here (cross-site POST).
apple.post("callback") { req async throws -> Response in
let settings = req.application.authSettings
let form = try req.content.decode(AppleCallbackForm.self)
if let error = form.error { throw Abort(.unauthorized, reason: "Apple returned \(error)") }
guard let code = form.code, let stateJWT = form.state else { throw Abort(.badRequest) }
// 1. The state must be ours, fresh, and started by this browser (no login CSRF).
let state = try await orUnauthorized(req) { try await req.jwt.verify(stateJWT, as: SignInState.self) }
guard let binding = req.cookies[appleBindingCookie]?.string, sha256Hex(binding) == state.browser else {
throw Abort(.unauthorized, reason: "Sign-in was not started in this browser")
}
// 2. Exchange the code, authenticating with the generated client secret.
struct TokenRequest: Content {
let client_id: String
let client_secret: String
let code: String
let grant_type: String
let redirect_uri: String
}
let tokenRequest = TokenRequest(
client_id: settings.appleServicesID,
client_secret: try await appleClientSecret(on: req),
code: code,
grant_type: "authorization_code",
redirect_uri: settings.publicBaseURL + "/auth/apple/callback"
)
let response = try await req.client.post(URI(string: settings.endpoints.appleToken)) { clientRequest in
try clientRequest.content.encode(tokenRequest, as: .urlEncodedForm)
}
guard response.status == .ok else { throw Abort(.badGateway, reason: "Apple token endpoint: \(response.status)") }
let tokens = try response.content.decode(AppleTokenResponse.self)
// 3. Verify the ID token. On the web the audience is the Services ID, not the bundle ID.
let idToken = try await orUnauthorized(req) {
try await req.jwt.apple.verify(tokens.idToken, applicationIdentifier: settings.appleServicesID)
}
guard idToken.nonce == state.nonce else { throw Abort(.unauthorized, reason: "Nonce mismatch") }
let profile = ExternalProfile(
provider: "apple",
subject: idToken.subject.value,
email: idToken.email,
emailVerified: idToken.emailVerified?.value ?? false
)
let user = try await resolveUser(for: profile, on: req.db)
let reply = try await issueAccessToken(for: user, on: req).encodeResponse(for: req)
reply.cookies[appleBindingCookie] = appleBindingCookieValue("", maxAge: 0) // one use only
return reply
}
}
The pieces:
-
The client secret. Apple does not give you a static secret. You sign a JWT with the
.p8key: headeralgES256 andkidof the key; claimsiss(your Team ID),iat,exp,aud(https://appleid.apple.com) andsub(the Services ID). Apple caps the lifetime:expmay be at most six months (15,777,000 seconds) afteriat. The listing makes one per request, valid for five minutes, which costs one ES256 signature per login and removes any rotation job. The captured log of the stub shows what the server sent:client_secret has 3 parts, header={"kid":"KEY1234567","alg":"ES256","typ":"JWT"} payload={"iss":"TEAM123456","aud":"https://appleid.apple.com","iat":1792256032,"sub":"com.example.bookshelf.web","exp":1792256332}(Team ID, key ID and Services ID in this output are made-up values; the key was generated for the run with
openssland deleted afterwards.) -
response_mode=form_post. Apple requires it when you ask for thenameoremailscope. The browser therefore arrives at the callback with aPOST, and that POST is cross-site. A session cookie set withSameSite=Lax(the Vapor default, and the sensible one) is not sent on a cross-site POST, so a state stored in the session would be missing exactly when it is needed. The listing makesstatea short-lived JWT signed with Bookshelf’s key that carries the nonce, so the session is not needed. -
Binding the state to the browser. A signed state alone proves that Bookshelf issued it, not that this browser asked for it. Without a binding, an attacker can start a sign-in, stop before the callback, and send the victim an auto-submitting form with the attacker’s
codeandstate; the victim is then logged in as the attacker (login CSRF), and whatever they save lands in the attacker’s account. The start route therefore also sets a random value in the__Host-apple-signincookie and puts its SHA-256 in the state; the callback answers 401 unless the cookie is present and matches, then deletes it. The cookie must beSameSite=Noneto travel with Apple’s cross-site POST, which also makes itSecure. The__Host-prefix makes the browser refuse it unless it isSecure, hasPath=/and has noDomain, so a sibling subdomain cannot plant one.HttpOnlyand a ten-minuteMax-Agekeep its exposure small. The cookie carries no session and no identity, soSameSite=Nonecosts little here. -
The token exchange.
req.client.postwith a URL-encoded body. The form is a smallContentstruct; the Content and Codable page covers.urlEncodedForm. Check the status before decoding; Apple answers 400 with a JSONerror(invalid_grantfor a reused or expired code). -
Verification. Use the same
req.jwt.apple.verify, now with the Services ID as the audience, and compare the nonce with the one inside the state. On the web the nonce is sent to Apple as-is and comes back unchanged in the token’snonceclaim; the stub does the same. -
PKCE. Apple’s REST API reference documents the token request with the generated client secret and lists no
code_challengeorcode_verifierparameters for Sign in with Apple, so the listing sends none. The client secret, the browser-boundstateand the nonce cover the same ground for this confidential web client.
Captured output, state and nonce round trip through the stub:
$ curl -si $B/auth/apple/start | grep -i '^location\|^set-cookie'
location: http://127.0.0.1:8090/mock/apple/authorize?client_id=com.example.bookshelf.web&redirect_uri=http://127.0.0.1:8090/auth/apple/callback
&response_type=code&response_mode=form_post&scope=name%20email&state=<signed JWT>&nonce=TgIHfdrpMJjipOCY_y_OQGZJmCqXCmggwEYug3tJ2s0
set-cookie: __Host-apple-signin=<random value>; Max-Age=600; Path=/; Secure; HttpOnly; SameSite=None
$ curl -si -X POST $B/auth/apple/callback -d "code=mock-code&state=<signed JWT>" # no cookie: another browser
HTTP/1.1 401 Unauthorized
{"reason":"Sign-in was not started in this browser","error":true}
$ curl -si -X POST $B/auth/apple/callback -b "__Host-apple-signin=<random value>" -d "code=mock-code&state=<signed JWT>"
HTTP/1.1 200 OK
set-cookie: __Host-apple-signin=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=None
{"expiresIn":900,"accessToken":"eyJraWQiOiJib29rc2hlbGYi...","tokenType":"Bearer"}
$ curl -si -X POST $B/auth/apple/callback -d "code=mock-code&state=abc.def.ghi"
HTTP/1.1 401 Unauthorized
{"reason":"Invalid identity token","error":true}
$ curl -si -X POST $B/auth/apple/callback -d "error=user_cancelled_authorize"
HTTP/1.1 401 Unauthorized
{"error":true,"reason":"Apple returned user_cancelled_authorize"}
The callback here returns the token as JSON to keep the example short. A real web application would instead log the user into a session (and redirect to a page); the Session Authentication and Web Login page shows how, and in that case the session ID must be rotated at login.
The user form field that Apple posts on the first authorization (a JSON string with the name and e-mail) is not
decoded in the listing;
add an optional user: String? to AppleCallbackForm and decode it with JSONDecoder if you want the name on the web.
Google and Microsoft ID tokens
The native routes above already include them. The helpers are siblings of the Apple one, with their own configuration:
| Property | Meaning |
|---|---|
|
Your OAuth client ID. Passed per call in the listing. |
|
Restricts logins to a Google Workspace domain by requiring the token’s hosted-domain ( |
|
The application (client) ID registered in Microsoft Entra ID. |
|
The key sets. Defaults: Google’s published keys, and Microsoft’s |
What each verify checks (from the 5.1.2 and jwt-kit 5.7.1 sources):
-
Google: signature, issuer is
accounts.google.comorhttps://accounts.google.com, subject at most 255 characters, not expired, and (if configured) audience and hosted domain.email_verifiedis aBoolClaim?and must be read; Treat a missing or false value as unverified. -
Microsoft: signature, a
tidclaim must exist, the issuer must equalhttps://login.microsoftonline.com/<tid>/v2.0for the token’s owntid, and expiry, plus the audience if configured. Because the issuer is compared with the token’s own tenant, a token from any Entra tenant passes if its audience matches. If your application is single-tenant, comparetoken.tenantId.valuewith your tenant ID yourself. The keys come from thecommonendpoint, which is right for multi-tenant tokens. Microsoft’ssubis pairwise (different for each application), and ID tokens carry noemail_verified, so the listing setsemailVerified: falseand never links by address.emailis also optional and not guaranteed to be the person’s mailbox;oidis the tenant-wide object ID.
Results observed against the stub (one request each, summarised rather than quoted):
google verified e-mail equal to an existing user -> same Bookshelf user as the Apple login
google unverified e-mail of an existing user -> 409 "An account with this e-mail address exists. Sign in with it first, then link."
google token for another client ID -> 401 {"reason":"Invalid identity token"}
microsoft token for another application -> 401 {"reason":"Invalid identity token"}
no bearer header -> 401 {"reason":"Unauthorized"}
garbage bearer value -> 401 {"reason":"Invalid identity token"}
Firebase Authentication tokens have the same helper pattern in the library (FirebaseAuthIdentityToken); it is not
covered here.
A hand-rolled authorization code flow with state and PKCE
GitHub issues no ID token: after the code exchange you get an opaque access token and call the API to learn who signed
in. This is the
case where you need the full redirect dance. It is also the clearest picture of what any OAuth library has to do, so the
page builds it
with req.client (the HTTP client page covers the client itself).
// GitHubFlow.swift
import Crypto
import Fluent
import Vapor
struct GitHubTokenResponse: Content {
let accessToken: String
enum CodingKeys: String, CodingKey { case accessToken = "access_token" }
}
struct GitHubUser: Content {
let id: Int
let login: String
}
struct GitHubEmail: Content {
let email: String
let primary: Bool
let verified: Bool
}
func registerGitHubFlow(_ routes: any RoutesBuilder) {
// Needs SessionsMiddleware: the state and the PKCE verifier live in the session between the two requests.
let github = routes.grouped("auth", "github")
github.get("start") { req async throws -> Response in
let settings = req.application.authSettings
let state = randomToken()
let verifier = randomToken()
let challenge = base64URL(Data(SHA256.hash(data: Data(verifier.utf8))))
req.session.data["oauth_state"] = state
req.session.data["pkce_verifier"] = verifier
var components = URLComponents(string: settings.endpoints.githubAuthorize)!
components.queryItems = [
URLQueryItem(name: "client_id", value: settings.githubClientID),
URLQueryItem(name: "redirect_uri", value: settings.publicBaseURL + "/auth/github/callback"),
URLQueryItem(name: "scope", value: "read:user user:email"),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "code_challenge", value: challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
]
return req.redirect(to: components.string!)
}
github.get("callback") { req async throws -> AccessTokenResponse in
let settings = req.application.authSettings
let code: String = try req.query.get(at: "code")
let returnedState: String = try req.query.get(at: "state")
// One-shot: remove the values whatever happens next.
let expectedState = req.session.data["oauth_state"]
let verifier = req.session.data["pkce_verifier"]
req.session.data["oauth_state"] = nil
req.session.data["pkce_verifier"] = nil
guard let expectedState, let verifier, returnedState == expectedState else {
throw Abort(.unauthorized, reason: "Invalid state")
}
struct TokenRequest: Content {
let client_id: String
let client_secret: String
let code: String
let redirect_uri: String
let code_verifier: String
}
let tokenRequest = TokenRequest(
client_id: settings.githubClientID,
client_secret: settings.githubClientSecret,
code: code,
redirect_uri: settings.publicBaseURL + "/auth/github/callback",
code_verifier: verifier
)
let tokenResponse = try await req.client.post(URI(string: settings.endpoints.githubToken)) { clientRequest in
clientRequest.headers.replaceOrAdd(name: .accept, value: "application/json")
try clientRequest.content.encode(tokenRequest, as: .urlEncodedForm)
}
guard tokenResponse.status == .ok,
let accessToken = try? tokenResponse.content.decode(GitHubTokenResponse.self).accessToken
else { throw Abort(.unauthorized, reason: "GitHub refused the code") }
// GitHub issues no ID token: ask the API who this is.
func call<T: Content>(_ path: String, as type: T.Type) async throws -> T {
let response = try await req.client.get(URI(string: settings.endpoints.githubAPI + path)) { clientRequest in
clientRequest.headers.bearerAuthorization = BearerAuthorization(token: accessToken)
clientRequest.headers.replaceOrAdd(name: .accept, value: "application/vnd.github+json")
clientRequest.headers.replaceOrAdd(name: .userAgent, value: "bookshelf") // GitHub rejects requests without one
}
guard response.status == .ok else { throw Abort(.badGateway, reason: "GitHub API: \(response.status)") }
return try response.content.decode(T.self)
}
let me = try await call("/user", as: GitHubUser.self)
let emails = try await call("/user/emails", as: [GitHubEmail].self)
let primary = emails.first { $0.primary && $0.verified }
let profile = ExternalProfile(
provider: "github",
subject: String(me.id), // the numeric ID never changes; the login can
email: primary?.email,
emailVerified: primary != nil
)
let user = try await resolveUser(for: profile, on: req.db)
return try await issueAccessToken(for: user, on: req)
}
}
Why each line is there:
-
stateis a random value stored in the session before the redirect and compared on return. It binds the callback to the browser that started the flow; without it, an attacker can make a victim’s browser complete the attacker’s login (login CSRF), after which the victim’s actions land in the attacker’s account. -
PKCE (
code_challengewithS256, thencode_verifierat the exchange) binds the code to the party that asked for it, so a code stolen from the redirect is useless. GitHub documents both as strongly recommended and supports only theS256method. The verifier is generated with[UInt8].random(count:), which Vapor backs with a cryptographically secure generator. -
The state and verifier are removed from the session before anything can fail, so each login attempt is one-shot (the replay in the captured output below is refused).
-
The numeric GitHub
idis the subject. Alogincan be renamed and re-registered by someone else. -
Only a primary, verified address from
/user/emailsis trusted for linking; theemailfield of/useris the public profile address and can be unverified or absent. -
GitHub rejects API requests without a
User-Agentheader. -
The route needs
SessionsMiddleware:configure.swiftabove registers the routes on a group that has it. The session cookie should beHttpOnlyand, in production,Secure; see the Sessions and Cookies page for the cookie factory. -
The access token is used once and dropped. Keep it (encrypted) only if the application later calls GitHub on the person’s behalf.
Captured output (stub provider):
$ curl -si -c jar.txt $B/auth/github/start
HTTP/1.1 303 See Other
location: http://127.0.0.1:8090/mock/github/authorize?client_id=gh-client-placeholder&redirect_uri=http://127.0.0.1:8090/auth/github/callback
&scope=read:user%20user:email&state=fERxHLer1lSL818VY0a1qEYeTdizWdlIvhFBJxcegq4&code_challenge=2oysa2TS49QYPK099tjbUWHr...
set-cookie: vapor-session=Ui03XSKcuN9Miwb1KdTG7j5xVtmxUFun7p6w0trlp8E=; Expires=Sat, 24 Oct 2026 16:53:52 GMT; Path=/; SameSite=Lax
$ curl -si -b jar.txt "$B/auth/github/callback?code=c1&state=nope"
HTTP/1.1 401 Unauthorized
{"error":true,"reason":"Invalid state"}
$ curl -si -b jar.txt "$B/auth/github/callback?code=c1&state=<the state from start>"
HTTP/1.1 200 OK
{"expiresIn":900,"accessToken":"eyJraWQiOiJib29rc2hlbGYi...","tokenType":"Bearer"}
$ # the same callback again
HTTP/1.1 401 Unauthorized
{"error":true,"reason":"Invalid state"}
The stub’s log line for the exchange confirms the verifier reached the token endpoint: github token request code=c1
verifier_len=43 (32 random
bytes, base64url, 43 characters, the length RFC 7636 requires at least). The session cookie above is the Vapor default:
no Secure, no HttpOnly. Configure it as the Sessions
page shows before this goes anywhere real.
The same listing works for any plain OAuth 2.0 provider: change the endpoints and scopes, the way the user is
identified, and (for OpenID Connect providers) verify the returned
id_token with JWTKeyCollection and the provider’s JWKS instead of calling a user-info endpoint. Google’s
authorization-code flow accepts code_challenge and code_challenge_method=S256 the same way (its OAuth 2.0 guides
document both parameters), so the PKCE part of the listing carries over unchanged.
Imperial
Imperial (vapor-community/Imperial) wraps this flow for about fifteen providers, with a router per service. Version 2.x targets Vapor 4 with async/await and Swift 6 (tools version 6.0); 2.2.0 was released on 2025-10-10, and the project has not been archived. Its 0.x line targeted Vapor 3 and futures.
// ImperialDemo.swift
import ImperialGitHub
import Vapor
/// Imperial 2.2.0: GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are read from the environment when the route is registered.
func registerImperial(_ app: Application) throws {
// Imperial keeps the provider's access token in the session.
let session = app.grouped(app.sessions.middleware)
try session.oAuth(
from: GitHub.self,
authenticate: "imperial/github",
callback: "http://localhost:8080/imperial/github/callback",
scope: ["read:user"]
) { req, accessToken in
// `accessToken` is GitHub's access token. There is no ID token: call the API yourself.
req.logger.info("GitHub login finished")
return req.redirect(to: "/imperial/me")
}
session.grouped(ImperialMiddleware(redirect: "/imperial/github")).get("imperial", "me") { req async throws -> String in
_ = try req.accessToken
return "signed in"
}
}
How it behaves (read from the 2.2.0 sources, and the redirect run against the page’s scratch app):
-
Client ID and secret are read from
GITHUB_CLIENT_IDandGITHUB_CLIENT_SECRET(GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET, and so on) when the route is registered; a missing variable throws at startup (ImperialError.missingEnvVar). -
oAuth(from:authenticate:callback:scope:completion:)registers two GET routes: theauthenticatepath that redirects to the provider, and the callback path. The completion closure receives the request and the provider’s access token, which Imperial also stores in the session (req.accessToken).ImperialMiddlewareguards routes on that stored token. It does not produce a Bookshelf user: the closure is where you call the provider API and runresolveUser. -
The redirect it issued in the run was exactly:
$ curl -si $B/imperial/github HTTP/1.1 303 See Other location: https://github.com/login/oauth/authorize?client_id=gh-client-placeholder&scope=read:userThat URL has no
stateand no PKCE challenge, and the callback does not verify one (FederatedServiceRouter.fetchTokenonly reads thecodequery item). The Google router builds its URL from the client ID, redirect URI, scope and response type only. So login CSRF is not defended by the library, and theauthenticateCallbackhook cannot add it because it runs before a fixed redirect. For a production login prefer the hand-rolled flow above, or a customFederatedServiceRouter(it is a public protocol) that adds both. -
The callback for GitHub is not sent as
redirect_uriin the authorization URL (GitHub falls back to the callback URL configured on the OAuth app). -
It is built on the session, so it needs
app.sessions.middlewareand a session driver that suits your deployment. -
It does not verify ID tokens; for Google that means you either call the user-info endpoint with the access token or use the JWT route (
ImperialGooglealso contains a separate JWT-based router,GoogleJWTRouter, for the service-accountjwt-bearergrant; it is a server-to-server flow, not a user login, and this page does not cover it.)
Choose Imperial when you need many providers quickly for a low-stakes login, and are willing to audit it; choose the hand-rolled flow or the ID-token helpers when the login protects accounts you care about.
Testing without the real providers
You cannot call Apple from a test, and you do not want to. Two properties make the verification path testable: the JWKS
URL is settable, and JWTKeyCollection
can sign with a key you generate. The page’s scratch app served /mock/jwks (the public half of an ES256PrivateKey()
generated at startup) and /mock/mint/apple,
/google and /microsoft (which sign payloads shaped like the real tokens); it then pointed the helpers and endpoints
at itself:
extension Application {
func pointProvidersAtStub(_ base: String) {
var s = authSettings
s.endpoints.appleAuthorize = base + "/mock/apple/authorize"
s.endpoints.appleToken = base + "/mock/apple/token"
s.endpoints.githubAuthorize = base + "/mock/github/authorize"
s.endpoints.githubToken = base + "/mock/github/token"
s.endpoints.githubAPI = base + "/mock/github"
s.publicBaseURL = base
authSettings = s
jwt.apple.jwksEndpoint = URI(string: base + "/mock/jwks")
jwt.google.jwksEndpoint = URI(string: base + "/mock/jwks")
jwt.microsoft.jwksEndpoint = URI(string: base + "/mock/jwks")
}
}
Two things the stub made visible:
-
Setting
jwksEndpointreplaces theEndpointCache, so call it during configuration, before traffic. Real servers fetch the keys over HTTPS; the stub used HTTP on127.0.0.1, whichreq.clientaccepts. -
The tokens the stub signs must have the same claim names and types as the real ones, or decoding fails.
email_verifiedis a string"true"in the Apple stub on purpose, to prove theBoolClaimaccepts it. The real Apple servers, which this page did not call, are the only authority on the exact wire format.
For VaporTesting use app.testing() and a stub that is itself registered as routes in the test application (the same
pattern, different transport); the tests of a
production application also assert that a token with the wrong audience, a wrong nonce and an expired exp are refused,
which are the three cheapest vulnerabilities to introduce.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
Any Apple token is accepted |
No |
|
|
Assertion "Key should be at least as large as the hash output: 32 bytes" |
The HMAC secret is shorter than 32 bytes for HS256. Use a longer random secret. |
|
The bearer value is not a JWT. Translate |
|
Audience mismatch: the client is using another client ID, or a bundle ID where the Services ID is expected. |
|
A provider e-mail collides with an existing user that cannot be linked. Answer 409 and require a login first (see |
The web callback never finds the session |
Apple’s |
A victim ends up logged in as the attacker |
Login CSRF: the |
First login works, name is missing later |
Apple sends the name once, to the app. Store it on the first request. |
Apple answers |
The client secret is wrong: Team ID, key ID or Services ID mismatch, an expired |
Apple answers |
The code was already used, has expired, or the |
|
The URI must match the registered one exactly, including scheme, host, port and trailing slash. |
Two accounts for one person |
No linking at all. Link when both the provider and the Bookshelf account verified the e-mail, otherwise by explicit in-account linking. |
A person’s provider login lands in an account an attacker registered |
Account pre-hijacking: an attacker registered the address first and the identity was linked to that unverified account. Link only to accounts with a verified e-mail. |
Security checklist
-
Pass the audience, always; check the nonce (native and web); check
state(web). -
Key accounts by (provider,
sub), never by e-mail; link by e-mail only when the provider marks it verified. -
Issue your own short-lived token; never use a provider ID token as your session.
-
Keep client secrets, the
.p8key and the HMAC secret in the environment or a secret store, never in the repository. -
Turn provider error detail into a generic 401 and log the real reason.
-
Rate-limit the login routes; they are public and each call can make an outbound request (JWKS refresh, token exchange).
-
Offer account deletion, and revoke the provider tokens you stored.
The Security Hardening page covers CSRF, rate limiting and headers, and the OAuth security best practices page the protocol side.
Where the books are outdated
Condon et al. (Kodeco) cover OAuth with Imperial for Google and GitHub and Sign in with Apple; the two Packt books cover authentication more generally. They use Imperial 0.7 on Vapor 3 and future-based code, so check any code from them, or from another source written for an earlier Vapor 4 release, against these changes:
-
Imperial: code from the 0.x line is future-based and was built for Vapor 3. Version 2.x is async/await and Swift 6 and uses
routes.oAuth(from: GitHub.self, ...)as shown above, with a completion closure that isasync throwsand receives the provider’s access token as aString. -
JWT: older code uses
JWTSigners,req.jwt.apple.verify().flatMap { ... }with futures, andapp.jwt.signers.use(...). In vapor/jwt 5 the API is the actorJWTKeyCollection(app.jwt.keys.add(hmac:digestAlgorithm:kid:)),async throwsverification, andJWTPayload.verify(using:). -
Sign in with Apple: the web flow with a generated client secret is the same idea as in the books, but
AppleIdentityTokengained fields (realUserStatus,orgId,nonceSupported) and Apple’s token response and revoke endpoint should be re-read in Apple’s current documentation. -
Whatever an older example does, check two things in the library you actually run: that the application identifier check is on (it is opt-in), and whether the redirect carries
state(Imperial 2.2.0 sends none). Both are covered above.
Version notes
-
vapor/jwt 5.0 moved to jwt-kit 5 (async
JWTKeyCollection,JWTAlgorithmgenerics); 5.1.2 is the version used here. Code written for vapor/jwt 4 does not compile unchanged. -
req.jwt.apple,.google,.microsoftand.firebaseAuthexist in the 5.1.2 sources;.apple.verifyreturnsAppleIdentityToken. -
Imperial 2.x has tools version 6.0 (Swift 6) and targets Vapor 4; 2.0.1 was released on 2025-06-25 and 2.2.0, the latest tag checked on 2026-10-17, on 2025-10-10.
-
Vapor 5 (5.0.0-beta.4, not released) is a major version. vapor/jwt 5.1.2 and Imperial 2.2.0 both depend on Vapor 4 (
from: "4.101.0"andfrom: "4.117.0"); check each package’s release notes for Vapor 5 support before moving.