●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
Sign in with Apple in Rork: Server-Side Verification, Account Linking, and the Mandatory Deletion Flow
A Sign in with Apple button that only lives on the client tends to fray on the server before it ever fails review. This guide builds the rest on Cloudflare Workers: a server-issued nonce, ID token verification, prompt authorizationCode exchange, an account deletion flow that revokes properly, and Apple's server-to-server notifications.
The part of the nonce check that stalled me longest wasn't the cryptography. It was a plainer question: who creates the nonce in the first place? I had written server code that looked up a value the client had made up itself, asking whether it had been "issued." Read back later, the two halves never fit together.
Dropping in the button takes ten minutes. Verifying the ID token on the server, and telling Apple to let go when an account is deleted, turns it into a small authentication system. The screens Rork generates usually cover the first half, and the second half is yours to fill in. In hindsight, the thing to settle before any cryptographic detail was where each value comes from.
Here we'll use expo-apple-authentication on the client and Cloudflare Workers (Hono and D1) on the server, and walk through nonce issuance, ID token verification, authorizationCode exchange, a deletion flow that revokes, and Apple's notifications. The same ideas carry over to Vercel Edge Functions or AWS Lambda.
What review actually looks at
Rejection notes are rarely uniform, and you often end up working out for yourself what was missing. Let me pin down the relevant guidelines first. The wording gets revised, so please check the current App Store Review Guidelines before you rely on any paraphrase here.
4.8 (Login Services): If your app uses a third-party or social login, you need to offer an equivalent, privacy-minded login option alongside it. Since the January 2024 revision, the text is written around conditions rather than around Sign in with Apple by name: the service limits collection to name and email, lets users keep their email private, and doesn't track app activity for advertising without consent. Sign in with Apple is the most convenient way to meet those conditions. Apps that only use their own account system are out of scope
5.1.1(v) (Account deletion): If your app lets people create an account, it must let them delete it from inside the app. This has been mandatory since June 30, 2022, and for Sign in with Apple users it also expects you to revoke their tokens through the Sign in with Apple REST API when deletion starts. As far as I know there is no numeric rule such as "within three taps"; the standard is that the entry point is easy to find
ID token verification: Apple's documentation lays out a procedure for verifying the JWS signature and claims on your server. Trusting values the client hands you and writing them straight into your database skips that procedure
One more practical note: expo-apple-authentication is awkward to test in Expo Go and needs the native entitlement. Turn on ios.usesAppleSignIn in app.json, enable Sign in with Apple on the App ID in Apple Developer, and rebuild your development client. I've written up that rebuild in the Expo Dev Client setup guide.
The big picture: four layers, five values
Settling roles up front makes later debugging far easier.
Layer 1, the screen (React Native generated by Rork): renders the button, calls signInAsync(), and receives identityToken and authorizationCode
Layer 2, client preprocessing: fetches a nonce from the server, hashes it with SHA-256, and hands the hash to Apple
Layer 3, server verification (Workers + Hono): consumes the nonce, verifies the ID token, exchanges the authorizationCode, and issues your own session
Layer 4, deletion and notifications: revokes on account deletion and handles the server-to-server notifications Apple sends
Five values travel through those layers, and they live for different lengths of time. This table shows where each one ends up.
Value
Created by
Goes to
Lifetime and handling
raw nonce
Server
Handed to the client, returned for verification
5 minutes, single use
hashed nonce
Client
Passed to Apple, appears in the ID token's nonce claim
Not stored
identityToken
Apple
Verified on the server (signature and claims)
Discard after verifying
authorizationCode
Apple
Exchanged on the server for a refresh_token
5 minutes, single use
refresh_token
Apple (result of the exchange)
Stored encrypted in D1
Kept until you revoke it on deletion
The detail that authorizationCode expires in five minutes and works only once is easy to miss. If you try to exchange it after you've already returned the login response, it's often too late.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You'll see how to issue the nonce on the server and make it single-use, consuming it atomically with D1's DELETE ... RETURNING
✦You'll wire ID token verification (jose), the five-minute authorizationCode exchange, and encrypted refresh_token storage into one working flow
✦You'll implement the 5.1.1(v) account deletion as a two-phase delete that survives a failed revoke, and receive Apple's server-to-server notifications
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The first decision is where the nonce comes from. Put it on the server and every later check lines up naturally.
-- D1: the nonce is a single-use value that expires in 5 minutesCREATE TABLE apple_nonces ( nonce TEXT PRIMARY KEY, expires_at INTEGER NOT NULL -- UNIX seconds);CREATE TABLE users ( id TEXT PRIMARY KEY, apple_sub TEXT UNIQUE, email TEXT, display_name TEXT, apple_refresh_token_enc TEXT, status TEXT NOT NULL DEFAULT 'active', -- active | pending_deletion created_at TEXT NOT NULL);
On the consuming side, skip the "check it exists, then delete it" two-step and do both in one statement.
// src/auth/nonce.tsexport async function consumeNonce(db: D1Database, raw: string): Promise<boolean> { const now = Math.floor(Date.now() / 1000); const row = await db .prepare("DELETE FROM apple_nonces WHERE nonce = ?1 AND expires_at > ?2 RETURNING nonce") .bind(raw, now) .first(); return row !== null;}
You'll often see this stored in KV instead, but Workers KV is eventually consistent, so a get followed by a delete leaves a window in which the same nonce can pass twice. If strict single use matters to you, D1's DELETE ... RETURNING or something that serializes in one place, such as Durable Objects, is a better fit.
Step 2: The client hashes it and hands it to Apple
Take the raw nonce from the server, turn it into a SHA-256 hash, and give that to Apple. The ID token's nonce claim comes back containing the hash you passed in. The server then hashes the raw nonce it gets back the same way and compares.
// app/(auth)/sign-in-apple.tsximport * as AppleAuthentication from "expo-apple-authentication";import * as Crypto from "expo-crypto";import * as SecureStore from "expo-secure-store";import { Alert, Platform } from "react-native";import { useState } from "react";import { useRouter } from "expo-router";const API_BASE = process.env.EXPO_PUBLIC_API_BASE!;async function fetchNonce(): Promise<string> { const res = await fetch(`${API_BASE}/auth/apple/nonce`, { method: "POST" }); if (!res.ok) throw new Error("nonce_unavailable"); return ((await res.json()) as { nonce: string }).nonce;}export function AppleSignInButton() { const router = useRouter(); const [busy, setBusy] = useState(false); const handlePress = async () => { if (busy) return; setBusy(true); try { const raw = await fetchNonce(); const hashed = await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, raw, { encoding: Crypto.CryptoEncoding.HEX } ); const credential = await AppleAuthentication.signInAsync({ requestedScopes: [ AppleAuthentication.AppleAuthenticationScope.FULL_NAME, AppleAuthentication.AppleAuthenticationScope.EMAIL, ], nonce: hashed, // Apple gets the hash }); const res = await fetch(`${API_BASE}/auth/apple`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ identityToken: credential.identityToken, authorizationCode: credential.authorizationCode, rawNonce: raw, // the server gets the original back fullName: credential.fullName, // only populated on the first authorization }), }); if (!res.ok) throw new Error(`server_verification_failed:${res.status}`); const { sessionToken } = (await res.json()) as { sessionToken: string }; await SecureStore.setItemAsync("session_token", sessionToken); await SecureStore.setItemAsync("apple_user", credential.user); router.replace("/(app)/home"); } catch (e: unknown) { const code = (e as { code?: string }).code; if (code === "ERR_REQUEST_CANCELED") return; // the user just closed the sheet Alert.alert("Sign-in failed", "Please try again in a moment."); } finally { setBusy(false); } }; if (Platform.OS !== "ios") return null; return ( <AppleAuthentication.AppleAuthenticationButton buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN} buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK} cornerRadius={8} style={{ width: "100%", height: 50 }} onPress={handlePress} /> );}
I detect cancellation through code, not by matching the message text. Wording shifts between versions, while ERR_REQUEST_CANCELED is much less likely to. Generated screens also tend to pipe the raw server error into an alert, so I've replaced it with a fixed message.
Step 3: What the nonce actually protects, one level down
If you treat the nonce as "something you add," the details will eventually bite. It helps to see the attack it answers.
Suppose someone's identityToken leaks somehow. Without nonce verification, posting that token to your server is enough to log in as the victim.
What matters here is where the nonce comes from. If the client invents its own nonce, an attacker can resend the token together with the raw nonce as a pair and sail through. The server has to issue it, and burn it after one use. Only when both conditions hold can you say: this ID token was produced for a login attempt the server itself just authorized.
A nonce should be issued by the party that verifies it, and used exactly once. That line of responsibility does more work than any cryptographic flourish.
Step 4: Verify the ID token, and exchange the authorizationCode on the spot
This is the core of the server. jose verifies the signature and claims, and then we exchange the authorizationCode.
// src/auth/verify-apple.tsimport { jwtVerify, createRemoteJWKSet } from "jose";const APPLE_ISSUER = "https://appleid.apple.com";// Module scope: don't rebuild it on every requestexport const APPLE_JWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/keys"));// Native uses the Bundle ID, web or REST flows use the Service ID as audexport const ACCEPTED_AUDIENCES = ["com.example.myrorkapp", "com.example.myrorkapp.web"];export type VerifiedAppleIdentity = { sub: string; email?: string; emailVerified: boolean; isPrivateEmail: boolean;};export async function verifyAppleIdentityToken(params: { identityToken: string; rawNonce: string;}): Promise<VerifiedAppleIdentity> { // Signature, iss, aud and exp in one call const { payload } = await jwtVerify(params.identityToken, APPLE_JWKS, { issuer: APPLE_ISSUER, audience: ACCEPTED_AUDIENCES, }); // The nonce claim carries the hash the client passed, unchanged const expected = await sha256Hex(params.rawNonce); if (payload.nonce !== expected) throw new Error("nonce_mismatch"); if (typeof payload.sub !== "string" || payload.sub.length === 0) { throw new Error("missing_sub"); } return { sub: payload.sub, email: typeof payload.email === "string" ? payload.email : undefined, emailVerified: payload.email_verified === true || payload.email_verified === "true", isPrivateEmail: payload.is_private_email === true || payload.is_private_email === "true", };}async function sha256Hex(input: string): Promise<string> { const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); return [...new Uint8Array(hash)].map((b) => b.toString(16).padStart(2, "0")).join("");}
Keep aud as an array from the start. When you add web sign-in later, you won't find out the Service ID is being rejected only after users complain.
On key fetching: createRemoteJWKSet holds on to the key set it fetched and refetches only when it sees an unknown kid, subject to a cooldown. The defaults differ between versions, so please check the README for the version you've installed. I'd rather let this standard behavior do the work than hand-roll a key cache. On Workers, state is per isolate, so assume a cold start will go fetch the keys again.
Next comes the authorizationCode exchange. If you don't obtain the refresh_token here, you have nothing to revoke when the user deletes their account.
// src/auth/apple-client.tsimport { SignJWT, importPKCS8 } from "jose";export async function buildAppleClientSecret(p: { teamId: string; // 10-character Team ID clientId: string; // Bundle ID for native, Service ID for web keyId: string; // Key ID of a key with Sign in with Apple enabled privateKeyPkcs8: string; // contents of the .p8 (PEM)}): Promise<string> { const now = Math.floor(Date.now() / 1000); const key = await importPKCS8(p.privateKeyPkcs8, "ES256"); return await new SignJWT({}) .setProtectedHeader({ alg: "ES256", kid: p.keyId }) .setIssuer(p.teamId) .setIssuedAt(now) .setExpirationTime(now + 300) // generated per call, so short is fine .setAudience("https://appleid.apple.com") .setSubject(p.clientId) .sign(key);}export async function exchangeAuthorizationCode(p: { code: string; clientId: string; clientSecret: string;}): Promise<string> { const res = await fetch("https://appleid.apple.com/auth/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: p.clientId, client_secret: p.clientSecret, code: p.code, grant_type: "authorization_code", }), }); if (!res.ok) throw new Error(`apple_token_exchange_failed:${res.status}`); return ((await res.json()) as { refresh_token: string }).refresh_token;}export async function revokeAppleToken(p: { refreshToken: string; clientId: string; clientSecret: string;}): Promise<void> { const res = await fetch("https://appleid.apple.com/auth/revoke", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: p.clientId, client_secret: p.clientSecret, token: p.refreshToken, token_type_hint: "refresh_token", }), }); if (!res.ok) throw new Error(`apple_revoke_failed:${res.status}`);}
I build the client_secret fresh on every request. You can set an expiry of up to six months, but a pre-made one stashed in an environment variable will expire one day and take both the exchange and the revoke down with invalid_client. Signing is cheap, so generating per call removes expiry management from your job entirely.
The refresh_token gets encrypted before it's stored. Keep a 32-byte key as a base64 Workers secret and wrap the token with AES-GCM.
// src/auth/secret-box.tsasync function importKey(base64Key: string) { const raw = Uint8Array.from(atob(base64Key), (ch) => ch.charCodeAt(0)); return crypto.subtle.importKey("raw", raw, "AES-GCM", false, ["encrypt", "decrypt"]);}export async function encryptSecret(plain: string, base64Key: string): Promise<string> { const key = await importKey(base64Key); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = new Uint8Array( await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, new TextEncoder().encode(plain)) ); const out = new Uint8Array(iv.length + ct.length); out.set(iv); out.set(ct, iv.length); return btoa(String.fromCharCode(...out));}export async function decryptSecret(boxed: string, base64Key: string): Promise<string> { const key = await importKey(base64Key); const all = Uint8Array.from(atob(boxed), (ch) => ch.charCodeAt(0)); const plain = await crypto.subtle.decrypt( { name: "AES-GCM", iv: all.slice(0, 12) }, key, all.slice(12) ); return new TextDecoder().decode(plain);}
Step 5: Creating the user, and untangling "email only comes once"
The common line about Sign in with Apple is that email and name come only once. Here is how I'd break that down.
credential.fullName and credential.email are populated only when that authorization is the first one. If you miss the name, the same route won't give it to you again
The email claim inside the ID token, on the other hand, often shows up again on later sign-ins. It can depend on the state of the Apple ID and the scopes you requested, so I wouldn't assume it's always there. Please try it once with your own test Apple ID
An email sent from the client has not passed any verification. The server trusts only verified claims and never writes the client's value to the database
A name is display data, not something to verify. Accepting it from the client is fine, as long as creation still works when it's empty.
// src/routes/auth-apple.tsimport { Hono } from "hono";import { consumeNonce } from "../auth/nonce";import { verifyAppleIdentityToken } from "../auth/verify-apple";import { buildAppleClientSecret, exchangeAuthorizationCode } from "../auth/apple-client";import { encryptSecret } from "../auth/secret-box";import { issueSessionToken } from "../auth/session";type Env = { DB: D1Database; APPLE_TEAM_ID: string; APPLE_KEY_ID: string; APPLE_PRIVATE_KEY_P8: string; TOKEN_BOX_KEY: string;};const CLIENT_ID = "com.example.myrorkapp"; // native Bundle IDconst app = new Hono<{ Bindings: Env }>();app.post("/auth/apple", async (c) => { const body = await c.req.json<{ identityToken: string; authorizationCode: string; rawNonce: string; fullName?: { givenName?: string | null; familyName?: string | null } | null; }>(); // 1) Consume the nonce atomically (401 if absent) if (!(await consumeNonce(c.env.DB, body.rawNonce))) { return c.json({ error: "nonce_invalid" }, 401); } // 2) Verify the ID token let identity; try { identity = await verifyAppleIdentityToken({ identityToken: body.identityToken, rawNonce: body.rawNonce, }); } catch { return c.json({ error: "token_invalid" }, 401); } const existing = await c.env.DB.prepare( "SELECT id, email, status, apple_refresh_token_enc FROM users WHERE apple_sub = ?1" ) .bind(identity.sub) .first<{ id: string; email: string | null; status: string; apple_refresh_token_enc: string | null }>(); if (existing?.status === "pending_deletion") { return c.json({ error: "account_deleting" }, 403); } // 3) authorizationCode lives 5 minutes and works once: exchange it here and now const needsToken = !existing || !existing.apple_refresh_token_enc; let refreshTokenEnc: string | null = null; if (needsToken) { try { const clientSecret = await buildAppleClientSecret({ teamId: c.env.APPLE_TEAM_ID, clientId: CLIENT_ID, keyId: c.env.APPLE_KEY_ID, privateKeyPkcs8: c.env.APPLE_PRIVATE_KEY_P8, }); const refreshToken = await exchangeAuthorizationCode({ code: body.authorizationCode, clientId: CLIENT_ID, clientSecret, }); refreshTokenEnc = await encryptSecret(refreshToken, c.env.TOKEN_BOX_KEY); } catch { // For a new user, stop here so we never create an account we cannot revoke later if (!existing) return c.json({ error: "token_exchange_failed" }, 502); } } let userId: string; if (existing) { userId = existing.id; if (refreshTokenEnc) { await c.env.DB.prepare("UPDATE users SET apple_refresh_token_enc = ?1 WHERE id = ?2") .bind(refreshTokenEnc, userId) .run(); } if (!existing.email && identity.email) { await c.env.DB.prepare("UPDATE users SET email = ?1 WHERE id = ?2") .bind(identity.email, userId) .run(); } } else { userId = crypto.randomUUID(); const displayName = [body.fullName?.givenName ?? "", body.fullName?.familyName ?? ""] .filter(Boolean) .join(" ") .trim(); await c.env.DB.prepare( `INSERT INTO users (id, apple_sub, email, display_name, apple_refresh_token_enc, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)` ) .bind( userId, identity.sub, identity.email ?? null, // verified claim only displayName || null, refreshTokenEnc, new Date().toISOString() ) .run(); } return c.json({ sessionToken: await issueSessionToken(c.env, userId) });});export default app;
Make apple_sub the unique key and let email be empty. Those two choices are the crux. For the same Apple ID and the same Team, sub is a stable value. An email address can move when the private relay changes or when the Apple ID is edited, so it makes a poor permanent key.
Step 6: Account deletion, built so a failed revoke doesn't strand anyone
To dodge a 5.1.1(v) rejection, a delete button and a delete API would be enough. If you use Sign in with Apple, though, releasing the link on Apple's side belongs in the same unit. Without it, the app you "deleted" stays listed under Sign-In & Security, then Sign in with Apple, in the user's Apple ID settings.
The awkward case is when the revoke fails. Delete locally first and Apple's side lingers. Revoke first and delete only after success, and a failure leaves the user waiting. So I split deletion into two phases.
When the request arrives, set status to pending_deletion and stop sign-in and sessions right away
Try the revoke, and on success purge the row from the database. On failure, leave it as pending_deletion and retry on a schedule with Cron Triggers
// src/routes/account-delete.tsimport { decryptSecret } from "../auth/secret-box";import { buildAppleClientSecret, revokeAppleToken } from "../auth/apple-client";export async function tryRevokeAndPurge(env: Env, userId: string): Promise<boolean> { const row = await env.DB.prepare( "SELECT apple_refresh_token_enc FROM users WHERE id = ?1" ) .bind(userId) .first<{ apple_refresh_token_enc: string | null }>(); if (row?.apple_refresh_token_enc) { try { const refreshToken = await decryptSecret(row.apple_refresh_token_enc, env.TOKEN_BOX_KEY); const clientSecret = await buildAppleClientSecret({ teamId: env.APPLE_TEAM_ID, clientId: CLIENT_ID, keyId: env.APPLE_KEY_ID, privateKeyPkcs8: env.APPLE_PRIVATE_KEY_P8, }); await revokeAppleToken({ refreshToken, clientId: CLIENT_ID, clientSecret }); } catch (e) { console.error("apple_revoke_failed", String(e)); // never log the token itself return false; } } await env.DB.prepare("DELETE FROM users WHERE id = ?1").bind(userId).run(); return true;}app.post("/account/delete", async (c) => { const userId = c.get("userId"); await c.env.DB.prepare("UPDATE users SET status = 'pending_deletion' WHERE id = ?1") .bind(userId) .run(); const done = await tryRevokeAndPurge(c.env, userId); // Even on 202, it is fine to tell the user "your deletion request was received" return c.json({ ok: true, purged: done }, done ? 200 : 202);});// Called from a wrangler Cron Triggerexport async function retryPendingDeletions(env: Env) { const { results } = await env.DB.prepare( "SELECT id FROM users WHERE status = 'pending_deletion' LIMIT 50" ).all<{ id: string }>(); for (const r of results) await tryRevokeAndPurge(env, r.id);}
When the revoke returns invalid_client, suspect the combination of the key you signed with, the Team ID, and clientId (Bundle ID or Service ID). A key that doesn't have Sign in with Apple enabled can also be the cause.
Step 7: Hearing about changes that happen on Apple's side
Users can unlink your app without going through it, by opening Sign in with Apple in the iPhone settings and stopping the app there. If your server never hears about it, that refresh_token just sits there, no longer usable.
If you register a server-to-server notification endpoint in Apple Developer, events such as a change to email forwarding, a consent revocation, or an Apple ID deletion arrive as a signed JWT.
// src/routes/webhooks-apple.tsimport { jwtVerify } from "jose";import { APPLE_JWKS, ACCEPTED_AUDIENCES } from "../auth/verify-apple";app.post("/webhooks/apple/siwa", async (c) => { const { payload } = await c.req.json<{ payload: string }>(); const { payload: claims } = await jwtVerify(payload, APPLE_JWKS, { issuer: "https://appleid.apple.com", audience: ACCEPTED_AUDIENCES, }); // events is a JSON string; confirm the exact shape against a real delivery const event = JSON.parse(claims.events as string) as { type: string; sub: string }; if (event.type === "consent-revoked" || event.type === "account-delete") { await c.env.DB.prepare( "UPDATE users SET apple_refresh_token_enc = NULL WHERE apple_sub = ?1" ) .bind(event.sub) .run(); // Invalidate that user's sessions here (to match your own session design) } return c.body(null, 200);});
What to do with the data after an account-delete event depends on your privacy policy. Discarding the token and stopping sessions is common to every design.
The app can help too, by checking the state at each launch. It uses the apple_user value saved in Step 2.
import * as AppleAuthentication from "expo-apple-authentication";import * as SecureStore from "expo-secure-store";export async function ensureAppleCredentialStillValid(): Promise<boolean> { const user = await SecureStore.getItemAsync("apple_user"); if (!user) return true; // signed in some other way const state = await AppleAuthentication.getCredentialStateAsync(user); const S = AppleAuthentication.AppleAuthenticationCredentialState; return state === S.AUTHORIZED; // REVOKED / NOT_FOUND means sign out}
Step 8: Living alongside email and other social logins
Almost no app ships with Sign in with Apple as the only option. Once email and password, or Google, sit next to it, the design turns on how you treat "the same person arriving through both paths."
I don't auto-merge accounts just because the email matches. Apple's private relay returns a different address, so the same person disagreeing with themselves on email is structural. If you do merge, put an explicit "yes, this is my account" confirmation in front of it, which keeps wrong merges down.
CREATE TABLE auth_providers ( user_id TEXT NOT NULL, provider TEXT NOT NULL, -- 'apple' | 'google' | 'password' provider_user_id TEXT NOT NULL, -- Apple or Google sub created_at TEXT NOT NULL, PRIMARY KEY (provider, provider_user_id));
With several providers hanging off one user, a request like "drop Apple and keep only Google" becomes adding and removing rows. If you adopt this table, note that it overlaps with users.apple_sub from Step 5, so decide up front which one is the source of truth.
Step 9: Keep the shape of failures
Authentication bugs are painful because they leave nothing behind. A user says "I can't sign in," and your logs show nothing. Before shipping, add structured logging at four points.
// src/auth/observability.tstype AppleAuthEvent = | { stage: "nonce"; outcome: "ok" | "invalid" } | { stage: "jwt_verify"; outcome: "ok" | "bad_signature" | "wrong_audience" | "expired" } | { stage: "token_exchange"; outcome: "ok" | "failed" } | { stage: "user_resolve"; outcome: "existing" | "new" };export function logAppleAuth(event: AppleAuthEvent, env: { LOG_SINK: Queue }) { // Never log the raw nonce or the ID token; record only the stage and the outcome env.LOG_SINK.send({ ts: new Date().toISOString(), flow: "apple_auth", ...event });}
What you keep is only which stage failed and how. The values of tokens and nonces stay out deliberately, so credentials never land in your log storage. An alert on a jump in bad_signature also helps you notice a key change and a stale cache early.
Step 10: Scenarios to run before you ship
Some behavior only shows up on TestFlight or a real device, so "it worked in the simulator" is an entrance, not an exit. Run these five in order before release.
Sign in with a never-before-used Apple ID (with email hidden) and confirm a user is created with an empty email
Sign out, sign back in with the same Apple ID, and confirm the user resolves by sub with no duplicate
Run account deletion and confirm the row is gone from your database and the app has disappeared from the Sign in with Apple list on the device
Sign in again with the same Apple ID after deletion and confirm a new user is created. The sub is the same value, but the authorization counts as a first one again
Make the revoke fail on purpose (break the key temporarily) and confirm the row stays pending_deletion and the Cron retry carries it through to deletion
Step 11: How the screen should look for 4.8
One last note, about the screen rather than the code. Apple's design guidelines ask that the Sign in with Apple button sit at about the same size and prominence as your other sign-in buttons. The points I understand reviewers to notice most are these.
Give it a container the same size as the others and a position that reads as equal priority. Tucking it small at the bottom is something to avoid
Use AppleAuthenticationButton instead of drawing your own, and pick the label from the wording Apple provides (sign in, continue, sign up)
An app that offers only email and password is outside 4.8. If you add Google later, though, you'll need to revisit the order on the login screen
When you ask Rork to generate the screen, one added sentence such as "place it next to the other login buttons at the same height" saves some rework.
What matters once you're in operation
The implementation for passing review ends here. What follows are the things that start to matter in operation, kept without numbers. Figures shift with devices and with where your Worker runs, so measuring them in your own logs is the reliable route.
Key rotation arrives unannounced: I avoid code that assumes the key cache will hit. Leave it to the standard behavior as in Step 4 and catch increases in bad_signature with an alert. Refetching in an unconditional loop can also get you rejected by Apple
Generate client_secret per call: The day a pre-made JWT expires, the exchange and the revoke stop at the same time. Because the cause doesn't look like it lives in the app, tracing it tends to eat time
Delete in two phases: Trying to finish in one transaction produces an inconsistency whenever the revoke fails, where Apple's link remains and only your user data is gone
Private relay email arrives only from registered senders: Register your sending domains or addresses under "Sign in with Apple for Email Communication" in Apple Developer, and set up SPF and the rest to match. Duplicate important notices as in-app messages for the times email doesn't land
Common mistakes
These are the spots where I've seen many people's hands stop.
Saving the email the client sent as-is: An unverified value can be forged. Use only verified claims from the ID token
Making the nonce on the client alone and feeling verified: Without issuing and consuming it on the server, the protection against replay gets thin
Exchanging the authorizationCode after returning the login response: It expires in five minutes and works once. Exchange it before you respond
Looking users up by email instead of apple_sub: Email can move, so it can't be a permanent key
Stashing client_secret in an environment variable ahead of time: On the day it expires, the exchange and the deletion stop together
The one thing to do next
With all of this in place, review findings tied to Sign in with Apple should drop sharply. Trying to polish everything at once tends to stall, though. Start by running one round trip with a test Apple ID: sign in, delete, and watch the app vanish from the list in Settings. The delete-and-revoke pair is the part most likely to draw a finding and the hardest to check, and I try to harden it first.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.