Sign in with Apple — setup guide
Turning on Sign in with Apple for a deployment: the Apple Developer portal setup, the API's environment (including how to pass the .p8 key), the storefront, and how the Flutter app integrates it on iOS and Android.
This guide turns on Sign in with Apple for one deployment. Follow it once per store. The HTTP surface it enables is documented in Auth — Social Sign-In.
One Apple Developer account setup serves every client:
| Client | How it signs in | Token audience |
|---|---|---|
| Storefront (web) | Browser redirect to Apple, back to /auth/callback/apple | Services ID |
| iOS app | Native Apple sheet, ID token to /auth/sign-in/social | App bundle id |
| Android app | Apple's web flow in a browser tab, back through /store/auth/apple/android-callback, ID token to /auth/sign-in/social | Services ID |
1. Apple Developer portal
You need the Account Holder or an Admin role on the Apple Developer team. Your Team ID is shown at the top right of the portal.
1.1 App ID (iOS app)
Go to Certificates, Identifiers & Profiles → Identifiers, open the app's App ID (e.g. com.example.shop), tick Sign in with Apple, choose Enable as a primary App ID, and save. Provisioning profiles must be regenerated after this.
1.2 Services ID (web + Android)
- Under Identifiers → +, choose Services IDs. Use an identifier such as
com.example.shop.web. This value becomesAPPLE_CLIENT_ID. - Open it, tick Sign in with Apple, then Configure:
-
Primary App ID: the App ID from 1.1.
-
Domains and Subdomains: the host every return URL below lives on (no scheme), e.g.
api.example.com, andshop.example.comif you use a callback proxy. -
Return URLs:
For URL Web https://api.example.com/auth/callback/apple— or, whenOAUTH_CALLBACK_BASE_URLis set,${OAUTH_CALLBACK_BASE_URL}/callback/appleAndroid https://api.example.com/store/auth/apple/android-callback(the API's public origin)
-
Apple accepts only https return URLs on a real domain, never localhost. To test locally, expose the API through an HTTPS tunnel and register that host.
1.3 Key (.p8)
- Under Keys → +, name the key, tick Sign in with Apple, click Configure and pick the primary App ID from 1.1.
- Register it and download the
.p8. Apple lets you download it only once. - Note the Key ID (10 characters, also in the file name
AuthKey_<KEYID>.p8).
The key signs in to Apple as your store. Keep it only in the API's secret store. Never commit it to a repository (the API's or the app's), and never ship it inside the app.
1.4 Email relay (Hide My Email)
Customers can hide their email behind a …@privaterelay.appleid.com address. Apple forwards mail to them only from senders you register:
- Go to Services → Sign in with Apple for Email Communication → Configure.
- Add the domain and the exact address the API sends from (
MAIL_FROM). - Make sure that domain passes SPF (and DKIM).
Without this, transactional email to these customers is silently dropped. That includes the welcome email, order updates and password resets.
2. API environment
| Variable | Value |
|---|---|
APPLE_CLIENT_ID | Services ID from 1.2 |
APPLE_APP_BUNDLE_IDENTIFIER | iOS bundle id(s), comma-separated (e.g. a staging build) |
APPLE_TEAM_ID | Team ID |
APPLE_KEY_ID | Key ID from 1.3 |
APPLE_PRIVATE_KEY | Contents of the .p8: see below |
APPLE_ANDROID_PACKAGE | Android applicationId: enables the Android return URL |
APPLE_ANDROID_REDIRECT_URI | Optional. Defaults to ${BETTER_AUTH_URL}/store/auth/apple/android-callback. Set it if the API's public URL differs. |
Apple is off unless the first four and the key are all set. If only some are set, the API logs which ones are missing and keeps Apple off.
Passing the .p8 key
Pass the key like the FCM service account (FCM_SERVICE_ACCOUNT_JSON): its contents go in an environment variable at runtime, from the deployment's secret store (the supercommerce-env secret on Kubernetes, the .env next to docker-compose.prod.yml for Compose). Do not COPY it into the Docker image or mount it into the build context. Anyone who can pull the image could then impersonate the store to Apple.
The API accepts any of these forms, so use whichever your secret store handles:
# 1. As-is (multi-line) — fine for Kubernetes secrets and most secret managers.
# 2. Newlines escaped: -----BEGIN PRIVATE KEY-----\nMIGT...\n-----END PRIVATE KEY-----
# 3. One base64 line (safest for .env files and CI variables):
base64 < AuthKey_ABC123DEFG.p8 | tr -d '\n'Apple does not take the key itself. It takes a short-lived client secret JWT signed with it, which may live at most six months. The API mints that JWT and renews it in-process, so there is nothing to rotate on a schedule. Rotate the .p8 only if it leaks: create a new key, update APPLE_KEY_ID + APPLE_PRIVATE_KEY, restart, then revoke the old key in the portal.
3. Storefront (web)
The storefront's Apple button calls authClient.signIn.social({ provider: "apple", callbackURL }) with an absolute callbackURL on the storefront's origin. Beyond the portal setup above, it needs:
- the storefront origin in
CORS_ORIGINS(it already is if Google sign-in works) - if the storefront proxies
/auththrough its own origin,OAUTH_CALLBACK_BASE_URLset, and that proxied return URL registered in 1.2 - Apple ticked for Web under Settings → Customer Sign-in. It is ticked by default. The storefront renders only the methods
GET /store/auth/sign-in-methodslists underweb.
4. Flutter app
The app uses sign_in_with_apple and crypto (for the nonce). Sessions come back as a bearer token, exactly like Google sign-in.
4.1 iOS project
- Signing & Capabilities → Sign in with Apple (adds
com.apple.developer.applesignin = [Default]toRunner.entitlements). - The bundle id must be listed in the API's
APPLE_APP_BUNDLE_IDENTIFIER.
4.2 Android project
The plugin's callback activity must be in AndroidManifest.xml:
<activity
android:name="com.aboutyou.dart_packages.sign_in_with_apple.SignInWithAppleCallback"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="signinwithapple" android:path="callback" />
</intent-filter>
</activity>The package name must equal the API's APPLE_ANDROID_PACKAGE.
Build-time values:
--dart-define=APPLE_SERVICE_CLIENT_ID=com.example.shop.web # = API's APPLE_CLIENT_ID
--dart-define=APPLE_SIGN_IN_REDIRECT_URI=https://api.example.com/store/auth/apple/android-callbackThe redirect URI must be the /store/auth/apple/android-callback route, not /auth/callback/apple. better-auth's own callback expects a browser that started sign-in on the API, and fails with state_mismatch for the app.
4.3 Getting the credential
Apple gets the SHA-256 of a nonce and the API gets the raw value. The API accepts either form, but sending the raw one proves the token was minted for this request.
final rawNonce = generateNonce();
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [AppleIDAuthorizationScopes.email, AppleIDAuthorizationScopes.fullName],
nonce: sha256.convert(utf8.encode(rawNonce)).toString(),
webAuthenticationOptions: Platform.isAndroid
? WebAuthenticationOptions(
clientId: Constants.appleServiceClientId,
redirectUri: Uri.parse(Constants.appleSignInRedirectUri),
)
: null,
);Apple returns givenName, familyName and email only on the first authorization of the app. Read the name from the two separate fields rather than splitting givenName:
final firstName = credential.givenName?.trim();
final lastName = credential.familyName?.trim();4.4 Signing in
POST {authBaseUrl}/sign-in/social (authBaseUrl = https://api.example.com/auth):
{
"provider": "apple",
"disableRedirect": true,
"idToken": {
"token": "<credential.identityToken>",
"nonce": "<rawNonce>",
"user": {
"email": "<credential.email, if present>",
"name": { "firstName": "<givenName>", "lastName": "<familyName>" }
}
}
}Omit user (or its parts) when Apple did not return them. Store token from the response and send it as Authorization: Bearer <token>, the same as the Google flow. If a guest session's bearer token is on this request, the guest's cart and orders move to the signed-in account.
4.5 Sending the authorization code (required for App Store review)
Apple requires apps that offer Sign in with Apple and account deletion to revoke the customer's Apple tokens when they delete their account. The API does the revoking, but it needs a token to revoke. So right after a successful sign-in, send the credential's one-time code, with the new session:
await ApiService.postApi(
url: '${EndPoints.domain}store/auth/apple/authorization-code',
body: {
'authorizationCode': credential.authorizationCode,
'platform': Platform.isAndroid ? 'android' : 'ios',
},
header: ApiService.appHeader(), // now carries the new Bearer token
);The code is valid for five minutes and can be used once, so send it immediately and don't retry it later. A failure here should not block the sign-in. Log it and carry on. 204 means the API stored the token. Account deletion (POST /auth/delete-user) then revokes it automatically, and the app has nothing more to do.
4.6 Showing the button
Fetch GET {domain}store/auth/sign-in-methods when the auth sheet opens, and render a button only for the methods listed under your platform (ios or android), in that order:
final platform = Platform.isIOS ? 'ios' : 'android';
final methods = List<String>.from(response['data'][platform] ?? const []);
final showApple = methods.contains('apple');
final showGoogle = methods.contains('google');The operator switches methods per platform under Settings → Customer Sign-in, and the API leaves a method out on any platform it can't serve. For example, Apple is left out on Android until APPLE_ANDROID_PACKAGE is set. If the request fails, fall back to email only rather than showing buttons that may not work.
If Google is offered on iOS, keep Apple enabled for iOS too. App Store Review Guideline 4.8 requires Sign in with Apple whenever another third-party login is offered.
5. Troubleshooting
| Symptom | Cause |
|---|---|
Apple page shows invalid_client | APPLE_CLIENT_ID is not a Services ID with Sign in with Apple enabled, or the key belongs to another team. |
Apple page shows invalid_request … redirect_uri | The return URL is not registered on the Services ID exactly (scheme, host, path, no trailing slash). |
Web sign-in lands on ?error=state_mismatch | The frontend proxies /auth but OAUTH_CALLBACK_BASE_URL is unset, or the proxied return URL isn't the one registered. |
Web sign-in lands on ?error=invalid_code | The client secret was rejected: wrong APPLE_TEAM_ID / APPLE_KEY_ID, or the key was revoked. |
App gets INVALID_TOKEN | The token's audience is not in APPLE_APP_BUNDLE_IDENTIFIER / APPLE_CLIENT_ID, the nonce doesn't match, or the token is older than an hour. |
App gets PROVIDER_NOT_FOUND | Apple is off on the API: check the startup log for Sign in with Apple is disabled. |
| Android returns to the app with nothing | APPLE_ANDROID_PACKAGE doesn't match the applicationId, or the callback activity is missing from the manifest. |
authorization-code returns 400 "different Apple ID" | The code was captured from another sign-in. Send the code from the same credential whose identity token signed in. |
| Hidden-email customers get no mail | Sending domain/address not registered for the private relay (1.4). |
Storage — Presigned Upload
Cross-role utility endpoint that mints short-lived S3 presigned URLs for direct browser uploads. Used by both the admin and vendor-admin uploaders (product photos, return-evidence…
Beautybarn Data Migration
How the @sc/seeder one-shot migrator moves customers, orders, rewards, reviews, wishlists and carts from the legacy beautybarn (Prisma/Postgres) database into supercommerce, including the same-email customer merge, password preservation (and the AUTH_PASSWORD_HASH switch for writing legacy phpass hashes), money scaling and the order-status crosswalk.