6.4 KiB
Apple OAuth Setup
Reference for when the Apple Developer account is ready. No code changes needed — just fill in the five env vars and restart.
What you need
- An Apple Developer Program membership ($99/yr) — enroll here
- The app must be served over HTTPS in production (localhost works for dev — see note in Step 2)
The five env vars
AUTH_APPLE_CLIENT_ID= # Services ID identifier (Step 2)
AUTH_APPLE_CLIENT_SECRET= # Pre-generated JWT (Step 4)
AUTH_APPLE_TEAM_ID= # 10-char team identifier (Step 1)
AUTH_APPLE_KEY_ID= # 10-char key identifier (Step 3)
AUTH_APPLE_PRIVATE_KEY= # Contents of the .p8 file (Step 3)
All five must be non-empty or the Apple button won't appear. Set any subset to empty to disable the provider without touching code.
Step 1 — Note your Team ID
Sign in to developer.apple.com.
Your Team ID is the 10-character string shown in the top-right corner
next to your name (e.g. ABCD123456).
AUTH_APPLE_TEAM_ID=ABCD123456
Step 2 — Create an App ID
- Certificates, IDs & Profiles → Identifiers
- Click + → App IDs → App → Continue
- Description:
Local Cal - Bundle ID (Explicit):
com.yourdomain.localcal - Under Capabilities scroll to Sign In with Apple → check it → Configure → Enable as primary App ID → Done
- Continue → Register
Step 3 — Create a Services ID (the OAuth client ID)
This is what identifies the web sign-in flow — separate from the App ID above.
- Identifiers → + → Services IDs → Continue
- Description:
Local Cal Web - Identifier:
com.yourdomain.localcal.web(must differ from the Bundle ID) - Continue → Register
- Click the Services ID you just created
- Enable Sign In with Apple → Configure
- Primary App ID: select
com.yourdomain.localcal(Step 2) - Domains and Subdomains:
yourdomain.com(nohttps://, no trailing slash, no path) - Return URLs:
https://yourdomain.com/api/auth/callback/applelocalhost dev: add
localhostas a domain andhttp://localhost:3000/api/auth/callback/appleas a return URL. Apple permits plain HTTP for localhost only. Alternatively use an HTTPS tunnel: ngrok or Cloudflare Tunnel.
- Primary App ID: select
- Done → Continue → Save
AUTH_APPLE_CLIENT_ID=com.yourdomain.localcal.web
Step 4 — Create a private key
- Keys → +
- Key Name:
Local Cal Sign In - Check Sign In with Apple → Configure → select the App ID from Step 2 → Save
- Continue → Register
- Download the
.p8file immediately — Apple only lets you download it once. Store it somewhere safe (password manager, secrets vault). The filename will beAuthKey_XXXXXXXXXX.p8.
The Key ID (AUTH_APPLE_KEY_ID) is shown on the key detail page.
AUTH_APPLE_KEY_ID=ABC1234567
Paste the full contents of the .p8 file into AUTH_APPLE_PRIVATE_KEY:
# .env.local — wrap multi-line value in double quotes
AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg...
-----END PRIVATE KEY-----"
On deployment platforms (Vercel, Railway, Fly.io) paste the value as-is — they handle multi-line secrets natively without quotes.
Step 5 — Generate the client secret JWT
Apple does not use a static client secret. Instead you sign a short-lived JWT with the private key. It is valid for up to 180 days.
Run this once with Bun:
// scripts/gen-apple-secret.ts
import { importPKCS8, SignJWT } from "jose";
import { readFileSync } from "fs";
const TEAM_ID = "ABCD123456"; // Step 1
const CLIENT_ID = "com.yourdomain.localcal.web"; // Step 3
const KEY_ID = "ABC1234567"; // Step 4
const PRIVATE_KEY = readFileSync("./AuthKey_ABC1234567.p8", "utf8");
const key = await importPKCS8(PRIVATE_KEY, "ES256");
const now = Math.floor(Date.now() / 1000);
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: KEY_ID })
.setIssuer(TEAM_ID)
.setSubject(CLIENT_ID)
.setAudience("https://appleid.apple.com")
.setIssuedAt(now)
.setExpirationTime(now + 180 * 24 * 60 * 60)
.sign(key);
console.log(jwt);
bun run scripts/gen-apple-secret.ts
Copy the printed JWT:
AUTH_APPLE_CLIENT_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6Ii...
Set a reminder to regenerate ~5 months from now. When the JWT expires the Apple button will silently fail at sign-in.
Completed .env.local block
AUTH_APPLE_CLIENT_ID=com.yourdomain.localcal.web
AUTH_APPLE_CLIENT_SECRET=eyJhbGciOiJFUzI1NiIsImtpZCI6Ii...
AUTH_APPLE_TEAM_ID=ABCD123456
AUTH_APPLE_KEY_ID=ABC1234567
AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg...
-----END PRIVATE KEY-----"
Restart the dev server — the Apple button appears automatically.
Verifying without a device
bun test tests/auth.test.ts
The tests mock env vars and confirm the Apple provider registers correctly. A passing suite means the wiring is correct before you touch the portal.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Apple button missing | Any of the 5 vars is empty | All five must be non-empty |
invalid_client |
JWT expired or signed with wrong key | Regenerate AUTH_APPLE_CLIENT_SECRET (Step 5) |
invalid_grant |
CLIENT_ID is the Bundle ID, not the Services ID |
Use com.yourdomain.localcal.web (Step 3) |
| Redirect fails in dev | Localhost not registered as a domain | Add localhost + http://localhost:3000/api/auth/callback/apple in Step 3 |
| Redirect fails in prod | Domain/return URL not registered | Re-check the Services ID configuration in Step 3 |
| Name is blank after sign-in | Apple only sends the user's name on the first sign-in | Expected — store the name on that first callback |
| Email is a relay address | User chose "Hide My Email" | Expected — treat it as canonical, it routes to their real inbox |