Raw Markdown for agents: https://speed.so/docs/agents/Apple-Sign-In.md
Sign in with Apple
This guide covers the Speed-specific parts of adding Sign in with Apple to an iOS app backed by Speed. It assumes you know how to use AuthenticationServices; it does not repeat Apple's documentation.
Why you need it
App Store Review Guideline 4.8 requires that an app offering a third-party login service also offer an equivalent option that lets users hide their email address. "Continue with Speed" counts as a third-party login service, so if you offer it, you must also offer Sign in with Apple.
Guideline 5.1.1(v) separately requires in-app account deletion. See Account deletion.
Two kinds of account
Your login screen offers two options, and they produce different kinds of Speed account:
| Sign in with Apple | Continue with Speed | |
|---|---|---|
| Flow | Native ASAuthorizationController | ASWebAuthenticationSession → dash.speed.so |
| Account | App-local — usable only in your app | Regular Speed account, shared across all Speed apps |
| Balance | App-local, with an optional linked Speed balance | Shared across all Speed apps |
Apple scopes its user identifier to your developer team, so a user signing in with Apple in your app cannot be recognised as the same person in any other Speed app. That is Apple's design, not a Speed limitation. Users who want a shared account can link their app-local account to a regular Speed account.
App-local accounts can use every platform API your app calls on their behalf. They cannot own Speed apps or sign in to dash.speed.so.
They start with no balance and cannot top up from the Speed dashboard. An app using the Local usage source can add app-local balance after independently processing a purchase or gift on its backend. See Apple In-App Purchases. The underlying Speed resource cost is charged to the app owner's Speed subscription and balance. A linked regular Speed account can be used as a fallback when its owner has granted access to the app.
Setup
In your Apple Developer account: Certificates, Identifiers & Profiles → Identifiers → select your App ID → enable Sign in with Apple → Save.
Wildcard App IDs are not supported. Changing capabilities invalidates existing provisioning profiles, so regenerate them.
That is the whole Apple-side setup. The native flow issues the identity token for your Bundle ID and Speed verifies it against Apple's public keys, so there is no Services ID, key, or Return URL to configure.
In the Speed dashboard, open your app's settings -> iOS and register:
- your Bundle ID (one or more). Identity tokens are only accepted for these.
- your custom URL scheme, without the
://. The sign-in page refuses to redirect to a scheme you have not registered, so add it before testing.
Sign in with Apple
All endpoints below live on https://api.speed.so.
Generate a random nonce, send Apple its SHA-256 as lowercase hex — what Apple's own
sha256 sample helper returns — and keep the raw value to send to Speed:
let request = ASAuthorizationAppleIDProvider().createRequest()
request.requestedScopes = [.fullName, .email]
request.nonce = sha256(rawNonce)
Then exchange the identity token for a Speed token:
POST /api/platform/auth/apple-native
Content-Type: application/json
{
"appId": 123, // or "domain": "yourapp.spdso.app"
"identityToken": "<credential.identityToken as UTF-8>",
"rawNonce": "<the unhashed nonce>",
"fullName": "Jane Doe" // first authorization only, see below
}
Response:
{
"token": "<Speed JWT>",
"user": { "id": "...", "name": "...", "email": "..." },
"role": "app-user",
"appUserUUID": "3d4c5b2d-0000-5000-8000-000000000000",
"isNewAccount": true,
"isLocalUser": true,
"linkSuggestion": "matching_email"
}
isNewAccount is true when this request created the account, so nothing in your app has ever been stored under it. isLocalUser is true for an app-local account that has not been linked yet — exactly when linking is still possible. linkSuggestion is present when we found a regular Speed account worth suggesting; it is absent otherwise. Together they drive the linking prompt — see Sign-in states.
appUserUUID is a stable UUID for this app-facing account. It is not a credential. Apps using StoreKit top-ups pass it as Apple's appAccountToken; see Apple In-App Purchases. Store it with the session and replace it whenever a link response replaces the user ID.
Send fullName on the first authorization. Apple returns the user's name exactly once, ever. If you do not forward it, it is gone permanently and Speed will have no name for that user.
Do not read credential.email. It is populated only on the first authorization and is nil afterwards. Speed reads the email from the identity token instead. Some Apple IDs return no email at all, so do not build flows that assume one exists.
Errors
400 ios_not_configured | No Bundle ID is registered for this app in the dashboard. |
401 invalid_identity_token | Expired, replayed, or signed for a Bundle ID you did not register. Also what a mismatched nonce returns. |
403 bundle_id_conflict | The Bundle ID is registered on another Speed app too. Remove it from one of them. |
404 Target not found | No app matches the appId or domain you sent. |
Use ASAuthorizationAppleIDButton. Apps have been rejected under Guideline 4.0 for Sign in with Apple buttons that deviate from Apple's design, branding, or localization.
Continue with Speed
Open ASWebAuthenticationSession at:
https://dash.speed.so/platform/auth?appId=123&redirect=yourscheme://speed-auth
The first time a user reaches your app this way, Speed asks them to confirm before issuing a token, since that first sign-in registers them with your app and hands you their name and photo. Later sign-ins go straight through.
The callback carries a _speed_auth_data query parameter containing the same JSON as above, URL-encoded.
Using the token
Authorization: Bearer <Speed JWT>
X-Speed-Request-From: app
Sessions
Tokens are valid for one week. There is no refresh endpoint — on a 401, run the sign-in flow again.
Remember which method the user signed in with and take them straight back to it. Do not make them choose between Apple and Speed a second time.
Account linking
An app-local account can be linked to a regular Speed account so the user gets a shared balance across Speed apps. Linking must be optional — an app-local account has to be able to use your app fully, or Sign in with Apple stops being an equivalent option under 4.8.
Sign-in states
Where the user can be when they sign in with Apple, and what your app should do about it.
| Apple account | Speed account | What your app should do |
|---|---|---|
| Created by this sign-in | One holds the same verified email | Before you show the app, tell the user they may already have a Speed account under this email, and offer to sign in to it. Nothing is stored under the new account yet, so link with keep: "speed". There is no need to ask which data to keep. |
| Signed in before, still app-local | One holds the same verified email | Prompt on every sign-in until the user links or declines for good. There may be data on both sides by now, so linking has to ask which side survives. |
| Created by this sign-in | None found. Either no Speed account uses that email, or the user hid it and Apple gave you an anonymous address that can never match one | Go straight into the app. Put a "Link to a Speed account" entry in settings for later. |
| Signed in before, still app-local | None found, for either reason above | No prompt. The settings entry is the only route. |
| Linked, keeping its own data | Backs it: balance and profile come from there | Nothing to offer; hide the linking entry. The user id did not change. |
| Linked, keeping the Speed account's data | Is the account the Apple ID now reaches | Nothing to offer; hide the linking entry. The user id changed to the Speed account's. |
isNewAccount, linkSuggestion and isLocalUser in the sign-in response tell you which row you are in.
A matching email is a suggestion, not proof of identity — we never return the address, and never link accounts on our own. Users who hid their email land in the third or fourth row, where the settings entry is the only route they will ever see.
Linking flow
- Open
ASWebAuthenticationSessionathttps://dash.speed.so/platform/auth?appId=123&link=1&redirect=yourscheme://speed-linkNote thelink=1. Without it this is the Continue with Speed sign-in URL. - The user signs in to Speed.
- The callback carries
_speed_auth_data, as with Continue with Speed. Do not adopt the token in it as a session — pass it straight to the call below. - Exchange it, authenticated with the app-local account's token:
POST /api/platform/auth/link
Authorization: Bearer <app-local account token>
{ "speedToken": "<token from _speed_auth_data>" }
The app-local token stays valid: the account's id did not change, only the account
backing it. You do not need to sign in again. The one exception is keep: "speed"
below, which hands you a new token to replace it with.
Linking is platform-level, so the Speed account does not need to have used your app before.
Handling the data conflict
If the Speed account the user is linking to already has data in your app — because they previously signed in to it with Continue with Speed — the request fails:
409 { "error": "app_data_conflict" }
Both accounts have data in your app and only one set can survive. Ask the user which one, then retry with the answer:
POST /api/platform/auth/link
{ "speedToken": "<same token>", "keep": "speed" }
keep: "speed"— the user keeps what they had in your app before. Apple sign-in resolves to their Speed account from now on, and the response carries a new token and a new user id. Switch to both, and delete the rows under the id returned aspreviousUserId.keep: "local"— the user keeps what they built up since signing in with Apple. The user id does not change. The Speed account's earlier rows in your app are not deleted, but nothing will reach them again.
App-local balance follows the surviving app-facing account:
keep: "local"leaves the balance andappUserUUIDunchanged.keep: "speed"merges the old app-local balance into the Speed account's balance for this app and moves its adjustment history. The response contains the newappUserUUID; use it for future StoreKit purchases.
Say which data is being given up before the user confirms. Neither direction can be undone through the API.
Linking errors
400 not_linkable | The token you authenticated with is not an unlinked app-local account. Check isLocalUser before offering the entry. |
400 invalid_speed_token | The speedToken is not a regular Speed account — expired, or itself app-local. Run the link=1 flow again. |
409 app_data_conflict | Both sides have data. Retry with keep, as above. |
keep is honoured whenever you send it, conflict or not — the first row of Sign-in states uses keep: "speed" on a brand-new account, where there is nothing to give up. Omitting it never changes the user id, and is only accepted when the Speed account has no data in your app.
Account deletion
Guideline 5.1.1(v) requires that users can start account deletion inside the app. Put it somewhere findable, typically account settings.
Two steps, in order, both on your app's own backend:
- Delete the user's data in your own database — every row keyed by the Speed user ID.
- Call:
DELETE /api/platform/auth/me
Authorization: Bearer <Speed JWT>
Afterwards your app holds no rows for that user and Speed has revoked its authorization. Speed also deletes that account's app-local balance. An account that only ever existed inside your app loses its Apple identity, so the same Apple ID signs in as a brand-new user next time and does not recover the previous balance.
A regular Speed account is never deleted by this, only its access to your app. It is the user's account across every Speed app, and their balance lives on it.
Deletion cannot be initiated by email or a support flow — it has to happen in the app.
Before confirmation, tell the user that any unused app-local balance will be permanently lost.
WebView shells
If your app is a WebView wrapper, do the Apple sign-in natively, then load your web app with the auth data attached:
https://yourapp.spdso.app/?_speed_auth_data=<url-encoded JSON>
The Speed SDK picks it up automatically and stores the session.
A Speed-generated web backend can call unlinkSpeedAccount(ctx.var.userToken) from
speed-framework in the same endpoint that deletes its own rows.