If your iOS app offers any third-party login—Google, GitHub, Facebook—Apple's App Store Review Guideline 4.8 requires you to offer Sign in with Apple as an equivalent option. That makes Apple Sign-In non-negotiable for most mobile apps, and it happens to be the most annoying OAuth provider to configure on a self-hosted Supabase instance.
The reason is simple: every other provider hands you a static client secret. Apple doesn't. Apple makes you generate your client secret—a signed JWT built from a private key—and that JWT expires after a maximum of six months. Miss the rotation and every web-based Apple login in your app breaks silently. On Supabase Cloud, the dashboard walks you through some of this. On a self-hosted instance, you're editing environment variables and restarting the auth container yourself.
This guide covers the full setup: Apple Developer portal configuration, generating the client secret JWT, wiring up GoTrue, handling native iOS versus web flows, and automating the rotation so it never bites you.
Why Apple Is Different From Every Other OAuth Provider
If you've already followed our guide to setting up OAuth providers for self-hosted Supabase, you know the usual pattern: create an app in the provider's console, copy the client ID and secret into your .env, restart the auth service, done.
Apple breaks this pattern in three ways:
- The client secret is a JWT you sign yourself. Apple gives you a
.p8private key (ES256). You use it to mint a JWT containing your Team ID, Key ID, and Services ID. That JWT is your client secret. - The secret expires. Apple caps the JWT's validity at 15,777,000 seconds (about six months). There is no "never expires" option.
- Native and web flows use different client IDs. Your iOS app authenticates with its bundle ID using the native
AuthenticationServicesflow. Web and Android use a separate Services ID through the OAuth redirect flow. GoTrue needs to know about both.
It also costs money: you need an active Apple Developer Program membership ($99/year). There's no free tier for Sign in with Apple.
Prerequisites
Before you start, make sure you have:
- A running self-hosted Supabase instance with the auth service (GoTrue) exposed over HTTPS. Apple refuses to redirect to plain HTTP or to
localhost, so a real domain with valid SSL is mandatory—if you haven't set that up, our custom domains guide covers it. - An Apple Developer Program membership with admin access.
- Access to your server's
docker-compose.ymland.envfiles.
Step 1: Configure the Apple Developer Portal
You'll create three things in the Apple Developer portal: an App ID, a Services ID, and a signing key.
App ID
Under Certificates, Identifiers & Profiles → Identifiers, create an App ID (or edit your existing one) and enable the Sign in with Apple capability. Note your bundle ID (e.g. com.yourcompany.yourapp) and your Team ID (top-right of the portal).
Services ID
Create a new identifier of type Services ID. This is the client ID for web-based flows. Convention is to use your bundle ID with a suffix, e.g. com.yourcompany.yourapp.web.
Enable Sign in with Apple on it, then configure:
- Domains: your auth domain, e.g.
auth.yourdomain.com(no protocol, no path) - Return URLs: your GoTrue callback, e.g.
https://auth.yourdomain.com/auth/v1/callback
The return URL must match exactly—Apple is stricter about this than Google or GitHub, and a mismatch produces an unhelpful invalid_request error page.
Signing key
Under Keys, create a new key with Sign in with Apple enabled and associate it with your App ID. Download the .p8 file—Apple only lets you download it once—and note the Key ID.
Store the .p8 file somewhere safe and backed up. If you lose it, you'll have to revoke the key and create a new one.
Step 2: Generate the Client Secret JWT
This is the step that trips everyone up. The client secret is an ES256-signed JWT with these claims:
| Claim | Value |
|---|---|
iss | Your Team ID |
sub | Your Services ID (com.yourcompany.yourapp.web) |
aud | https://appleid.apple.com |
iat | Now |
exp | Max ~6 months from iat |
Here's a Node.js script that generates it:
// generate-apple-secret.mjs
import { SignJWT, importPKCS8 } from 'jose';
import { readFileSync } from 'fs';
const TEAM_ID = 'YOUR_TEAM_ID';
const KEY_ID = 'YOUR_KEY_ID';
const SERVICES_ID = 'com.yourcompany.yourapp.web';
const PRIVATE_KEY = readFileSync('./AuthKey_YOURKEYID.p8', 'utf8');
const key = await importPKCS8(PRIVATE_KEY, 'ES256');
const jwt = await new SignJWT({})
.setProtectedHeader({ alg: 'ES256', kid: KEY_ID })
.setIssuer(TEAM_ID)
.setSubject(SERVICES_ID)
.setAudience('https://appleid.apple.com')
.setIssuedAt()
.setExpirationTime('180d') // Apple's max is ~6 months
.sign(key);
console.log(jwt);
Run it with node generate-apple-secret.mjs (requires npm install jose). The output string is your GOTRUE_EXTERNAL_APPLE_SECRET.
Write down the expiry date somewhere you'll actually see it. We'll come back to this.
Step 3: Configure GoTrue
Add these to your Supabase .env file:
GOTRUE_EXTERNAL_APPLE_ENABLED=true GOTRUE_EXTERNAL_APPLE_CLIENT_ID=com.yourcompany.yourapp.web GOTRUE_EXTERNAL_APPLE_SECRET=eyJraWQiOiJ... # the JWT from Step 2 GOTRUE_EXTERNAL_APPLE_REDIRECT_URI=https://auth.yourdomain.com/auth/v1/callback
Make sure your docker-compose.yml passes these variables through to the auth service, then restart it:
docker compose up -d auth
GoTrue reads configuration at startup only—editing .env without recreating the container does nothing, which is one of the most common "why isn't my provider working" mistakes we see.
Native iOS: the second client ID
If your iOS app uses the native signInWithIdToken flow (which you should—it's a far better UX than a web redirect), the ID token Apple issues has your bundle ID as its audience, not your Services ID. GoTrue will reject it with an audience error unless the bundle ID is also registered as a valid client.
Self-hosted GoTrue accepts a comma-separated list:
GOTRUE_EXTERNAL_APPLE_CLIENT_ID=com.yourcompany.yourapp.web,com.yourcompany.yourapp
List the Services ID first (it's used for the web OAuth flow), then the bundle ID. This single line resolves the Unacceptable audience in id_token error that fills Supabase's GitHub discussions.
Step 4: Client Code
Web (or any platform using the redirect flow):
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'apple',
options: { redirectTo: 'https://app.yourdomain.com/auth/callback' },
});
Native iOS (Swift, after obtaining the credential from AuthenticationServices):
let session = try await supabase.auth.signInWithIdToken( credentials: .init(provider: .apple, idToken: idToken, nonce: nonce) )
One honest caveat: Apple only returns the user's name and email on the first authorization. If you don't capture them then, they're gone—you'll need to have the user re-authorize from their Apple ID settings. Handle this in your first-login flow, not as an afterthought. And if your mobile app uses deep links to complete auth redirects, our deep linking guide for mobile authentication covers the redirect URL wiring in detail.
The Six-Month Time Bomb (And How to Defuse It)
Here's the part that makes Apple Sign-In an operations problem, not just a setup problem. That client secret JWT you generated in Step 2 expires in at most six months. When it does:
- Web-based Apple logins fail with
invalid_client. - Native iOS token-exchange flows can keep working, which makes the failure partial and confusing—your iOS users are fine while your web users are locked out.
- Nothing warns you in advance. The first signal is usually a support ticket.
Your options, in ascending order of robustness:
- Calendar reminder. Works until the one person who set it leaves the company.
- Scheduled regeneration. Run the generation script from Step 2 via cron every ~5 months, update
.env, and recreate the auth container. Script it end-to-end—a regenerated secret that never makes it into the running container is worth nothing. - Monitor the flow itself. Synthetic checks that exercise the OAuth authorize endpoint will catch an expired secret within minutes rather than days.
Whichever you choose, keep the .p8 key available to the automation—it's the one credential that doesn't expire (unless revoked), and it's the only thing you need to mint fresh secrets forever.
How Supascale Handles This
We built Supascale's OAuth provider configuration because editing env vars and bouncing containers for every credential change gets old fast—especially with a provider that forces you to change credentials twice a year.
With Supascale, Apple Sign-In configuration is a form: paste your Services ID and client secret, save, and Supascale updates the auth service configuration and restarts it for you. No SSH session, no docker compose incantations, no risk of editing .env and forgetting the restart. When rotation time comes, updating the secret is a 30-second task instead of a maintenance window. It's a one-time $39.99 purchase for unlimited projects—see pricing for details.
What Supascale doesn't do (yet): generate the client secret JWT for you. You still own the .p8 key and the six-month cadence. We'd rather be honest about that boundary than pretend the Apple Developer portal steps disappear.
Conclusion
Apple Sign-In on self-hosted Supabase comes down to five things: an App ID with the capability enabled, a Services ID with an exact-match return URL, a .p8 key you guard carefully, a self-signed JWT client secret, and a plan for regenerating that secret before Apple's six-month limit. The setup is fiddlier than any other provider, but it's entirely doable in an afternoon—and mandatory if your iOS app offers social login at all.
If you'd rather manage OAuth providers through a UI than through .env files, sign up for Supascale and configure Apple, Google, GitHub, and a dozen other providers from one dashboard.
