If you're here because Apple login on your production app suddenly started failing with invalid_client — and you changed nothing — this post is for you. The fix takes 5 minutes, the permanent fix takes 10, and both are below.
The symptom
- "Sign in with Apple" on web worked fine for months, then every attempt fails with
invalid_client. - In Supabase logs it may show up as
Unable to exchange external code. - Native iOS sign-in (
signInWithIdToken) still works, which makes it even more confusing. - It usually happens almost exactly 6 months after you set Apple auth up.
The cause: Apple's "client secret" is not a secret — it's a JWT
Every other OAuth provider (Google, GitHub, Facebook…) hands you a static client_secret once, and it lives forever. Apple doesn't. With Apple you generate the client secret yourself: it's an ES256 JWT that you sign with the .p8 private key from your Apple Developer account.
And here's the trap: Apple caps the JWT's lifetime at 6 months (exp − iat ≤ 15777000 seconds). When it expires:
- Apple sends you no warning,
- your provider dashboard (Supabase, Firebase, Auth0…) shows no error,
- web sign-in just starts returning
invalid_client.
Your users find out before you do.
The quick fix (5 minutes)
- Take your
AuthKey_XXXXXXXXXX.p8file (you saved it when you created the Sign in with Apple key — it can't be re-downloaded, only re-created). - Generate a fresh client secret JWT from it. The Supabase Apple login docs include a generator tool; there are also open-source scripts.
- Paste the new secret into your provider's dashboard (for Supabase: Authentication → Providers → Apple → Secret Key).
Done — for the next 6 months. Which is exactly the problem.
The permanent fix: rotate it on a schedule
The official Supabase docs recommend — I'm not making this up — "set a recurring calendar reminder every 6 months". A calendar reminder as production infrastructure for your auth system.
I didn't trust future-me with that reminder, so I automated it with a GitHub Action: apple-client-secret-rotator. A scheduled workflow regenerates the ES256 JWT from your .p8 and updates your Supabase project via the Management API. Set it up once, forget forever:
# .github/workflows/rotate-apple-secret.yml
name: Rotate Apple client secret
on:
schedule:
- cron: "0 6 1 */5 *" # every 5 months — 1 month safety margin
workflow_dispatch: {} # manual run for the first rotation
jobs:
rotate:
runs-on: ubuntu-latest
steps:
- uses: oskar-makarov/apple-client-secret-rotator@v1
with:
apple_team_id: ${{ secrets.APPLE_TEAM_ID }}
apple_key_id: ${{ secrets.APPLE_KEY_ID }}
apple_services_id: com.example.app.web
apple_p8: ${{ secrets.APPLE_P8 }}
supabase_project_ref: your-project-ref
supabase_access_token: ${{ secrets.SUPABASE_ACCESS_TOKEN }}
Add 4 repository secrets (APPLE_TEAM_ID, APPLE_KEY_ID, APPLE_P8 — the full contents of the .p8 file — and SUPABASE_ACCESS_TOKEN), run it once manually from the Actions tab, and the cron takes it from there.
A few design notes, because you should be suspicious of anything that touches your auth keys:
-
Zero dependencies. The entire logic is one ~100-line file of pure Node built-ins (
crypto.signwithdsaEncoding: 'ieee-p1363'for JOSE-style ES256). Nothing tonpm install, nothing to audit but one file. -
Your
.p8never leaves your workflow. It lives in your own repo's GitHub Secrets and is only read on your own runner. -
The secret is masked in logs (
::add-mask::) and never printed. - Failures are loud. A bad key, a missing input or a Supabase API error fails the workflow with a clear message — you get GitHub's failed-run email instead of finding out from your users.
Not on Supabase? Output-only mode
Omit the Supabase inputs and the Action just exposes the fresh JWT as a workflow output — pipe it into Firebase, Auth0, a self-hosted GoTrue, a secrets manager, wherever:
- uses: oskar-makarov/apple-client-secret-rotator@v1
id: apple
with:
apple_team_id: ${{ secrets.APPLE_TEAM_ID }}
apple_key_id: ${{ secrets.APPLE_KEY_ID }}
apple_services_id: com.example.app.web
apple_p8: ${{ secrets.APPLE_P8 }}
- run: ./push-secret-somewhere.sh "${{ steps.apple.outputs.client_secret }}"
(If your auth lives in your own code — NextAuth, Laravel Socialite, better-auth — you may not need any of this: generate the secret dynamically on each request instead. Rotation matters when you paste the secret into someone else's dashboard.)
FAQ
Why every 5 months, not 6? Apple's hard maximum is ~6 months. A 5-month cron leaves a month of margin if a run fails or GitHub disables the schedule on an inactive repo.
Does this affect native iOS sign-in? No. Native signInWithIdToken flows don't use the client secret — that's why iOS keeps working while web breaks.
Is the expiry really silent? Yes. No email from Apple, no dashboard warning. The first signal is invalid_client in production.
I built this after hitting the 6-month wall myself. It's MIT-licensed, free, and lives on the GitHub Marketplace. Issues and PRs welcome.
Top comments (0)