form_post responses with the one-time user JSON, optional PKCE, refresh tokens, revocation, server-to-server notifications, team transfers, RS256 ID tokens, and OIDC discovery.
At a glance
Start
4004 in the default set, or on http://localhost:4000 when started alone. Without a seed config there is one user ([email protected], Test User) and no registered clients, so client secrets are checked for their claims but not for iss or kid (see Client secret). Point your Sign in with Apple client at the emulator URL in place of https://appleid.apple.com.
Authorization flow
GET /auth/authorize— Authorize: shows the user pickerPOST /auth/token— Exchange token: authorization code, refresh token, and client credentials with scopeuser.migrationPOST /auth/revoke— Revoke token
- Redirect the user to
/auth/authorizewithclient_id,redirect_uri,scope,state,response_mode=form_post(required fornameoremail), and optionallynonceandcode_challenge - The emulator renders a user picker page where the user selects a seeded account
- On selection, the emulator auto-submits a form to
redirect_uriwithcodeandstate(or redirects, forqueryandfragment) - On the first authorization per user and client, the form also carries the
userJSON, as Apple’s does - Exchange the code for tokens via
POST /auth/tokenwithclient_id,client_secret,redirect_uri, andcode_verifierwhen a challenge was sent
nameandemailcan only come back throughresponse_mode=form_post. Aqueryorfragmentrequest for either scope gets Apple’s error,response_mode must be form_post when name or email scope is requested. Withscope=openid(or no scope),queryputscodeandstatein the query string andfragmentputs them after#.- The
userJSON (nameandemail, only the parts that were requested) is posted with the code on the first authorization per user and client only. - A code works once, only for the
client_idit was issued to, and only with theredirect_uriof the authorization request. Codes expire after 5 minutes. - PKCE: Apple does not document it and discovery does not list it, but when the authorization request carries a
code_challenge(S256orplain), the token request must send the matchingcode_verifier. Anothercode_challenge_methodis refused. - Refresh tokens and revocation are tied to the client that received the token; another client’s refresh is
invalid_grantand its revocation is a quiet no-op.
Parameters
| Param | Description |
|---|---|
client_id | OAuth client ID (Apple Services ID) |
redirect_uri | Callback URL |
scope | Space-separated scopes (openid email name) |
state | Opaque state for CSRF protection |
nonce | Nonce for ID token (optional) |
code_challenge, code_challenge_method | PKCE challenge (optional; S256 or plain) |
response_mode | query (default), form_post, or fragment; form_post is required when name or email is requested |
Client secret
The token endpoint and/auth/revoke check client_secret the way Apple does. It must be an ES256 JWT with kid set to the client’s key_id, iss set to its team_id, sub set to the client_id, aud set to https://appleid.apple.com, an iat that is not in the future, and an exp that has not passed and is at most six months after iat. When the client has a public_key (the P-256 public key of your Sign in with Apple key, as SPKI PEM or JWK), the signature must verify with it; without one only the header and claims are checked. Anything else gets 400 invalid_client. When no clients are registered at all, the claims are checked but not iss or kid.
Generate a secret with the same code your app uses for real Apple, for example with jose:
node -e "const {generateKeyPairSync}=require('crypto');const k=generateKeyPairSync('ec',{namedCurve:'P-256'});console.log(k.privateKey.export({type:'pkcs8',format:'pem'}));console.log(k.publicKey.export({type:'spki',format:'pem'}))". Sign with the private key and seed the public key as public_key.
ID tokens
GET /.well-known/openid-configuration— OIDC discovery documentGET /auth/keys— JSON Web Key Set (JWKS)
id_token is an RS256 JWT containing sub, email, email_verified, is_private_email, real_user_status, auth_time, and optional nonce.
Users with is_private_email: true in the seed config receive a generated @privaterelay.appleid.com email in the id_token instead of their real email, matching Apple’s Hide My Email behavior.
Signing key
ID tokens are signed with a key stored in emulator state (kid: baseline-apple-1), so they stay valid across snapshot restores and persisted restarts. Set apple.signing_key.private_jwk to an RSA private JWK to pin the key. See OIDC signing keys.
Team transfers
POST /auth/usermigrationinfo— Transfer user: team transfers (transfer_sub)
sub, so Apple provides /auth/usermigrationinfo. Both teams first get an access token with grant_type=client_credentials&scope=user.migration from /auth/token (their own client_id and client secret), then call /auth/usermigrationinfo with Authorization: Bearer <token>:
- The sending team posts
sub,target(the recipient team ID),client_id, andclient_secret, and gets{"transfer_sub": "..."}. - The recipient team posts
transfer_sub,client_id, andclient_secret, and gets{"sub": "..."}with the user’s newsubat that team, plus a new private relayemailandis_private_emailfor users who hide their email.
sub and, for 60 days, a transfer_sub claim; the sending team keeps the original sub. Transfer identifiers expire after 60 days. A missing or foreign token is 401 invalid_client; an identifier for another team, unknown, or expired is 400 invalid_grant.
Server-to-server notifications
POST /_apple/simulate/notification— Simulate notification: send a server-to-server notification and apply its change
notification_url on a client to receive the notifications Apple sends when a user changes how they use Sign in with Apple with your app. Apple posts {"payload": "<JWT>"}; the JWT is signed with the identity token keys (verify it against /auth/keys), has aud set to the client ID, and its events claim is a JSON string:
POST /_apple/simulate/notification and a JSON body with type, the user’s email (or sub), and client_id (leave it out to notify every client with a notification_url). The emulator applies the change first:
Seed config
email, name, given_name, family_name, and is_private_email. Users with is_private_email: true get a generated @privaterelay.appleid.com email in the id_token.
OAuth clients take client_id, team_id, name, redirect_uris, key_id (default TESTKEY001), an optional public_key (SPKI PEM or JWK), and notification_url (see Server-to-server notifications). Client secrets must be Apple’s ES256 JWT with that kid, the team_id as iss, and the client_id as sub, and must verify with public_key when one is set (see Client secret).
signing_key.private_jwk pins the ID token signing key (see Signing key).
Conformance
See CONFORMANCE.md for every provider.