Apple's docs are correct, but they're spread across three different sites and one of the rules — the one about only getting the user's name on the very first login — is hidden in a footnote. You can ship a perfectly working Apple flow and still get rejected if you don't handle that case.
If your app supports Google or Facebook login on iOS, the App Store guidelines require you to also support Sign in with Apple. The fastest way to be rejected on submission is to ship social login without Apple as an option of equal prominence.
This guide is the version I wish I had when I wired it up the first time: the actual code, the gotchas, and how to bridge it to a real backend.
The pieces you actually need
If you skip the token verification on the backend, you do not have authentication. You have a vibe.
Install the package
npx expo install expo-apple-authentication
In your app.json, list the plugin so Expo configures the entitlement at build time:
{
"expo": {
"plugins": ["expo-apple-authentication"]
}
}
The capability also has to be enabled on the App ID in your Apple Developer account. EAS Build will fail with a clear message if it isn't.
The button itself
Apple ships a system button you should use rather than rolling your own — it satisfies the prominence rule and adapts to dark mode automatically.
import * as AppleAuthentication from "expo-apple-authentication";
import { Platform } from "react-native";
export function AppleAuthButton({ onSignedIn }: { onSignedIn: (creds: AppleAuthentication.AppleAuthenticationCredential) => void }) {
if (Platform.OS !== "ios") return null;
return (
<AppleAuthentication.AppleAuthenticationButton
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.BLACK}
cornerRadius={12}
style={{ width: "100%", height: 48 }}
onPress={async () => {
try {
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
onSignedIn(credential);
} catch (error) {
if ((error as { code?: string }).code === "ERR_CANCELED") return;
throw error;
}
}}
/>
);
}
That's the easy half.
The rule that catches everyone
Apple returns the user's full name and email only on the first sign in. Every subsequent sign in returns a credential with fullName and email set to null, no matter how many times you ask.
This is documented, but a lot of teams miss it because the dev simulator behavior differs from production. The fix is simple: persist whatever you got on the first call, server-side, keyed by the user field (the stable Apple ID).
async function handleAppleSignIn(credential: AppleAuthentication.AppleAuthenticationCredential) {
const payload = {
appleUserId: credential.user,
identityToken: credential.identityToken,
// These will be null on every login after the first one — store them when present
email: credential.email ?? null,
fullName: credential.fullName
? [credential.fullName.givenName, credential.fullName.familyName].filter(Boolean).join(" ")
: null,
};
await fetch("/api/auth/apple", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
Your backend handler then:
- Verifies the identity token against Apple's public keys.
- Looks up an existing user by
appleUserId. - If new, creates a user with whatever name and email you got.
- If returning, ignores the null name/email and returns the existing record.
If you don't do step 4, returning users will eventually have a null name in your database and you'll spend an afternoon trying to figure out why.
Verifying the identity token
You verify the JWT against Apple's public keys at https://appleid.apple.com/auth/keys. Most teams use a JWT library and a small key cache.
// Node example using `jose`
import { createRemoteJWKSet, jwtVerify } from "jose";
const APPLE_JWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/keys"));
export async function verifyAppleIdentityToken(idToken: string, expectedAudience: string) {
const { payload } = await jwtVerify(idToken, APPLE_JWKS, {
issuer: "https://appleid.apple.com",
audience: expectedAudience, // your iOS bundle ID
});
return payload; // includes `sub` (the Apple user ID), `email`, etc.
}
expectedAudience is your iOS bundle identifier. If it doesn't match, Apple's docs say to reject. Listen to that.
With Supabase
Supabase has first-class Apple support. You pass the identity token straight into signInWithIdToken:
import { supabase } from "@/lib/supabase";
const { data, error } = await supabase.auth.signInWithIdToken({
provider: "apple",
token: credential.identityToken!,
});
The catch: Supabase will not capture name and email if Apple didn't return them — same rule applies. So persist them yourself in user_metadata when you have them.
With Convex
Convex doesn't have a built-in Apple provider, but the integration through Convex Auth or a custom function is straightforward — verify the identity token in a Convex action, then return a session token your client stores:
// convex/auth.ts
import { internalAction } from "./_generated/server";
import { v } from "convex/values";
import { verifyAppleIdentityToken } from "./apple";
export const signInWithApple = internalAction({
args: { idToken: v.string() },
handler: async (ctx, { idToken }) => {
const claims = await verifyAppleIdentityToken(idToken, process.env.IOS_BUNDLE_ID!);
// Upsert user, mint session, return token
// ...
},
});
Things I've gotten wrong, so you don't have to
Testing in the simulator
The simulator only signs in with the iCloud account on your Mac. You don't get the consent sheet flow you'd see on a device. For end-to-end testing, use a real device with a real Apple ID — preferably one you can clear from Settings → Apple ID → Password & Security → Apps Using Apple ID so you can re-test the first-login path.
Submission tips
- Apple checks that the Apple Sign In button is visually equivalent to other social options — same height, similar prominence.
- If you use email aliasing (
@privaterelay.appleid.com), make sure your transactional email is configured to be allowed by Apple's relay. Misconfigured DKIM is a common cause of "I never got my magic link" complaints. - The reviewer will explicitly try Apple Sign In. If it errors, you get rejected.
Where this connects to the rest of your app
If you're using Shipnative, the Apple flow is wired into the Supabase auth path along with email and Google. The bit you usually have to write — handling the first-login name capture, persisting it server-side, and matching prominence with Google in the auth screens — is already there. If you're rolling your own, the gotchas above are the same regardless of the backend you choose.
The next thing most teams hit after Apple Sign In is push notifications. That guide is in Push Notifications with React Native and Expo.
