# 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](https://developer.apple.com/programs/enroll/) - The app must be served over **HTTPS** in production (localhost works for dev — see note in Step 2) ## The five env vars ```bash 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](https://developer.apple.com/account). 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 1. [Certificates, IDs & Profiles → Identifiers](https://developer.apple.com/account/resources/identifiers/list) 2. Click **+** → **App IDs** → **App** → Continue 3. **Description**: `Local Cal` 4. **Bundle ID** (Explicit): `com.yourdomain.localcal` 5. Under **Capabilities** scroll to **Sign In with Apple** → check it → **Configure** → **Enable as primary App ID** → Done 6. 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. 1. **Identifiers → +** → **Services IDs** → Continue 2. **Description**: `Local Cal Web` 3. **Identifier**: `com.yourdomain.localcal.web` *(must differ from the Bundle ID)* 4. Continue → **Register** 5. Click the Services ID you just created 6. Enable **Sign In with Apple** → **Configure** - **Primary App ID**: select `com.yourdomain.localcal` (Step 2) - **Domains and Subdomains**: `yourdomain.com` *(no `https://`, no trailing slash, no path)* - **Return URLs**: ``` https://yourdomain.com/api/auth/callback/apple ``` > **localhost dev**: add `localhost` as a domain and > `http://localhost:3000/api/auth/callback/apple` as a return URL. > Apple permits plain HTTP for localhost only. > Alternatively use an HTTPS tunnel: > [ngrok](https://ngrok.com/) or [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/). 7. Done → Continue → **Save** ``` AUTH_APPLE_CLIENT_ID=com.yourdomain.localcal.web ``` --- ## Step 4 — Create a private key 1. [Keys → +](https://developer.apple.com/account/resources/authkeys/list) 2. **Key Name**: `Local Cal Sign In` 3. Check **Sign In with Apple** → **Configure** → select the App ID from Step 2 → Save 4. Continue → **Register** 5. **Download the `.p8` file immediately** — Apple only lets you download it once. Store it somewhere safe (password manager, secrets vault). The filename will be `AuthKey_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`: ```bash # .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: ```ts // 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); ``` ```bash 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 ```bash 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 ```bash 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 |