DEV Community

Cover image for Sign in with Apple broke with invalid_client after 6 months? Here's why — and how to never deal with it again
oskar-makarov
oskar-makarov

Posted on

Sign in with Apple broke with invalid_client after 6 months? Here's why — and how to never deal with it again

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)

  1. Take your AuthKey_XXXXXXXXXX.p8 file (you saved it when you created the Sign in with Apple key — it can't be re-downloaded, only re-created).
  2. Generate a fresh client secret JWT from it. The Supabase Apple login docs include a generator tool; there are also open-source scripts.
  3. 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 }}
Enter fullscreen mode Exit fullscreen mode

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.sign with dsaEncoding: 'ieee-p1363' for JOSE-style ES256). Nothing to npm install, nothing to audit but one file.
  • Your .p8 never 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 }}"
Enter fullscreen mode Exit fullscreen mode

(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)