apple
Apple Sign In Emulator
Sign in with Apple emulation with authorization code flow, PKCE support, RS256 ID tokens, and OIDC discovery.
Start
# Apple only
npx emulate --service apple
# Default port (when run alone)
# http://localhost:4000
Or programmatically:
import { createEmulator } from 'emulate'
const apple = await createEmulator({ service: 'apple', port: 4004 })
// apple.url === 'http://localhost:4004'
Pointing Your App at the Emulator
Environment Variable
APPLE_EMULATOR_URL=http://localhost:4004
OAuth URL Mapping
| Real Apple URL | Emulator URL |
|---|---|
https://appleid.apple.com/.well-known/openid-configuration |
$APPLE_EMULATOR_URL/.well-known/openid-configuration |
https://appleid.apple.com/auth/authorize |
$APPLE_EMULATOR_URL/auth/authorize |
https://appleid.apple.com/auth/token |
$APPLE_EMULATOR_URL/auth/token |
https://appleid.apple.com/auth/keys |
$APPLE_EMULATOR_URL/auth/keys |
https://appleid.apple.com/auth/revoke |
$APPLE_EMULATOR_URL/auth/revoke |
Auth.js / NextAuth.js
import Apple from '@auth/core/providers/apple'
Apple({
clientId: process.env.APPLE_CLIENT_ID,
clientSecret: process.env.APPLE_CLIENT_SECRET,
authorization: {
url: `${process.env.APPLE_EMULATOR_URL}/auth/authorize`,
params: { scope: 'openid email name', response_mode: 'form_post' },
},
token: {
url: `${process.env.APPLE_EMULATOR_URL}/auth/token`,
},
jwks_endpoint: `${process.env.APPLE_EMULATOR_URL}/auth/keys`,
})
Passport.js
import { Strategy as AppleStrategy } from 'passport-apple'
const APPLE_URL = process.env.APPLE_EMULATOR_URL ?? 'https://appleid.apple.com'
new AppleStrategy({
clientID: process.env.APPLE_CLIENT_ID,
teamID: process.env.APPLE_TEAM_ID,
keyID: process.env.APPLE_KEY_ID,
callbackURL: 'http://localhost:3000/api/auth/callback/apple',
authorizationURL: `${APPLE_URL}/auth/authorize`,
tokenURL: `${APPLE_URL}/auth/token`,
}, verifyCallback)
Seed Config
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
name: My Apple App
redirect_uris:
- http://localhost:3000/api/auth/callback/apple
When no OAuth clients are configured, the emulator accepts any client_id. With clients configured, strict validation is enforced for client_id and redirect_uri.
Users with is_private_email: true get a generated @privaterelay.appleid.com email in the id_token instead of their real email.
API Endpoints
OIDC Discovery
curl http://localhost:4004/.well-known/openid-configuration
Returns the standard OIDC discovery document with all endpoints pointing to the emulator:
{
"issuer": "http://localhost:4004",
"authorization_endpoint": "http://localhost:4004/auth/authorize",
"token_endpoint": "http://localhost:4004/auth/token",
"jwks_uri": "http://localhost:4004/auth/keys",
"revocation_endpoint": "http://localhost:4004/auth/revoke",
"response_types_supported": ["code"],
"subject_types_supported": ["pairwise"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid", "email", "name"],
"token_endpoint_auth_methods_supported": ["client_secret_post"],
"response_modes_supported": ["query", "fragment", "form_post"]
}
JWKS
curl http://localhost:4004/auth/keys
Returns an RSA public key (kid: emulate-apple-1) for verifying id_token signatures.
Authorization
# Browser flow: redirects to a user picker page
curl -v "http://localhost:4004/auth/authorize?\
client_id=com.example.app&\
redirect_uri=http://localhost:3000/api/auth/callback/apple&\
scope=openid+email+name&\
response_type=code&\
state=random-state&\
nonce=random-nonce&\
response_mode=form_post"
Query 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) |
response_mode |
query (default), form_post, or fragment |
The emulator renders an HTML page where you select a seeded user. After selection, it redirects (or auto-submits a form for form_post) to redirect_uri with code and state. On the first authorization per user/client pair, a user JSON blob is also included (matching Apple's real behavior).
Token Exchange
curl -X POST http://localhost:4004/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "code=<authorization_code>&\
client_id=com.example.app&\
client_secret=<client_secret>&\
grant_type=authorization_code"
Returns:
{
"access_token": "apple_...",
"refresh_token": "r_apple_...",
"id_token": "<jwt>",
"token_type": "Bearer",
"expires_in": 3600
}
The id_token is an RS256 JWT containing sub, email, email_verified (string), is_private_email (string), real_user_status, auth_time, and optional nonce.
Refresh Token
curl -X POST http://localhost:4004/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "refresh_token=r_apple_...&\
client_id=com.example.app&\
grant_type=refresh_token"
Returns a new access_token and id_token. No new refresh_token is issued on refresh (matching Apple's behavior).
Token Revocation
curl -X POST http://localhost:4004/auth/revoke \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "token=apple_..."
Returns 200 OK. The token is removed from the emulator's token map.
Common Patterns
Full Authorization Code Flow
APPLE_URL="http://localhost:4004"
CLIENT_ID="com.example.app"
REDIRECT_URI="http://localhost:3000/api/auth/callback/apple"
# 1. Open in browser (user picks a seeded account)
# $APPLE_URL/auth/authorize?client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URI&scope=openid+email+name&response_type=code&state=abc&response_mode=form_post
# 2. After user selection, emulator posts to:
# $REDIRECT_URI with code=<code>&state=abc (and user JSON on first auth)
# 3. Exchange code for tokens
curl -X POST $APPLE_URL/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "code=<code>&client_id=$CLIENT_ID&grant_type=authorization_code"
# 4. Decode the id_token JWT to get user info
Private Relay Email
When a user has is_private_email: true in the seed config, the id_token will contain a generated @privaterelay.appleid.com email instead of the user's real email. This matches Apple's Hide My Email behavior.