onebox

Guide 14 of 27

For your agent: .md · all guides

On this page
  1. What it is and what it costs
  2. How the flow works
  3. Steps
  4. Where the values go
  5. Check it works
  6. Common errors

Sign in with Apple

Runs on: your Mac (the app and the developer account) and your box (the server check).

This guide adds Sign in with Apple to the Expo app, verifies Apple’s token on your own server, and adds account deletion with token revocation. On a hosted backend, hosted-backend.md has the token check and the revoke step for Supabase, Convex and Firebase. The app side below is the same. It follows the flow I use in my own apps.

Before you start you need a development build on your phone (expo-app.md, step 5) and the API on the box (backend.md).

What it is and what it costs

Sign in with Apple lets a user create an account with the Apple Account that is already on their iPhone. Face ID, no password. The user can hide their real email address. It is free and part of the Apple Developer Program (apple-developer.md).

When App Review requires it

Guideline 4.8 (Login Services): if the app uses a third-party or social login (Google, Facebook and others) for the user’s main account, it must also offer an equivalent login option. That option must limit data collection to name and email, let the user keep their email private, and not collect app activity for advertising without consent. Sign in with Apple meets all three. There are exceptions (for example an app that uses only your own company’s account system), listed in the guideline.

Simplest path for a new app: make Sign in with Apple the only login.

Guideline 5.1.1(v): if the app lets users create an account, it must also let them delete the account inside the app. Apple also asks apps that use Sign in with Apple to revoke the user’s tokens through its REST API when the account is deleted. Step 6 does both.

How the flow works

App                                   Your server                        Apple
 │ make random nonce N                    │                                 │
 │ signInAsync(nonce = SHA256(N)) ──────────────────────────────────────────>│
 │ <── identityToken (JWT), authorizationCode, fullName*, email* ───────────│
 │ POST /api/auth/apple {identityToken, nonce: N, fullName}                 │
 │ ──────────────────────────────────────>│ fetch keys (cached) ───────────>│
 │                                        │ verify signature, iss, aud,     │
 │                                        │ exp, nonce == SHA256(N)         │
 │                                        │ find or create user by `sub`    │
 │ <── your own access + refresh token ───│                                 │

 * fullName and email come on the first sign-in only.

The app never trusts itself. The server never trusts the app. Only Apple’s signature decides who the user is.

Steps

1. Turn on the capability

The App ID for your bundle identifier needs the Sign in with Apple capability. app-store-connect-setup.md, step 1, shows where App IDs live.

In the app config (expo-app.md):

{
  "expo": {
    "ios": { "bundleIdentifier": "com.example.myapp", "usesAppleSignIn": true },
    "plugins": ["expo-apple-authentication"]
  }
}

When EAS manages your signing, it syncs capabilities from this config to the App ID on every build. That works both ways. A build from a directory with an empty config turns the capability off. Keep the root tripwire from expo-app.md, and read the “synced capabilities” line in the build log.

2. The button in the app

cd apps/mobile
npx expo install expo-apple-authentication expo-crypto expo-secure-store

Sign in with Apple needs a development build. In Expo Go the token is issued for Expo Go’s bundle ID, and your server rejects it (step 3).

import * as AppleAuthentication from "expo-apple-authentication";
import * as Crypto from "expo-crypto";
import { Platform } from "react-native";
import { API_URL } from "../config/api";

export async function signInWithApple() {
  if (Platform.OS !== "ios" || !(await AppleAuthentication.isAvailableAsync())) {
    throw new Error("Sign in with Apple is not available on this device.");
  }

  // A fresh nonce per sign-in. Apple signs its SHA-256 into the token.
  // The raw value goes to your server, which checks the two match.
  const nonce = Array.from(Crypto.getRandomBytes(32), (b) => b.toString(16).padStart(2, "0")).join("");
  const hashedNonce = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, nonce);

  const credential = await AppleAuthentication.signInAsync({
    requestedScopes: [
      AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
      AppleAuthentication.AppleAuthenticationScope.EMAIL,
    ],
    nonce: hashedNonce,
  });
  if (!credential.identityToken) throw new Error("Apple returned no identity token.");

  // The name arrives only on the first sign-in. Send it now or lose it.
  const fullName = [credential.fullName?.givenName, credential.fullName?.familyName]
    .filter(Boolean).join(" ") || null;

  const res = await fetch(`${API_URL}/api/auth/apple`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ identityToken: credential.identityToken, nonce, fullName }),
  });
  if (!res.ok) throw new Error(`Sign-in failed: ${res.status}`);
  return res.json(); // your server's tokens: store them with expo-secure-store
}

Use Apple’s own button, AppleAuthentication.AppleAuthenticationButton. Apple sets rules for how the button looks, and a home-made look-alike is an easy way to break them. You may pick the style (black or white) and the corner radius.

The user can cancel the Apple sheet. signInAsync then throws an error with code ERR_REQUEST_CANCELED. Treat that as “no”, not as a failure.

3. Verify the token on the server

Apple’s identity token is a signed JWT. Your server must check, every time:

  1. The signature, with Apple’s public keys from https://appleid.apple.com/auth/keys. Pick the key whose kid matches the token header. Cache the keys; fetch again only for an unknown kid.
  2. iss is exactly https://appleid.apple.com.
  3. aud is your bundle identifier, com.example.myapp. This is the check that stops a token made for another app from logging in to yours.
  4. exp is in the future.
  5. nonce in the token equals SHA-256 (hex) of the raw nonce the app sent. Refuse a request that has no nonce. For extra safety, remember used nonces for a few minutes and refuse a repeat.

Node, with the jose library:

import { createRemoteJWKSet, jwtVerify } from "jose";
import { createHash, timingSafeEqual } from "node:crypto";

const APPLE_KEYS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/keys"));

export async function verifyAppleIdentityToken(identityToken: string, rawNonce: string) {
  if (!rawNonce || rawNonce.length < 32) throw new Error("missing nonce");

  const { payload } = await jwtVerify(identityToken, APPLE_KEYS, {
    issuer: "https://appleid.apple.com",
    audience: process.env.APPLE_CLIENT_ID,   // com.example.myapp
    algorithms: ["RS256"],
  });                                        // jwtVerify also checks exp

  const expected = createHash("sha256").update(rawNonce).digest("hex");
  const actual = String(payload.nonce ?? "");
  if (actual.length !== expected.length ||
      !timingSafeEqual(Buffer.from(actual), Buffer.from(expected))) {
    throw new Error("nonce mismatch");
  }

  return { sub: payload.sub!, email: (payload.email as string | undefined) ?? null };
}

.NET (my stack), with Microsoft.IdentityModel: fetch the JSON Web Key Set, then

var parameters = new TokenValidationParameters
{
    ValidIssuer = "https://appleid.apple.com",
    ValidAudience = bundleId,                 // com.example.myapp
    IssuerSigningKeys = appleKeys.GetSigningKeys(),
    ValidAlgorithms = [SecurityAlgorithms.RsaSha256],
    ValidateLifetime = true,
};
// MapInboundClaims = false keeps "sub" as "sub". The default renames it to
// ClaimTypes.NameIdentifier, and step 4 then finds no user.
var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
var principal = handler.ValidateToken(identityToken, parameters, out _);
// then compare principal's "nonce" claim with SHA-256 hex of the raw nonce

Log why a token was refused (bad audience, expired, bad signature). Do not log the token itself. In .NET, write the log line as a [LoggerMessage] method. The strict settings refuse LogWarning(...) (CA1848, agent-test-loop.md, step 3).

4. Find or create the user by sub

5. Your own session

After the check, your server issues its own tokens: a short-lived access token (a JWT signed with JWT_SECRET_KEY) and a longer refresh token. The app stores both with expo-secure-store and sends the access token on every request. It calls POST /api/auth/refresh for a new pair, and POST /api/auth/sign-out to end the session on this device. The server side is in backend.md, “Protect the API”, step 7.

Do not use Apple’s identity token as your session. It is short-lived, and getting a new one needs the user to tap again.

6. Account deletion and token revocation

Put a “Delete account” action in the app’s settings. It must delete the account, not only sign out.

The flow:

  1. The user confirms. The app calls signInAsync again (no scopes needed) to get a fresh authorizationCode. The code is single-use and valid for five minutes, so the one from the original sign-in is useless now.
  2. The app calls DELETE /api/account with that code, using its normal access token.
  3. The server exchanges the code for tokens: POST https://appleid.apple.com/auth/token (form data) with client_id (the bundle ID), client_secret (see below), code, and grant_type=authorization_code.
  4. The server checks that the sub in the returned id_token is the user being deleted. If not, stop: the user signed in with a different Apple Account.
  5. The server revokes: POST https://appleid.apple.com/auth/revoke with client_id, client_secret, token (the refresh token) and token_type_hint=refresh_token. Apple answers 200 with no body.
  6. The server deletes the user’s data, and the RevenueCat customer if you use it (revenuecat.md).

If Apple or the network fails in steps 3 to 5, still delete the account and log the failure. The user asked for deletion; do not block it on Apple.

Deleting an account does not cancel an App Store subscription. Tell the user before they confirm, and show them how to cancel it in the iPhone’s subscription settings.

The client secret

Apple’s auth/token and auth/revoke endpoints want a client_secret that is a JWT you sign with a Sign in with Apple private key:

import { SignJWT, importPKCS8 } from "jose";

const pem = process.env.APPLE_SIGNIN_PRIVATE_KEY!.replace(/\\n/g, "\n");
const key = await importPKCS8(pem, "ES256");
const clientSecret = await new SignJWT({})
  .setProtectedHeader({ alg: "ES256", kid: process.env.APPLE_SIGNIN_KEY_ID! })
  .setIssuer(process.env.APPLE_TEAM_ID!)
  .setIssuedAt()
  .setExpirationTime("5m")
  .setAudience("https://appleid.apple.com")
  .setSubject(process.env.APPLE_CLIENT_ID!)
  .sign(key);

Make the Sign in with Apple key

  1. In your Apple Developer account, open the Keys page (under Certificates, Identifiers & Profiles).
  2. Create a new key. Enable Sign in with Apple for it, and pick your app’s App ID as its primary App ID.
  3. Download the .p8 file. Apple lets you download it only once. Note the Key ID.
  4. Put the .p8 text straight into your secrets tool as APPLE_SIGNIN_PRIVATE_KEY. Do not commit it, do not paste it into a chat.

One key can serve several apps in the same team.

This is a different key from the App Store Connect API key (app-store-connect-api-key.md). They are not interchangeable.

Where the values go

ValueSecret?Where
Bundle ID (APPLE_CLIENT_ID)nodocker-compose.yml environment:, and ios.bundleIdentifier
Team ID (APPLE_TEAM_ID)noapple.teamId in the onebox config; the API’s environment
Key ID (APPLE_SIGNIN_KEY_ID)nothe API’s environment
.p8 contents (APPLE_SIGNIN_PRIVATE_KEY)yesyour secrets tool only; listed by name in the compose environment:
JWT_SECRET_KEY (your session key)yesyour secrets tool only

Many secrets tools store a multi-line PEM with literal \n. The code above turns those back into newlines before it reads the key.

Check it works

  1. Install a development or preview build on your iPhone. Sign in.
  2. The server log shows a verified sub, and a new user row exists.
  3. Sign out and sign in again. The same user row is used. fullName and email from the app are null this time; that is expected.
  4. Delete the account in the app. The row is gone. The server log shows the revoke returned 200.
  5. On the iPhone, open the list of apps that use Sign in with Apple in your Apple Account settings. Your app is no longer there. The next sign-in asks for name and email again, like the very first one.

Step 5 is also how you test the first-sign-in path again: remove the app from that list, then sign in.

Common errors

Wrong or out of date? Fix it on GitHub.