<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: oskar-makarov</title>
    <description>The latest articles on DEV Community by oskar-makarov (@oskarmakarov).</description>
    <link>https://dev.to/oskarmakarov</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4056769%2F1018bf2e-9598-4a1a-9439-3c52712de9fa.jpg</url>
      <title>DEV Community: oskar-makarov</title>
      <link>https://dev.to/oskarmakarov</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oskarmakarov"/>
    <language>en</language>
    <item>
      <title>Sign in with Apple broke with invalid_client after 6 months? Here's why — and how to never deal with it again</title>
      <dc:creator>oskar-makarov</dc:creator>
      <pubDate>Fri, 31 Jul 2026 14:20:16 +0000</pubDate>
      <link>https://dev.to/oskarmakarov/sign-in-with-apple-broke-with-invalidclient-after-6-months-heres-why-and-how-to-never-deal-2mhk</link>
      <guid>https://dev.to/oskarmakarov/sign-in-with-apple-broke-with-invalidclient-after-6-months-heres-why-and-how-to-never-deal-2mhk</guid>
      <description>&lt;p&gt;If you're here because Apple login on your production app suddenly started failing with &lt;code&gt;invalid_client&lt;/code&gt; — and you changed &lt;em&gt;nothing&lt;/em&gt; — this post is for you. The fix takes 5 minutes, the permanent fix takes 10, and both are below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;"Sign in with Apple" on &lt;strong&gt;web&lt;/strong&gt; worked fine for months, then every attempt fails with &lt;code&gt;invalid_client&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;In Supabase logs it may show up as &lt;code&gt;Unable to exchange external code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Native iOS sign-in (&lt;code&gt;signInWithIdToken&lt;/code&gt;) still works, which makes it even more confusing.&lt;/li&gt;
&lt;li&gt;It usually happens almost exactly 6 months after you set Apple auth up.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The cause: Apple's "client secret" is not a secret — it's a JWT
&lt;/h2&gt;

&lt;p&gt;Every other OAuth provider (Google, GitHub, Facebook…) hands you a static &lt;code&gt;client_secret&lt;/code&gt; once, and it lives forever. Apple doesn't. With Apple &lt;strong&gt;you generate the client secret yourself&lt;/strong&gt;: it's an ES256 JWT that you sign with the &lt;code&gt;.p8&lt;/code&gt; private key from your Apple Developer account.&lt;/p&gt;

&lt;p&gt;And here's the trap: Apple caps the JWT's lifetime at &lt;strong&gt;6 months&lt;/strong&gt; (&lt;code&gt;exp − iat ≤ 15777000&lt;/code&gt; seconds). When it expires:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Apple sends you &lt;strong&gt;no warning&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;your provider dashboard (Supabase, Firebase, Auth0…) shows &lt;strong&gt;no error&lt;/strong&gt;,&lt;/li&gt;
&lt;li&gt;web sign-in just starts returning &lt;code&gt;invalid_client&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Your users find out before you do.&lt;/p&gt;

&lt;h2&gt;
  
  
  The quick fix (5 minutes)
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Take your &lt;code&gt;AuthKey_XXXXXXXXXX.p8&lt;/code&gt; file (you saved it when you created the Sign in with Apple key — it can't be re-downloaded, only re-created).&lt;/li&gt;
&lt;li&gt;Generate a fresh client secret JWT from it. The &lt;a href="https://supabase.com/docs/guides/auth/social-login/auth-apple" rel="noopener noreferrer"&gt;Supabase Apple login docs&lt;/a&gt; include a generator tool; there are also open-source scripts.&lt;/li&gt;
&lt;li&gt;Paste the new secret into your provider's dashboard (for Supabase: &lt;strong&gt;Authentication → Providers → Apple → Secret Key&lt;/strong&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Done — for the next 6 months. Which is exactly the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The permanent fix: rotate it on a schedule
&lt;/h2&gt;

&lt;p&gt;The official Supabase docs recommend — I'm not making this up — &lt;em&gt;"set a recurring calendar reminder every 6 months"&lt;/em&gt;. A calendar reminder as production infrastructure for your auth system.&lt;/p&gt;

&lt;p&gt;I didn't trust future-me with that reminder, so I automated it with a GitHub Action: &lt;a href="https://github.com/oskar-makarov/apple-client-secret-rotator" rel="noopener noreferrer"&gt;&lt;strong&gt;apple-client-secret-rotator&lt;/strong&gt;&lt;/a&gt;. A scheduled workflow regenerates the ES256 JWT from your &lt;code&gt;.p8&lt;/code&gt; and updates your Supabase project via the Management API. Set it up once, forget forever:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# .github/workflows/rotate-apple-secret.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Rotate Apple client secret&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;schedule&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;cron&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;6&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*/5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;*"&lt;/span&gt;   &lt;span class="c1"&gt;# every 5 months — 1 month safety margin&lt;/span&gt;
  &lt;span class="na"&gt;workflow_dispatch&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{}&lt;/span&gt;       &lt;span class="c1"&gt;# manual run for the first rotation&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;rotate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oskar-makarov/apple-client-secret-rotator@v1&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;apple_team_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.APPLE_TEAM_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;apple_key_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.APPLE_KEY_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;apple_services_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;com.example.app.web&lt;/span&gt;
          &lt;span class="na"&gt;apple_p8&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.APPLE_P8 }}&lt;/span&gt;
          &lt;span class="na"&gt;supabase_project_ref&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;your-project-ref&lt;/span&gt;
          &lt;span class="na"&gt;supabase_access_token&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.SUPABASE_ACCESS_TOKEN }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add 4 repository secrets (&lt;code&gt;APPLE_TEAM_ID&lt;/code&gt;, &lt;code&gt;APPLE_KEY_ID&lt;/code&gt;, &lt;code&gt;APPLE_P8&lt;/code&gt; — the full contents of the &lt;code&gt;.p8&lt;/code&gt; file — and &lt;code&gt;SUPABASE_ACCESS_TOKEN&lt;/code&gt;), run it once manually from the Actions tab, and the cron takes it from there.&lt;/p&gt;

&lt;p&gt;A few design notes, because you should be suspicious of anything that touches your auth keys:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies.&lt;/strong&gt; The entire logic is one ~100-line file of pure Node built-ins (&lt;code&gt;crypto.sign&lt;/code&gt; with &lt;code&gt;dsaEncoding: 'ieee-p1363'&lt;/code&gt; for JOSE-style ES256). Nothing to &lt;code&gt;npm install&lt;/code&gt;, nothing to audit but one file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your &lt;code&gt;.p8&lt;/code&gt; never leaves your workflow.&lt;/strong&gt; It lives in your own repo's GitHub Secrets and is only read on your own runner.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The secret is masked&lt;/strong&gt; in logs (&lt;code&gt;::add-mask::&lt;/code&gt;) and never printed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failures are loud.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Not on Supabase? Output-only mode
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oskar-makarov/apple-client-secret-rotator@v1&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apple&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;apple_team_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.APPLE_TEAM_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;apple_key_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.APPLE_KEY_ID }}&lt;/span&gt;
          &lt;span class="na"&gt;apple_services_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;com.example.app.web&lt;/span&gt;
          &lt;span class="na"&gt;apple_p8&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.APPLE_P8 }}&lt;/span&gt;

      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./push-secret-somewhere.sh "${{ steps.apple.outputs.client_secret }}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(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.)&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why every 5 months, not 6?&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this affect native iOS sign-in?&lt;/strong&gt; No. Native &lt;code&gt;signInWithIdToken&lt;/code&gt; flows don't use the client secret — that's why iOS keeps working while web breaks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is the expiry really silent?&lt;/strong&gt; Yes. No email from Apple, no dashboard warning. The first signal is &lt;code&gt;invalid_client&lt;/code&gt; in production.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I built this after hitting the 6-month wall myself. It's MIT-licensed, free, and lives on the &lt;a href="https://github.com/marketplace/actions/apple-client-secret-rotator" rel="noopener noreferrer"&gt;GitHub Marketplace&lt;/a&gt;. Issues and PRs welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>oauth</category>
      <category>apple</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
