AppleSiwaServer
The server half of Sign in with Apple on Android, written down because there is no client-only path and pretending otherwise is how this ships broken.
Why a server is unavoidable. Apple forces response_mode=form_post whenever scope is requested (name/email). form_post means Apple sends an HTTP POST with a form body to the redirect_uri. An Android App Link cannot receive a POST — the intent system delivers a GET-shaped VIEW intent and the body is dropped. So redirect_uri must point at a server endpoint that:
accepts
POSTwithapplication/x-www-form-urlencoded;reads
code,id_token,state, anduser(first authorization only) from the body;verifies
stateagainst the one it issued — this is the CSRF check, and the client's second check in AppleWebFlow.complete is a belt, not the braces;replies
302to your app link, e.g.https://yourdomain.example/auth/apple/callback?code=…&id_token=…&state=…&user=…, percent-encoding every value;ideally exchanges the code server-side and bounces only an opaque session handle, so the Apple
codenever rides through a URL an installed browser extension could read.
Skipping the scope request is the one escape hatch: with no scope, Apple allows response_mode=query and will 302 straight to an app link, no server. You then never learn the user's name or email — and Apple only ever sends the name once, on first authorization, so "we'll ask for it later" does not exist. AppleSignInConfig supports both; the empty-scope form is honest about what it gives up.
The client secret. Token exchange and revocation authenticate with a JWT signed ES256 by the .p8 key (APPLE_SIWA_KEY_ID), iss = APPLE_TEAM_ID, sub = APPLE_SERVICES_ID. Apple caps its lifetime at 6 months, so a rotation job is mandatory — the failure mode is every sign-in breaking at once, six months after a launch nobody remembers. The .p8 is downloadable exactly once, is a private key, and must never enter this repo; it belongs in a secret manager.