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:
- On your App ID, enable the “Sign in with Apple” capability.
- 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. - Create a Key with “Sign in with Apple” capability. Download the
.p8private key — Apple only lets you download it once. - 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.
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. Defaultconfirm(safe). See section 5.allowed_redirect_origins— origins the web flow accepts asreturn_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:
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:
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:
<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:
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 withheimdall_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 saysemail_verified=trueAND 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 withaccount_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.
{
"type": "user.signup",
"data": {
"user_id": "648616c8-...",
"username": "jane",
"email": "jane@example.com",
"provider": "apple"
}
}{
"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
fullNameandemailon 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 theuserpayload, 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.