> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Apple

> Sign in with Apple: authorization, client secrets, ID tokens, server-to-server notifications.

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

| | |
| - | - |
| Package | `@deploybaseline/apple` |
| Port | `4004` in the default set, `4000` when started alone |
| Config key | `apple` |
| HTTP operations | 8, browsable at `http://localhost:3999/docs/apple` while it runs ([API reference](/api-reference.md)) |

```bash CLI theme={null}
npx @deploybaseline/cli start --service apple
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const apple = await createEmulator({ service: "apple" });
// apple.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "apple": {
    "users": [
      {
        "email": "testuser@icloud.com",
        "name": "Test User"
      }
    ],
    "oauth_clients": [
      {
        "client_id": "com.example.app",
        "team_id": "TEAM001",
        "name": "My Apple App",
        "redirect_uris": ["http://localhost:3000/api/auth/callback/apple"]
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service apple
```

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 (`testuser@icloud.com`, Test User) and no registered clients, so client secrets are checked for their claims but not for `iss` or `kid` (see [Client secret](#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

<table>
  <thead>
    <tr>
      <th>Param</th>
      <th>Description</th>
    </tr>
  </thead>

  <tbody>
    <tr>
      <td>`client_id`</td>
      <td>OAuth client ID (Apple Services ID)</td>
    </tr>

    <tr>
      <td>`redirect_uri`</td>
      <td>Callback URL</td>
    </tr>

    <tr>
      <td>`scope`</td>
      <td>Space-separated scopes (`openid email name`)</td>
    </tr>

    <tr>
      <td>`state`</td>
      <td>Opaque state for CSRF protection</td>
    </tr>

    <tr>
      <td>`nonce`</td>
      <td>Nonce for ID token (optional)</td>
    </tr>

    <tr>
      <td>`code_challenge`, `code_challenge_method`</td>
      <td>PKCE challenge (optional; `S256` or `plain`)</td>
    </tr>

    <tr>
      <td>`response_mode`</td>
      <td>`query` (default), `form_post`, or `fragment`; `form_post` is required when `name` or `email` is requested</td>
    </tr>
  </tbody>
</table>

## 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:

```typescript theme={null}
import { SignJWT, importPKCS8 } from 'jose'

const clientSecret = await new SignJWT({})
  .setProtectedHeader({ alg: 'ES256', kid: process.env.APPLE_KEY_ID })
  .setIssuer(process.env.APPLE_TEAM_ID)
  .setSubject(process.env.APPLE_CLIENT_ID)
  .setAudience('https://appleid.apple.com')
  .setIssuedAt()
  .setExpirationTime('180d')
  .sign(await importPKCS8(process.env.APPLE_PRIVATE_KEY, 'ES256'))
```

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](/configuration.md#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:

```json theme={null}
{ "type": "email-disabled", "sub": "001234.abc...", "email": "x1y2@privaterelay.appleid.com", "is_private_email": "true", "event_time": 1760870000000 }
```

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:

| `type` | Change |
| - | - |
| `email-disabled`, `email-enabled` | Turns private relay forwarding off or on. Only for users with `is_private_email: true` (otherwise `400`); the event carries `email` and `is_private_email` |
| `consent-revoked` | Revokes the user's access and refresh tokens for that client; the next sign in is a first sign in again, with the `user` JSON |
| `account-delete` | Revokes every token and deletes the user |

## Seed config

```json theme={null}
{
  "apple": {
    "users": [
      {
        "email": "testuser@icloud.com",
        "name": "Test User",
        "given_name": "Test",
        "family_name": "User"
      },
      {
        "email": "private@example.com",
        "name": "Private User",
        "is_private_email": true
      }
    ],
    "oauth_clients": [
      {
        "client_id": "com.example.app",
        "team_id": "TEAM001",
        "key_id": "KEY0000001",
        "public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----",
        "name": "My Apple App",
        "redirect_uris": ["http://localhost:3000/api/auth/callback/apple"]
      }
    ]
  }
}
```

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](#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](#client-secret)).

`signing_key.private_jwk` pins the ID token signing key (see [Signing key](#signing-key)).

## Conformance

| | |
| - | - |
| Routes | 8 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | no public spec |
| SDKs tested | `jose@6.2.12`, `openid-client@6.8.8` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.