Heimdall guides
09 · Sign in with Apple

Sign in with Apple.

Two integrations, one backend. Native iOS / macOS posts the Apple ID token straight to Heimdall; web apps redirect through Heimdall 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. You need an Apple Developer account ($99/yr) and an App ID that already exists for your iOS app.

In Certificates, Identifiers & Profiles:

  1. On your App ID, enable the “Sign in with Apple” capability.
  2. For web apps only — create a Services ID. Configure its “Sign in with Apple” section with the return URL https://api.heimdall.productcraft.co/<your-appSlug>/v1/auth/oauth/apple/callback. Apple does an exact match — no trailing slash, no port.
  3. Create a Key with “Sign in with Apple” capability. Download the .p8 private key — Apple only lets you download it once.
  4. Note your Team ID (10 chars, top-right of the developer portal) and the Key ID (10 chars, shown alongside the .p8).

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


2

Upload your Apple config to Heimdall

One PUT against the Heimdall-admin API. Encrypts the private key at rest with the same AES-256-GCM key Heimdall uses for webhook signing secrets. After this call the key never appears in any API response again.

PUT /v1/apps/<appId>/auth-config/providers/apple
curl https://api.heimdall.productcraft.co/v1/apps/<appId>/auth-config/providers/apple \
  -X PUT \
  -H 'authorization: Bearer pcft_live_...' \
  -H 'content-type: application/json' \
  -d '{
    "config": {
      "service_id":      "com.acme.web",
      "bundle_ids":      ["com.acme.ios", "com.acme.ios.watch"],
      "team_id":         "ABC1234567",
      "key_id":          "KEY1234567",
      "private_key_pem": "-----BEGIN PRIVATE KEY-----\nMIGTAg...\n-----END PRIVATE KEY-----"
    },
    "enabled": true
  }'

Auth: any cookie or PAK carrying heimdall.update on pcft:heimdall:app/<appId>. Workspace owners get this by default; PAK lane works the same way.

The response is the redacted view — service_id, bundle_ids, team_id, key_id, private_key_present: true. The private key never round-trips. To replace it, PUT a newprivate_key_pem; to retire Apple sign-in without losing the config, PUT "enabled": false.

Two related app-level settings live on the regular auth config — PATCH /v1/apps/<appId>/auth-config:

  • oauth_link_policy — what to do when an Apple sign-in lands on an email that already has an account. Default confirm (safe). See section 5.
  • allowed_redirect_origins — origins the web flow accepts as return_to. Empty array (the default) disables the web flow entirely. Exact origin match — scheme + host + port, no paths, no wildcards. Set it before users hit the web flow.

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

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 postToHeimdall(body) }
  }
}

Then one POST. Heimdall 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 Heimdall tokens:

POST /<app_slug>/v1/auth/oauth/apple
curl https://api.heimdall.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 Heimdall's authorize endpoint:

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

Heimdall 302s to Apple → the user does the Apple sheet → Apple posts the result back to Heimdall's callback → Heimdall 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.heimdall.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 Heimdall-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 Heimdall 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 — Heimdall 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. Heimdall 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. Heimdall 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.