Supercommerce API Docs
Guides & Operations

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:

ClientHow it signs inToken audience
Storefront (web)Browser redirect to Apple, back to /auth/callback/appleServices ID
iOS appNative Apple sheet, ID token to /auth/sign-in/socialApp bundle id
Android appApple's web flow in a browser tab, back through /store/auth/apple/android-callback, ID token to /auth/sign-in/socialServices 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)

  1. Under Identifiers → +, choose Services IDs. Use an identifier such as com.example.shop.web. This value becomes APPLE_CLIENT_ID.
  2. 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, and shop.example.com if you use a callback proxy.

    • Return URLs:

      ForURL
      Webhttps://api.example.com/auth/callback/apple — or, when OAUTH_CALLBACK_BASE_URL is set, ${OAUTH_CALLBACK_BASE_URL}/callback/apple
      Androidhttps://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)

  1. Under Keys → +, name the key, tick Sign in with Apple, click Configure and pick the primary App ID from 1.1.
  2. Register it and download the .p8. Apple lets you download it only once.
  3. 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:

  1. Go to Services → Sign in with Apple for Email Communication → Configure.
  2. Add the domain and the exact address the API sends from (MAIL_FROM).
  3. 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

VariableValue
APPLE_CLIENT_IDServices ID from 1.2
APPLE_APP_BUNDLE_IDENTIFIERiOS bundle id(s), comma-separated (e.g. a staging build)
APPLE_TEAM_IDTeam ID
APPLE_KEY_IDKey ID from 1.3
APPLE_PRIVATE_KEYContents of the .p8: see below
APPLE_ANDROID_PACKAGEAndroid applicationId: enables the Android return URL
APPLE_ANDROID_REDIRECT_URIOptional. 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 /auth through its own origin, OAUTH_CALLBACK_BASE_URL set, 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-methods lists under web.

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] to Runner.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-callback

The 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

SymptomCause
Apple page shows invalid_clientAPPLE_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_uriThe return URL is not registered on the Services ID exactly (scheme, host, path, no trailing slash).
Web sign-in lands on ?error=state_mismatchThe 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_codeThe client secret was rejected: wrong APPLE_TEAM_ID / APPLE_KEY_ID, or the key was revoked.
App gets INVALID_TOKENThe 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_FOUNDApple is off on the API: check the startup log for Sign in with Apple is disabled.
Android returns to the app with nothingAPPLE_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 mailSending domain/address not registered for the private relay (1.4).

On this page