Add Apple sign-in

You need: the auth capability composed, an Apple Developer account, and a verified domain.

Time: half an hour, and one thing you will have to come back to twice a year.

Why this part is manual, and harder than Google

Google sign-in is the easier one to do first. What you end up holding is a credential, and native iOS is where Apple’s own button replaces this flow.

Credentials are created in the Apple Developer portal by a human. There is no API to provision an App ID, a Services ID or a signing key on your behalf.

And there is one wrinkle Google does not have: Apple’s client secret is not a static string. It is a JWT you sign yourself, and it expires.

1. Register an App ID

Certificates, Identifiers & Profiles → Identifiers → App IDs. Register one for your iOS app and enable the Sign in with Apple capability on it.

The bundle id — com.example.myapp — is what the native iOS flow uses as the id-token audience. Note it down.

2. Create a Services ID

Identifiers → Services IDs. This is the OAuth client id for the web and redirect flow, and it is distinct from the App ID above. That distinction catches people out.

Enable Sign in with Apple on it, add your web domain, and add the return URL from step 5. Apple requires the domain be verified.

3. Create a signing key

Keys → new key, with Sign in with Apple enabled.

Download the .p8 private key. You get exactly one chance — it cannot be downloaded again.

Note two more values: the Key ID shown on the key, and your Team ID, the ten-character id in your account membership.

You now hold three things: the private key, its Key ID, and your Team ID.

4. Generate the client secret

Apple’s client secret is an ES256 JWT you sign with that private key.

ClaimValue
issYour Team ID
subYour Services ID
audhttps://appleid.apple.com
expAn expiry at most six months out

Sign it with the .p8, the Key ID in the header, and ES256. That signed JWT is the client secret.

Because the expiry caps at six months, this secret expires. Put a reminder in a calendar now rather than discovering it when sign-in stops working. The secret is stored as rotatable, so a fresh JWT replaces the old one with no config change and no code change.

5. The exact return URL

<baseURL><basePath>/callback/apple

With the default base path, /auth/callback/apple. Register one per environment on the Services ID, each on that environment’s own origin.

Apple requires https and a verified domain, so localhost generally will not work as a return URL. Dev typically uses the native iOS flow, or a tunneled https domain.

Apple sends the callback as a POST, not a redirect. Better Auth handles that; there is nothing to change.

Apple is the strictest about feature branches

Each environment needs its own registered return URL on its exact host — and Apple additionally requires that host’s domain to be verified, over https.

A preview URL is neither registered nor domain-verified, so web Apple sign-in from a preview simply cannot complete. Registering and verifying a throwaway domain per branch is impractical, so do not plan on it.

To exercise Apple on a branch, use the native iOS flow, which sends an id token straight to the Worker and needs no return URL — or run against staging.

Magic link and one-time code have no return URL and work anywhere.

6. Mobile

Native iOS uses Apple’s own flow rather than the web redirect: the app gets an identity token from Apple and sends it to the Worker. The bundle id is the audience that token is validated against, which is why it is part of the credential.

You do not register a deep link in the Apple portal. Apple returns to the Services ID’s return URL, or hands the id token straight to the app.

The app passes its own deep link as the sign-in callback, and the scheme must be listed in your trusted origins:

auth({
  trustedOrigins: ["myapp://", "https://app.example.com"],
}),

7. Store the credentials

All three values travel as one typed JSON secret:

pithy secrets create auth-apple-credentials

The Services ID as the client id, the signed JWT as the client secret, and the bundle id — which is optional, and omitted for web-only.

Then enable it, with no credential values in config:

auth({
  apple: { enabled: true },
}),

Rotating it, twice a year

Generate a fresh JWT and rotate the secret. Read sites stay byte-identical — nothing in your code knows the value changed.

The store holds both versions while anything signed under the old one drains.

Account linking, and the name that arrives once

Somebody who signed up with a magic link and later signs in with Apple is linked automatically when the verified emails match, because Apple is a trusted provider and the magic link already proved the local address.

Apple only returns the user’s name on the first authorization. It is persisted then. Wipe the user and re-authorize and the name does not come back — unless you first remove the app from that Apple ID’s signed-in apps.

There is nothing to configure. Just know it arrives once, and store it when it does.

Leave the local-verification requirement on. It blocks the takeover where an attacker pre-registers an unverified row for a victim’s address and waits for the victim’s Apple sign-in to link into it.

When the credential will not read

An enabled provider whose secret is missing or malformed costs that provider and nothing else.

Apple sign-in then answers 503 with auth/provider_unavailable, naming the provider — rather than the 404 a provider nobody enabled gets. Those are different facts and never share an answer.

Given the six-month expiry, this is the provider most likely to hit it. The audit trail is where an operator finds out.

Checklist

ESC