Skip to main content
Sign in with Apple emulation with the authorization code flow, Apple’s ES256 client secret checks, 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

Apple listens on port 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 picker
  • POST /auth/token — Exchange token: authorization code, refresh token, and client credentials with scope user.migration
  • POST /auth/revoke — Revoke token
  1. Redirect the user to /auth/authorize with client_id, redirect_uri, scope, state, response_mode=form_post (required for name or email), and optionally nonce and code_challenge
  2. The emulator renders a user picker page where the user selects a seeded account
  3. On selection, the emulator auto-submits a form to redirect_uri with code and state (or redirects, for query and fragment)
  4. On the first authorization per user and client, the form also carries the user JSON, as Apple’s does
  5. Exchange the code for tokens via POST /auth/token with client_id, client_secret, redirect_uri, and code_verifier when a challenge was sent
  • name and email can only come back through response_mode=form_post. A query or fragment request for either scope gets Apple’s error, response_mode must be form_post when name or email scope is requested. With scope=openid (or no scope), query puts code and state in the query string and fragment puts them after #.
  • The user JSON (name and email, 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_id it was issued to, and only with the redirect_uri of 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 (S256 or plain), the token request must send the matching code_verifier. Another code_challenge_method is refused.
  • Refresh tokens and revocation are tied to the client that received the token; another client’s refresh is invalid_grant and its revocation is a quiet no-op.

Parameters

ParamDescription
client_idOAuth client ID (Apple Services ID)
redirect_uriCallback URL
scopeSpace-separated scopes (openid email name)
stateOpaque state for CSRF protection
nonceNonce for ID token (optional)
code_challenge, code_challenge_methodPKCE challenge (optional; S256 or plain)
response_modequery (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:
For a throwaway local key: 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 document
  • GET /auth/keys — JSON Web Key Set (JWKS)
The 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)
Moving an app to another developer team changes each user’s 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>:
  1. The sending team posts sub, target (the recipient team ID), client_id, and client_secret, and gets {"transfer_sub": "..."}.
  2. The recipient team posts transfer_sub, client_id, and client_secret, and gets {"sub": "..."} with the user’s new sub at that team, plus a new private relay email and is_private_email for users who hide their email.
Afterwards, ID tokens issued to the recipient team’s clients carry the new 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
Seed 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:
Trigger one with 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

Users take 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.