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.

req.jwt.apple.verify(_:applicationIdentifier:). No client secret is needed.

Native app or single-page app, Google or Microsoft

The app obtains an ID token with the provider’s SDK and posts it.

req.jwt.google.verify(...), req.jwt.microsoft.verify(...).

Browser, Sign in with Apple on your site

Apple redirects the browser back with an authorization code (a form_post).

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 req.client, then call the provider’s API to learn who the user is.

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:

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

  2. The router matches a path such as POST /auth/apple and calls the handler.

  3. The handler verifies the proof. req.jwt.apple.verify fetches the provider’s JWKS through req.client on the first call (and refreshes it when the cache entry expires), checks the signature and claims, and returns a typed payload.

  4. 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.

  5. 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.

sequenceDiagram participant A as iOS app participant S as Sign in with Apple participant V as Vapor API participant K as Apple JWKS participant D as Fluent database A->>A: generate raw nonce, hash it A->>S: authorization request with hashed nonce S-->>A: identity token JWT A->>V: POST /auth/apple with token and raw nonce V->>K: GET keys, only when not cached K-->>V: JWKS V->>V: verify signature, iss, exp, aud, then the nonce V->>D: find identity by provider and sub, else link or create D-->>V: User V-->>A: Bookshelf access token

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

Four details are worth knowing:

  • Placeholders never reach production. fromEnvironment falls back to an obvious placeholder only in the development and testing environments; anywhere else a missing variable throws, and configureAuthKeys lets 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.storage is the place to hang a Sendable settings value; the Services, Storage and Dependency Injection page explains why a StorageKey is 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 .p8 file 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:

  1. Known identity wins. A returning user is found by (provider, sub) alone, whatever e-mail the provider shows today.

  2. 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 EmailVerification row, 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).

  3. 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.email and 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.

  4. 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 .invalid top-level domain is used. When the provider verified the address, an EmailVerification row 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")
    }
}

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:

  1. If no token string is passed, it takes the Authorization: Bearer header, and answers 401 when it is missing.

  2. It obtains the keys from app.jwt.apple.jwks, an EndpointCache<JWKS> that fetches https://appleid.apple.com/auth/keys with req.client and keeps the result (the EndpointCache type refreshes it when the entry expires). The key is chosen by the token’s kid.

  3. It verifies the signature, then the payload’s own verify: the issuer must be exactly https://appleid.apple.com, and the token must not be expired.

  4. Only if an application identifier is available (the argument, or else app.jwt.apple.applicationIdentifier), it checks that the audience contains it.

  5. 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

issuer, audience, expires, issuedAt

Standard claims. audience is an AudienceClaim, which can hold one value or several.

subject

The stable, per-team user identifier. The same person has a different sub for a different developer team.

email, emailVerified, isPrivateEmail

Optional. Present when the email scope was granted. isPrivateEmail is true for an Apple relay address (@privaterelay.appleid.com), which forwards mail only if you registered your sending domain with Apple. emailVerified is a BoolClaim (Apple sends it as a string or a Boolean; read .value).

nonce, nonceSupported

The nonce the client sent, if any, and whether the device supports one.

realUserStatus

.unsupported, .unknown or .likelyReal: Apple’s hint that the account is used by a person.

orgId

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.

  • email is 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/revoke endpoint. Apple’s Sign in with Apple REST API reference lists the parameter names and the accepted token_type_hint values.

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.

sequenceDiagram participant B as Browser participant V as Vapor API participant A as appleid.apple.com B->>V: GET /auth/apple/start V-->>B: 302 to Apple with state, nonce, response_mode=form_post, plus a binding cookie B->>A: user signs in and consents A-->>B: auto-submitting form to the redirect URI B->>V: POST /auth/apple/callback with code and state V->>V: verify the signed state and its binding cookie V->>A: POST token endpoint with code and ES256 client secret A-->>V: id_token, access_token, refresh_token V->>V: verify id_token, compare nonce V-->>B: Bookshelf access token
// 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 .p8 key: header alg ES256 and kid of the key; claims iss (your Team ID), iat, exp, aud (https://appleid.apple.com) and sub (the Services ID). Apple caps the lifetime: exp may be at most six months (15,777,000 seconds) after iat. 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 openssl and deleted afterwards.)

  • response_mode=form_post. Apple requires it when you ask for the name or email scope. The browser therefore arrives at the callback with a POST, and that POST is cross-site. A session cookie set with SameSite=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 makes state a 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 code and state; 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-signin cookie 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 be SameSite=None to travel with Apple’s cross-site POST, which also makes it Secure. The __Host- prefix makes the browser refuse it unless it is Secure, has Path=/ and has no Domain, so a sibling subdomain cannot plant one. HttpOnly and a ten-minute Max-Age keep its exposure small. The cookie carries no session and no identity, so SameSite=None costs little here.

  • The token exchange. req.client.post with a URL-encoded body. The form is a small Content struct; the Content and Codable page covers .urlEncodedForm. Check the status before decoding; Apple answers 400 with a JSON error (invalid_grant for 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’s nonce claim; the stub does the same.

  • PKCE. Apple’s REST API reference documents the token request with the generated client secret and lists no code_challenge or code_verifier parameters for Sign in with Apple, so the listing sends none. The client secret, the browser-bound state and 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

app.jwt.google.applicationIdentifier

Your OAuth client ID. Passed per call in the listing.

app.jwt.google.gSuiteDomainName

Restricts logins to a Google Workspace domain by requiring the token’s hosted-domain (hd) claim to equal it. Never trust the email suffix for this: the hosted domain is the claim Google designed for it. Also accepted per call as gSuiteDomainName:.

app.jwt.microsoft.applicationIdentifier

The application (client) ID registered in Microsoft Entra ID.

app.jwt.google.jwksEndpoint, app.jwt.microsoft.jwksEndpoint

The key sets. Defaults: Google’s published keys, and Microsoft’s https://login.microsoftonline.com/common/discovery/keys. Settable; this page uses it to point the tests at a stub.

What each verify checks (from the 5.1.2 and jwt-kit 5.7.1 sources):

  • Google: signature, issuer is accounts.google.com or https://accounts.google.com, subject at most 255 characters, not expired, and (if configured) audience and hosted domain. email_verified is a BoolClaim? and must be read; Treat a missing or false value as unverified.

  • Microsoft: signature, a tid claim must exist, the issuer must equal https://login.microsoftonline.com/<tid>/v2.0 for the token’s own tid, 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, compare token.tenantId.value with your tenant ID yourself. The keys come from the common endpoint, which is right for multi-tenant tokens. Microsoft’s sub is pairwise (different for each application), and ID tokens carry no email_verified, so the listing sets emailVerified: false and never links by address. email is also optional and not guaranteed to be the person’s mailbox; oid is 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:

  • state is 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_challenge with S256, then code_verifier at 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 the S256 method. 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 id is the subject. A login can be renamed and re-registered by someone else.

  • Only a primary, verified address from /user/emails is trusted for linking; the email field of /user is the public profile address and can be unverified or absent.

  • GitHub rejects API requests without a User-Agent header.

  • The route needs SessionsMiddleware: configure.swift above registers the routes on a group that has it. The session cookie should be HttpOnly and, 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_ID and GITHUB_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: the authenticate path 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). ImperialMiddleware guards routes on that stored token. It does not produce a Bookshelf user: the closure is where you call the provider API and run resolveUser.

  • 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:user

    That URL has no state and no PKCE challenge, and the callback does not verify one (FederatedServiceRouter.fetchToken only reads the code query 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 the authenticateCallback hook cannot add it because it runs before a fixed redirect. For a production login prefer the hand-rolled flow above, or a custom FederatedServiceRouter (it is a public protocol) that adds both.

  • The callback for GitHub is not sent as redirect_uri in 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.middleware and 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 (ImperialGoogle also contains a separate JWT-based router, GoogleJWTRouter, for the service-account jwt-bearer grant; 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 jwksEndpoint replaces the EndpointCache, so call it during configuration, before traffic. Real servers fetch the keys over HTTPS; the stub used HTTP on 127.0.0.1, which req.client accepts.

  • The tokens the stub signs must have the same claim names and types as the real ones, or decoding fails. email_verified is a string "true" in the Apple stub on purpose, to prove the BoolClaim accepts 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 applicationIdentifier passed or configured. Always pass it (bundle ID for native, Services ID for web).

property 'db' is not available due to missing import of defining module 'Fluent'

MemberImportVisibility is on. Add import Fluent (and import JWT, import Crypto) to the file that uses the member.

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.

401 with Couldn't decode alg and/or kid from JWT

The bearer value is not a JWT. Translate JWTError to a generic 401 (see orUnauthorized).

401 with not intended for ...

Audience mismatch: the client is using another client ID, or a bundle ID where the Services ID is expected.

500 with UNIQUE constraint failed: users.email

A provider e-mail collides with an existing user that cannot be linked. Answer 409 and require a login first (see resolveUser).

The web callback never finds the session

Apple’s form_post is a cross-site POST, and SameSite=Lax cookies are not sent. Use a signed state bound to a SameSite=None __Host- cookie (as above).

A victim ends up logged in as the attacker

Login CSRF: the state is not bound to the browser that started the flow. Bind it with a cookie and compare at the callback.

First login works, name is missing later

Apple sends the name once, to the app. Store it on the first request.

Apple answers invalid_client at the token endpoint

The client secret is wrong: Team ID, key ID or Services ID mismatch, an expired exp, or the .p8 key does not belong to that key ID.

Apple answers invalid_grant

The code was already used, has expired, or the redirect_uri differs from the authorization request.

invalid_request mentioning redirect_uri at GitHub or Google

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 .p8 key 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 is async throws and receives the provider’s access token as a String.

  • JWT: older code uses JWTSigners, req.jwt.apple.verify().flatMap { ... } with futures, and app.jwt.signers.use(...). In vapor/jwt 5 the API is the actor JWTKeyCollection (app.jwt.keys.add(hmac:digestAlgorithm:kid:)), async throws verification, and JWTPayload.verify(using:).

  • Sign in with Apple: the web flow with a generated client secret is the same idea as in the books, but AppleIdentityToken gained 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, JWTAlgorithm generics); 5.1.2 is the version used here. Code written for vapor/jwt 4 does not compile unchanged.

  • req.jwt.apple, .google, .microsoft and .firebaseAuth exist in the 5.1.2 sources; .apple.verify returns AppleIdentityToken.

  • 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" and from: "4.117.0"); check each package’s release notes for Vapor 5 support before moving.