Sign in with Apple
ActingWeb supports Sign in with Apple as a first-class OAuth provider alongside Google and GitHub, covering the web SPA flow, native iOS, Android Capacitor (web flow), and the LLM-triggered (MCP) OAuth web form.
Apple’s App Store Guideline 4.8 effectively requires offering Sign in with Apple (or an equivalent privacy-preserving login) whenever you offer Google login in an iOS app, so this is usually mandatory for Capacitor apps shipping Google login.
Why Apple is different
Apple departs from “generic OAuth2” in ways the library handles for you:
No userinfo endpoint. All identity lives in the OIDC
id_token(a JWT).JWT ``client_secret``. Every token / refresh / revoke request must carry a freshly-signed ES256 JWT built from your Team ID, Key ID and
.p8key (not a static string).``response_mode=form_post``. When
name/emailscopes are requested, Apple POSTs the authorization response to yourredirect_uri.Dual ``aud``. Native iOS uses the Bundle ID as
client_id; web/Android use the Services ID. Validation accepts a list of audiences.Name/email only on first sign-in. Apple includes the user’s name only on the very first authorization; persist it on first contact.
Apple Developer Portal setup (runbook)
For a single product spanning web + iOS + Android Capacitor:
App ID (e.g.
com.example.app) with the Sign In with Apple capability, marked as Primary. This Bundle ID is theclient_idfor the native iOS plugin.Services ID (e.g.
com.example.web), associated with the primary App ID, with your HTTPS return URLs registered. This is theclient_idfor the web SPA and the Android Capacitor web flow.Sign in with Apple key (Keys →
+→ Sign In with Apple). Download the.p8once. Note its Key ID. This key is separate from any App Store Connect API key.Team ID (top-right of the developer portal).
When the Services ID is associated with the primary App ID, the same Apple user yields the same ``sub`` across both audiences.
Note
Apple’s redirect_uri must be HTTPS — no localhost, no IPs. For
local development use a tunnel (ngrok/cloudflared) or a real subdomain with a
certificate.
Configuring the library
from actingweb.interface import ActingWebApp
app = (
ActingWebApp(
aw_type="urn:actingweb:example.com:myapp",
fqdn="myapp.example.com",
proto="https://",
)
.with_oauth(provider="google", client_id="...", client_secret="...")
.with_apple_sign_in(
client_id="com.example.web", # Services ID
audiences=["com.example.web", # Services ID (web/Android)
"com.example.app"], # Bundle ID (native iOS)
team_id="ABCDE12345",
key_id="KEY1234567",
private_key_path="/etc/secrets/AuthKey_KEY1234567.p8",
# Android Capacitor deep link (optional). Apple still POSTs to the
# HTTPS callback; this is only the final app handoff:
mobile_redirect_uri="io.example.app://callback",
)
)
Key points:
client_idis the Services ID;audiencesis the list of acceptableaudvalues (Services ID + Bundle ID). All audience entries are optional individually, but the list must be non-empty.The
.p8key may be supplied as a file path (private_key_pathor theAPPLE_PRIVATE_KEY_PATHenv var) or an inline PEM (private_key_pem/APPLE_PRIVATE_KEY_PEM). The file path wins if both are set. The key is validated eagerly — an unreadable path or unparseable PEM raisesValueErrorat config-build time, not at first request.Setting
mobile_redirect_urialso registers anapple-mobileprovider for the Android flow. Apple’sredirect_urifor both the authorize request and the token exchange remains the HTTPS/oauth/callback/apple; the custom scheme is only the deep link the Capacitor app intercepts.
Environment variables
# File path wins over inline PEM if both are set
export APPLE_PRIVATE_KEY_PATH=/etc/secrets/AuthKey_KEY1234567.p8
# or
export APPLE_PRIVATE_KEY_PEM="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
The flows
Web SPA
The SPA POSTs to
/oauth/spa/authorizewithprovider=apple. The library stores the full state server-side and returns an Apple authorization URL whosestateis an opaque single-use nonce and whoseresponse_modeisform_post.The browser authenticates with Apple; Apple POSTs
code,state,id_tokenand (first sign-in only)usertoPOST /oauth/callback/apple.The library consumes the nonce (rejecting forged/replayed POSTs), exchanges the code with Apple using the ES256
client_secret, validates theid_token, merges the first-sign-inusername, creates/looks-up the actor, firesoauth_success, and redirects back to the SPA with a session token.
Native iOS
The Capacitor plugin returns an id_token directly. The app submits it to the
JWT-bearer grant (see Native id_token (JWT-bearer) grant).
Android Capacitor
Android has no native Apple SDK, so the app opens Apple’s web flow in a Custom
Tab. Apple POSTs to the HTTPS /oauth/callback/apple with provider=apple-mobile;
the library persists the IdP code against an opaque ticket and deep-links
the app (io.example.app://callback?ticket=...) — no ActingWeb token in the
deep link. The app then redeems the ticket:
POST /oauth/spa/token
{"grant_type": "apple_mobile_ticket", "ticket": "<ticket from deep link>"}
Native id_token (JWT-bearer) grant
Native apps that already hold a provider id_token (Apple via the iOS plugin,
or Google via with_google_native) exchange it for an ActingWeb session:
POST /oauth/spa/token
{
"grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer",
"provider": "apple-mobile", # or "google-native"
"assertion": "<the id_token JWT>",
"nonce": "<the nonce the app sent to the IdP>"
}
Security properties:
The validator is selected by the declared
providerand the tokenissmust match that provider — a Googleid_tokensubmitted asapple-mobileis rejected.nonceis required and must match the token’snonceclaim.Each
id_tokenis single-use (replay-protected) within its validity window.
Configure Google native sign-in with:
app.with_google_native(
client_id="WEB_CLIENT_ID.apps.googleusercontent.com",
ios_client_id="IOS_CLIENT_ID.apps.googleusercontent.com",
android_client_id="ANDROID_CLIENT_ID.apps.googleusercontent.com",
)
The normalized user_info shape
Before oauth_success fires, the library normalizes provider-specific name
fields so your hook reads one shape regardless of provider:
Field |
Source |
|---|---|
|
Apple |
|
Apple |
|
derived, or GitHub |
|
|
|
provider subject claim (passthrough) |
Warning
Apple sends the user’s name only on the first sign-in. Persist it then — you cannot retrieve it later (the user would have to revoke the app in iOS Settings to re-trigger first-login behavior).
Account deletion and token revocation
Apple (Technote TN3194) requires apps to call Apple’s /auth/revoke with the
user’s refresh token when the account is deleted. The library’s revoke_token()
supports Apple’s ES256 client_secret; call it from your own actor_deleted
hook (best-effort / rate-limited):
@app.lifecycle_hook("actor_deleted")
def on_actor_deleted(actor):
provider = actor.store.oauth_provider or ""
refresh = actor.store.oauth_refresh_token
if provider.startswith("apple") and refresh:
from actingweb.oauth2 import create_apple_authenticator
create_apple_authenticator(actor.config).revoke_token(refresh)
actor.store.oauth_provider is written on every sign-in, so it reflects the
most recent provider.
MCP / LLM-triggered web form
Apple is also offered in the LLM-triggered OAuth web form (MCP). The encrypted
MCP state is wrapped in the same server-side nonce so Apple’s form_post callback
can dispatch back to the MCP completion path. Only the web apple variant is
offered in the form; apple-mobile / *-native variants are native-only.