Auth guides
09 · Sign in with Apple

Sign in with Apple.

Two integrations, one backend. Native iOS / macOS posts the Apple ID token straight to Auth; web apps redirect through Auth and pick up a single-use code on the callback. No SDK, no PKCE bookkeeping in your frontend, no Apple-specific UI in your product.


1

Apple Developer console setup

One-time, ~10 minutes. Everything happens inside Certificates, Identifiers & Profiles. By the end you'll have five values to hand to Auth in step 2: a Team ID, a Key ID, a .p8 private key, your App ID's bundle id(s), and — only if you support web — a Services ID.

Apple splits a single “Sign in with Apple” integration across three separate artifacts, which is what makes the first run confusing. Here's what each one is and why you need it:

  • App ID — the identifier for your native iOS / macOS app (e.g. com.acme.ios). You almost certainly already have one. Its bundle id is the audience Apple stamps into tokens issued to your native app.
  • Services ID — a separate identifier that represents your app on the web (e.g. com.acme.web). Apple treats web sign-in as a distinct client, so it gets its own id and its own audience. Skip this entirely if you only support native.
  • Key — a private signing key (a .p8 file) that Auth uses to authenticate itself to Apple on your behalf. One key works for both the native and web flows.

Sign in to Certificates, Identifiers & Profiles and work through the following.

1a · Enable the capability on your App ID

Open your existing App ID (under Identifiers) and turn on the Sign in with Apple capability, then save. That's the only change here. Note the bundle id — it goes into Auth as bundle_ids. If you ship a watchOS or iPad companion under the same team, each has its own bundle id and they all go in the array.

1b · Create a Services ID — web flow only

Skip this if you don't have a browser sign-in flow. Otherwise, create a new Services ID (also under Identifiers — choose “Services IDs” as the type), give it a reverse-DNS identifier like com.acme.web, and enable Sign in with Apple on it. Then configure that capability:

  • Primary App ID — point it at the App ID from step 1a.
  • Domains — add api.auth.productcraft.co.
  • Return URLs — add exactly https://api.auth.productcraft.co/<your-appSlug>/v1/auth/oauth/apple/callback. Apple does a byte-exact match: no trailing slash, no port, https only. Substitute your real app slug for <your-appSlug>.

The Services ID identifier (com.acme.web) is what you'll send to Auth as service_id.

1c · Create a Sign in with Apple key

Under Keys, create a new key, enable the Sign in with Apple capability on it, and register it. Apple then lets you download the .p8 file exactly once — download it now and store it somewhere safe; there is no second chance, and losing it means generating a new key. The contents of that file (the PEM block, including the -----BEGIN PRIVATE KEY----- lines) become private_key_pem in step 2.

1d · Collect your Team ID and Key ID

  • Team ID — a 10-character string shown in the top-right of the developer portal (also under Membership). Sent to Auth as team_id.
  • Key ID — a 10-character string shown on the key's detail page right after you created it in step 1c. Sent to Auth as key_id.

That's everything Apple-side. Nothing else gets registered with Apple as you scale — Auth handles the per-customer wiring through one config row, which you upload next.


2

Upload your Apple config in the console

Take the five values from step 1 and paste them into the console. The private key is encrypted at rest with AES-256-GCM and never appears in any API response after you save it. (Prefer scripting it? The same config is one PUT against the Auth-admin API — see the OpenAPI spec.)

In the ProductCraft console, open Auth, select the app you're wiring up, then go to Settings → Auth config. Scroll to the Federated sign-in providers card and click Configure… on the Apple row.

Fill in the modal:

  • Services ID — the Services ID identifier from step 1b (e.g. com.acme.web). Web flow only; if you're native-only you still need a value here today, so use your primary bundle id.
  • Bundle IDs — type each bundle id and press Enter to add it as a chip. Add your iOS bundle id plus any watchOS / macOS / iPad companions that share the same Apple team. At least one is required.
  • Team ID and Key ID — the two 10-character values from step 1d.
  • Private key (.p8 contents) — paste the full PEM from step 1c, including the -----BEGIN PRIVATE KEY----- / -----END PRIVATE KEY----- lines.
  • Enabled — flip this on to accept Apple sign-ins. Left off, the provider is saved but inactive and sign-in attempts return 503 until you enable it.

Click Configure to save. The card then shows the Apple row as Configured + Enabled, and the button changes to Manage for future edits.

One thing worth knowing up front: the encrypted key is never retrievable, so when you re-open Manage the private-key field is blank and editing any other field requires re-pasting your .p8 contents. To retire Apple sign-in without deleting the config, just toggle Enabled off; Remove provider… deletes the config row entirely (existing Apple users keep their accounts but can't sign in via Apple until it's re-uploaded).

Two related settings live one card below, under Federated sign-in policy:

  • Account-linking policy — what happens when an Apple sign-in lands on an email that already has an account. Defaults to confirm (safe). See section 5.
  • Allowed redirect origins — the origins the web flow accepts as return_to. Empty (the default) disables the web flow entirely. Exact origin match — scheme + host + port, no paths, no wildcards. Add yours before users hit the web flow (section 4).

3

Native iOS / macOS

Use Apple's ASAuthorizationController. Generate a random nonce locally, hash it, pass the hash to Apple, send the raw nonce + identity token to Auth.

Build a sign-in button with ASAuthorizationAppleIDButton; on tap, kick off an ASAuthorizationAppleIDRequest configured with requestedScopes = [.fullName, .email] and the SHA-256 hex of a random nonce. When Apple returns, you get an identityToken (the JWT) and — on first signin only — a fullName + email:

Swift — sign-in handler
import AuthenticationServices
import CryptoKit

final class AppleSignInCoordinator: NSObject, ASAuthorizationControllerDelegate {
  private var currentNonce: String?

  func startSignIn() {
    let raw = randomNonceString()
    currentNonce = raw

    let provider = ASAuthorizationAppleIDProvider()
    let request  = provider.createRequest()
    request.requestedScopes = [.fullName, .email]
    request.nonce = sha256(raw)

    let controller = ASAuthorizationController(authorizationRequests: [request])
    controller.delegate = self
    controller.performRequests()
  }

  func authorizationController(
    controller: ASAuthorizationController,
    didCompleteWithAuthorization auth: ASAuthorization
  ) {
    guard
      let credential = auth.credential as? ASAuthorizationAppleIDCredential,
      let identityTokenData = credential.identityToken,
      let identityToken = String(data: identityTokenData, encoding: .utf8),
      let rawNonce = currentNonce
    else { return }

    var body: [String: Any] = [
      "id_token": identityToken,
      "nonce":    rawNonce,
    ]

    // First-signin only — Apple withholds name + email on subsequent runs.
    if let givenName = credential.fullName?.givenName,
       let familyName = credential.fullName?.familyName {
      body["user"] = [
        "name": ["firstName": givenName, "lastName": familyName],
      ]
    }

    Task { try await postToAuth(body) }
  }
}

Then one POST. Auth verifies the JWT signature, pins iss = appleid.apple.com, checks the audience against your configured bundle_ids, recomputes the SHA-256 of nonce and compares to the JWT's nonce claim, claims the nonce in Redis so it can't be replayed, resolves or creates the account, and mints Auth tokens:

POST /<app_slug>/v1/auth/oauth/apple
curl https://api.auth.productcraft.co/acme/v1/auth/oauth/apple \
  -H 'content-type: application/json' \
  -d '{
    "id_token": "<the value from ASAuthorizationAppleIDCredential.identityToken>",
    "nonce":    "<the RAW nonce, NOT the hash>",
    "user":     { "name": "Jane Doe" }
  }'
{
  "access_token":  "eyJhbGciOi...",
  "refresh_token": "rt_...",
  "token_type":    "Bearer",
  "expires_in":    3600
}

Same response shape as /auth/signin — drop the tokens into your existing storage and your app code doesn't care whether the session came in through password or Apple. The amr claim on the access token is ["oauth", "apple"] if you want to branch on it.


4

Web (browser redirect)

No SDK. Two redirects + one server-to-server exchange. Tokens never travel through URLs or browser history.

Add allowed_redirect_origins to your auth config first (see step 2). Then put a link on your sign-in page that points users at Auth's authorize endpoint:

Your sign-in page
<a
  href="https://api.auth.productcraft.co/acme/v1/auth/oauth/apple/authorize?return_to=https%3A%2F%2Fapp.acme.com%2Flogin%2Fdone"
>
  Sign in with Apple
</a>

Auth 302s to Apple → the user does the Apple sheet → Apple posts the result back to Auth's callback → Auth 303s the user to https://app.acme.com/login/done?heimdall_code=<opaque>. Your frontend reads the code and forwards it to your backend, which exchanges it for tokens:

Your backend — code → tokens
async function completeAppleSignIn(code: string) {
  const res = await fetch(
    'https://api.auth.productcraft.co/acme/v1/auth/oauth/exchange',
    {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ code }),
    },
  );

  if (!res.ok) throw new Error('Exchange failed'); // 401 = unknown/expired/redeemed
  const { access_token, refresh_token, token_type, expires_in } = await res.json();
  // Drop into your session cookie / store as you'd handle /auth/signin.
}

The code is single-use, 60-second TTL, bound to theappSlug it was minted under. Once your backend exchanges it, the code is gone — even if someone intercepts the URL after the fact.

Failure modes redirect back to return_to with a typed heimdall_error query string instead of a Auth-hosted error page you can't style:

  • heimdall_error=token_invalid — signature / iss / audience / nonce check failed.
  • heimdall_error=nonce_replayed — same Apple token reaching the callback twice.
  • heimdall_error=link_required — an account with this email already exists; the user needs to sign in with their original method first. See section 5.

5

Linking policy — what happens on email collision

When an Apple sign-in lands on a verified email that already has an account in your app, three policies are available. Default is the safe one.

Set on PATCH /v1/apps/<appId>/auth-config:

  • confirm (default) — refuse the auto-link. Native flow returns 409 with { "code": "link_required" }; web flow redirects with heimdall_error=link_required. Your UI asks the user to sign in with their original method first; you then bind the Apple identity through a one-shot link-token flow (lands in a follow-up release).
  • auto — link silently when Apple says email_verified=true AND the email is not a private relay. Convenient; trades a small amount of security (you're trusting Apple's email verification) for fewer prompts.
  • reject — refuse outright with account_exists_with_different_provider. For products that don't want federated and password to mix at all.

Private-relay @privaterelay.appleid.com emails never auto-link, regardless of policy. They aren't deliverable through your channels and aren't suitable as a linking primitive.


6

Webhooks + audit

Same events you already wire for password signin, with an extra `provider` field so you can tell which path the user took.

user.signup
{
  "type": "user.signup",
  "data": {
    "user_id":  "648616c8-...",
    "username": "jane",
    "email":    "jane@example.com",
    "provider": "apple"
  }
}
user.signin
{
  "type": "user.signin",
  "data": {
    "user_id":  "648616c8-...",
    "provider": "apple",
    "linked":   false
  }
}

linked: true when the policy was auto and Auth bound the Apple identity to an existing email account. Audit events: auth.signup.success and auth.signin.success with the same provider + linked metadata.

amr claim on issued access tokens is ["oauth", "apple"]. Your backend can branch on it without an extra round-trip — for example, to require a password set-up step before allowing payment changes on a federated-only account.


7

Apple-specific gotchas

Three behaviors that surprise people the first time. None of them break sign-in, but they'll save you a half-day each.

  • The user's name only arrives once. Apple delivers fullName and email on the very first authorization for a given (user, app) pair. Subsequent signins ship neither. Persist the name on first signin or it's gone — Auth stores it on the identity row from the user payload, but only if you forward it.
  • Private-relay emails look real but aren't deliverable through you. Apple lets users hide their email behind xxxxx@privaterelay.appleid.com; mail to that address only routes if the customer's Apple-registered sender domains include yours. Your transactional email pipeline likely doesn't. Auth flags these on the identity row and refuses to auto-link them, but if you rely on email for password reset / receipts / etc., note in your UI that private relays may bounce.
  • The audience is your Services ID for web, your bundle id for native — never the other way. Auth enforces this; a web-flow token replayed against the native endpoint fails with audience does not match. If you switch a flow you'll need a different Services ID / bundle entry; the config field is plural (bundle_ids) precisely because companion / watchOS / iPad apps under one team each have their own.

8

Going beyond Apple

The abstraction is provider-agnostic. Google / Microsoft / custom OIDC will plug into the same surface.

The endpoints under /auth/oauth/:provider take the provider id as a URL segment. :provider = apple today; Google ships next. The config row shape is provider-specific (Apple wants a private key; Google wants a static client secret) but every other piece — the linking policy, the web flow, the exchange code, the audit trail, the webhook events — is the same surface across providers. Wiring a second provider on an app is one more PUT against /auth-config/providers/<id> with that provider's config shape.