unity-ios-secure-backend
Unity iOS Secure Backend (Game Center identity + App Attest)
Make an online leaderboard for a Unity iOS game un-spoofable: the server only accepts a score if (a) it came from a real Apple ID (Game Center identity verification) and (b) it came from a genuine, unmodified build of your app on a real device (App Attest), and (c) the proof is bound to the exact bytes of this request. A Node/NestJS backend verifies all three. Gameplay must stay fully playable with no Game Center and no network — security is best-effort on top of a working local game, never a gate on play.
This skill is the hard-won residue of a long debugging session. Every claim below is grounded in shipped code; the byte-level crypto details live in references/crypto-details.md.
Doctrine (these override convenience)
- The chain runs strictly in sequence, so an early failure masks every later bug. Order on the server is: parse GC headers → verify GC identity (401 stops here) → compute request hash → App Attest attestation/assertion → persist score. Until GC passed, the App Attest code never executed even once — so three real App Attest bugs (OID length, SPKI-vs-raw-point keyId, fabricated root CA) sat latent and surfaced one at a time only as each prior gate opened. Expect this. Fix the first failing stage, redeploy, and the next latent bug appears. Do not assume "the rest works" because it compiled.
- Make every stage report its EXACT reason, end to end. The server returns a typed machine code (
gc_auth_failed,request_hash_mismatch,unknown_key, …) plus diagnostic detail in the message (e.g. the server-computedclientDataHash, whether the cert nonce was found, candidate count), and the client surfaces it on-device (a HUD line, the win card). An opaque "401" or "couldn't submit" is useless across a device round-trip you can't attach a debugger to. The client also logsLastClientHash(the base64 hash it signed) so you can diff it against the server's reportedcdh. - NEVER add a production bypass for App Attest. No "if attest fails, submit anyway" path. If App Attest is unsupported/unavailable/failed, the client does NOT submit and shows a non-blocking message; the local leaderboard is untouched. A bypass is a cheat hole.
- Security never blocks gameplay. Local-first: store the score locally, then fire-and-forget the remote POST. No GC / no network / declined sign-in → outcome is
NotSignedIn/AttestUnavailable/NetworkError, all non-errors that the player never feels. In the Editor and non-iOS, every native call resolves to a clean "not available" so the game runs. - Client and server must hash the EXACT same bytes. The whole scheme collapses if the client signs bytes the server doesn't reproduce. Build the request body once, send those bytes, hash those bytes; on the server capture the raw body before JSON parsing and hash that. Never re-serialize a parsed DTO to hash it — key order/whitespace/unicode normalization will differ.
The architecture (one POST, three proofs)
Client → POST /api/scores/submit with a JSON body and these headers: