Skip to content
Tutorials
Tutorials·7 min read·May 8, 2026

Apple Sign In with React Native and Expo: A Working Guide

How to add Sign in with Apple to a React Native Expo app in 2026, including the App Store rule that catches everyone, the right Supabase or Convex token exchange, and how to handle the only-on-first-login name field.

Written by
Kaspar Noor
Apple Sign In with React Native and Expo: A Working Guide
Why this trips so many people up

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

An Apple Developer account with the Sign in with Apple capability enabled on your App ID
expo-apple-authentication, the official Expo wrapper around AuthenticationServices
A backend that can verify the identity token Apple returns
A user record where you can persist the Apple user ID (the stable identifier you actually trust) plus name and email if you got them

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:

  1. Verifies the identity token against Apple's public keys.
  2. Looks up an existing user by appleUserId.
  3. If new, creates a user with whatever name and email you got.
  4. 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

Don't render the Apple button on Android — it confuses users and Apple's auth doesn't run there anyway
Don't call signInAsync inside a useEffect — only on a user-initiated press, otherwise iOS treats it as a programmatic prompt and rejects it
Don't trust the email field as deliverable — Apple's relay addresses sometimes bounce silently if the user later disables forwarding
Don't store the identityToken long-term — it's short-lived. Verify it once, mint your own session, store the Apple user ID

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.

Ready to ship faster?

Get lifetime access to Shipnative for a one-time payment of $99.