Your app was rejected under Guideline 5.1.1(v), or you want to avoid it: if users sign in with Apple and you let them delete their account, Apple also requires you to revoke the Sign in with Apple tokens tied to that account, not just erase your own database rows. Apple's Technote TN3194 spells this out. This guide gives you the full flow with working code for Node.js, Swift, and Firebase Auth. Build the public account-deletion page reviewers also expect →
Why Apple requires token revocation on deletion
Guideline 5.1.1(v) has been enforced since June 30, 2022: any app that supports account creation must let users start account deletion from inside the app. When the account was created with Sign in with Apple, deletion is not finished until the authorization your app holds is also torn down. TN3194 is direct about it — the token revocation endpoint is "the only way to programmatically invalidate user tokens associated to your developer account without user interaction," and apps that hold a refresh token are expected to call it during deletion.
There is a user-visible reason too. Apple only sends a user's full name and email address on the very first authorization. If you delete your rows but never revoke, your app still appears in the list of apps using that person's Apple ID, and the next time they sign in Apple treats them as a returning user and withholds the name and email — so a user who deletes and later comes back hits a broken sign-up with no email address. Revoking resets that state, so a future sign-in is a clean first-time consent.
For the privacy-policy wording that goes with this feature, see our clause for Sign in with Apple; Firebase stacks should also read the Firebase Authentication clause.
The flow end to end
- On the client, ask for a fresh Sign in with Apple authorization and read the one-time authorization code.
- Send that code to your server over HTTPS. Do not revoke or delete anything on the device.
- On the server, build the client secret (a signed ES256 JWT).
- If you stored a refresh token when the user signed up, use it. Otherwise exchange the fresh code at
https://appleid.apple.com/auth/tokenfor one. - Delete the user's data from your systems.
- Call
https://appleid.apple.com/auth/revokewith that token.
Storing the refresh token at sign-up is the more reliable design: TN3194 recommends validating it with Apple up to once per day so it stays usable for a later revocation. The authorization-code path below is the fallback for apps that never stored one.
Step 1: get an authorization code (Swift / iOS)
Use ASAuthorizationAppleIDProvider to run a normal authorization request. The authorizationCode on the returned credential is Data; decode it to a UTF-8 string. It is single use and expires a few minutes after issue, so hand it to your server immediately and do not cache it.
import AuthenticationServices
final class AppleAccountDeleter: NSObject, ASAuthorizationControllerDelegate {
func start() {
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
let controller = ASAuthorizationController(authorizationRequests: [request])
controller.delegate = self
controller.performRequests()
}
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithAuthorization authorization: ASAuthorization
) {
guard
let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let codeData = credential.authorizationCode,
let authCode = String(data: codeData, encoding: .utf8)
else { return }
// Send the code to your backend over HTTPS and let the backend
// revoke and delete. Do not delete anything on the device yet.
Task {
do {
try await Backend.deleteAccount(appleAuthorizationCode: authCode)
await MainActor.run { AppSession.signOut() }
} catch {
// Surface the error and keep the account intact.
}
}
}
func authorizationController(
controller: ASAuthorizationController,
didCompleteWithError error: Error
) {
// User cancelled, or Apple returned an error. Abort the deletion.
}
}
Step 2: generate the client secret JWT (Node.js)
The client_secret Apple wants is not a static string. It is a JWT signed with the .p8 key you download from the Keys section of the Apple Developer portal, using ES256 (ECDSA with the P-256 curve and SHA-256). Per Apple's specification the claims are: iss = your 10-character Team ID, iat = now, exp = no more than 15777000 seconds (six months) ahead, aud = https://appleid.apple.com, and sub = your client_id (the app's bundle identifier for a native app, or the Services ID for a web flow). The header carries alg = ES256 and kid = the key's 10-character ID.
import { readFileSync } from "node:fs";
import { SignJWT, importPKCS8 } from "jose";
const TEAM_ID = process.env.APPLE_TEAM_ID; // 10 chars, e.g. "AB12CD34EF"
const CLIENT_ID = process.env.APPLE_CLIENT_ID; // native app: the bundle ID
const KEY_ID = process.env.APPLE_KEY_ID; // 10 chars, shown next to the key
const KEY_PATH = process.env.APPLE_KEY_PATH; // path to AuthKey_XXXXXXXXXX.p8
export async function makeClientSecret() {
const privateKey = await importPKCS8(readFileSync(KEY_PATH, "utf8"), "ES256");
const now = Math.floor(Date.now() / 1000);
return new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: KEY_ID })
.setIssuer(TEAM_ID) // iss = Team ID
.setIssuedAt(now) // iat
.setExpirationTime(now + 300) // exp; ceiling is 15777000 (6 months)
.setAudience("https://appleid.apple.com") // aud, exact string
.setSubject(CLIENT_ID) // sub = client_id
.sign(privateKey);
}
Keep the .p8 file out of your repository and load it from a secret store. A short expiry such as five minutes is fine because you mint a fresh secret per request; the six-month value is only a ceiling.
Step 3: exchange the code and revoke (Node.js)
Both calls are application/x-www-form-urlencoded POSTs. The token exchange returns access_token, refresh_token and id_token. The revoke call takes client_id, client_secret, token and an optional token_type_hint of refresh_token or access_token. Apple returns HTTP 200 with an empty body on success, or when the token was already invalid; a bad request returns 400 with an error code.
const TOKEN_URL = "https://appleid.apple.com/auth/token";
const REVOKE_URL = "https://appleid.apple.com/auth/revoke";
const CLIENT_ID = process.env.APPLE_CLIENT_ID;
function form(url, params) {
return fetch(url, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(params),
});
}
export async function exchangeCode(code, clientSecret) {
const res = await form(TOKEN_URL, {
client_id: CLIENT_ID,
client_secret: clientSecret,
grant_type: "authorization_code",
code,
});
if (!res.ok) {
throw new Error("token exchange failed: " + res.status + " " + (await res.text()));
}
return res.json(); // { access_token, refresh_token, id_token, expires_in }
}
export async function revoke(token, clientSecret, hint = "refresh_token") {
const res = await form(REVOKE_URL, {
client_id: CLIENT_ID,
client_secret: clientSecret,
token,
token_type_hint: hint,
});
// 200 with an empty body on success (or if the token was already invalid).
if (res.status !== 200) {
throw new Error("revoke failed: " + res.status + " " + (await res.text()));
}
}
export async function deleteAppleAccount(userId, appleAuthorizationCode) {
const clientSecret = await makeClientSecret();
// Prefer the refresh token stored at sign-up; fall back to the fresh code.
let refreshToken = await db.getAppleRefreshToken(userId);
if (!refreshToken && appleAuthorizationCode) {
refreshToken = (await exchangeCode(appleAuthorizationCode, clientSecret)).refresh_token;
}
// 1. Delete your own data first, and copy the token into a local
// variable (done above) so deleting the row cannot lose it.
await db.deleteUserAndData(userId);
// 2. Then revoke at Apple.
if (refreshToken) {
await revoke(refreshToken, clientSecret, "refresh_token");
}
}
Firebase Auth: revokeToken(withAuthorizationCode:)
If you authenticate with Firebase, you do not have to run the JWT and revoke plumbing yourself. Firebase iOS SDK 10.16.0 and later expose Auth.auth().revokeToken(withAuthorizationCode:), which takes the same one-time authorization code and performs the token exchange and the /auth/revoke call on Google's servers, using the Apple key you configured in the Firebase console under Authentication, Sign-in method, Apple. You still call user.delete() yourself, and because account deletion is security sensitive, Firebase requires a recent sign-in — reauthenticate first or you get requiresRecentLogin.
import FirebaseAuth
import AuthenticationServices
func deleteFirebaseUser(reauth authorization: ASAuthorization, rawNonce: String) async throws {
guard let user = Auth.auth().currentUser else { return }
guard
let credential = authorization.credential as? ASAuthorizationAppleIDCredential,
let idTokenData = credential.identityToken,
let idToken = String(data: idTokenData, encoding: .utf8),
let codeData = credential.authorizationCode,
let authCode = String(data: codeData, encoding: .utf8)
else { throw DeletionError.missingAppleCredential }
// delete() is security-sensitive: reauthenticate with a fresh sign-in
// or Firebase throws requiresRecentLogin.
let firebaseCredential = OAuthProvider.appleCredential(
withIDToken: idToken,
rawNonce: rawNonce,
fullName: nil
)
try await user.reauthenticate(with: firebaseCredential)
// Remove Firestore / Storage data you control here, before revoking.
try await Backend.purge(uid: user.uid)
// Firebase exchanges the code and calls /auth/revoke on its servers.
try await Auth.auth().revokeToken(withAuthorizationCode: authCode)
// Finally remove the Auth record.
try await user.delete()
}
The nonce must be the same raw value you hashed into the original sign-in request; reuse your existing nonce plumbing. Delete your own user data before revokeToken and delete(), for the same reason as the raw flow.
The private email relay implication
When a user picks "Hide My Email," Apple gives you a relay address such as abc123@privaterelay.appleid.com and forwards mail to their real inbox. That relay works only while your app holds a valid authorization. After you revoke, the relay for that user is torn down and further mail bounces — correct for a deleted account, but it means you must send any "your account has been deleted" confirmation email before the revoke call, not after. It is also why you should never treat the relay address as a stable long-term identifier or a way to contact a former user.
Testing
- Create a throwaway Apple ID, sign in, delete the account through your real UI, and confirm your server logs an HTTP 200 from
/auth/revoke. - Open the Sign in with Apple management screen in Apple ID settings; your app should disappear from the list of apps using that Apple ID.
- Sign in again with the same Apple ID. You should be treated as a brand-new user and see the full consent screen with name and email — proof the previous authorization was really gone.
- Test the no-token branch by clearing the stored refresh token first and letting the code exchange run.
- Watch for 400
invalid_client(wrong Team ID, key ID, or client_id in the JWT) and 400invalid_grant(expired or reused authorization code).
Common mistakes
- Revoking before deleting local data. If the revoke succeeds and your database delete then fails, the user is stranded: signed out at Apple but still present in your system. Delete your data first, then revoke.
- Wrong client_secret audience.
audmust be exactlyhttps://appleid.apple.com. A trailing slash or a path breaks it withinvalid_client. - Wrong subject.
subis theclient_id— the bundle ID for a native app or the Services ID for a web flow — not the Team ID and not the key ID. - Expired authorization code. The code from
ASAuthorizationAppleIDCredentialis valid once and only for a few minutes. Do not cache it or request it on a screen the user might leave open. - Signing with the wrong key. The .p8 must be a Sign in with Apple key with that capability enabled, not an APNs key or an App Store Connect API key.
- Assuming a 200 means the token was valid. Apple returns 200 even for an already-invalid token, so a 200 is not proof you sent the right one — confirm with the re-sign-in test above.
- Never offering the manual route. If you hold no token and no code (for example the user deletes from your website), TN3194 says to delete your data and direct the user to revoke access for your app manually in their Apple ID settings, then handle the resulting revocation notification.
Reviewer reply template
Dear App Review,
Re: Guideline 5.1.1(v) - Account deletion
Our app uses Sign in with Apple. Users can delete their account from
Settings - Account - Delete Account (screenshot attached).
On confirmation the app:
1. Runs a fresh Sign in with Apple authorization to get a one-time
authorization code.
2. Sends the code to our server over HTTPS.
3. The server deletes the user record and all associated data.
4. The server calls POST https://appleid.apple.com/auth/revoke with
client_id, client_secret (a signed ES256 JWT), token and
token_type_hint=refresh_token, per Technote TN3194.
Users who cannot open the app can also request deletion at
https://example.com/delete-account.
This ships in build 1.4.2. We can provide a screen recording on request.
Thank you,
Jane Doe
This is engineering guidance, not legal advice; confirm your own data-retention and deletion obligations for your jurisdiction separately.
Related
See the Google Play Data Safety form walkthrough for the Android side of account-deletion disclosure, the App Store account deletion requirement for the wider Guideline 5.1.1(v) picture, app SDK privacy policy clauses for the matching policy text, and the account deletion page generator for the public request page reviewers ask for.