Sign in with Apple โ€” REST API Reference

Condensed, server-side-integration-only reference. Cuts the iOS/native-app framework material (AuthenticationServices, ASAuthorizationController) and keeps only what a backend needs to implement the web/REST authorization-code flow.

Merged from 10 pages under developer.apple.com/documentation/signinwithapplerestapi and .../signinwithapple โ€” see footer for the full list.

Overview

Sign in with Apple REST API is the web service your backend talks to directly โ€” for anything that isn't a native Apple-platform app using AuthenticationServices. Two-factor auth happens entirely on Apple's side; your server only ever sees an authorization code and a signed identity token (JWT), never a password.

๐Ÿ”‘

Identity, not a session. Use the token's sub claim (a stable, per-team user identifier) to key accounts โ€” never the email, since Apple's private-relay addresses can churn independently of the underlying account.

Authorization code flow โ€” end to end

100% Drag to pan ยท scroll/pinch to zoom
sequenceDiagram
    participant U as User (browser)
    participant BE as Your backend
    participant A as Apple (appleid.apple.com)

    U->>BE: GET /auth/apple/login (or /register)
    BE->>BE: generate state, store in session
    BE-->>U: 302 redirect to Apple auth URL
    U->>A: GET /auth/authorize?response_type=code&response_mode=form_post&scope=name+email&state=...
    A-->>U: Sign-in UI (Face ID / Touch ID / password + 2FA)
    U->>A: Approves
    Note over A,U: response_mode=form_post is MANDATORY once any scope is requested
    A-->>U: HTML page with a hidden, auto-submitting
<form method="POST" action=redirect_uri> U->>BE: Browser auto-submits the form:
POST redirect_uri (application/x-www-form-urlencoded)
body: code, state, user (first login only) BE->>BE: verify state matches session BE->>BE: build client_secret JWT (ES256, signed with your private key) BE->>A: POST /auth/token
client_id, client_secret, code, grant_type=authorization_code A-->>BE: 200 { access_token, refresh_token, id_token, expires_in } BE->>A: GET /auth/keys (JWKS, cached) A-->>BE: { keys: [ JWK... ] } BE->>BE: verify id_token: sig (kid match), iss, aud, exp, nonce BE->>BE: extract sub + email from id_token, create/link user BE-->>U: 302 redirect to app, session cookie set

1. Request an authorization

GET https://appleid.apple.com/auth/authorize
Param Req. Notes
client_id required Your App ID or Services ID. Must not contain your Team ID.
redirect_uri required HTTPS, has a real domain (no IP/localhost), no fragment.
response_type required code, or code id_token. id_token alone is unsupported.
response_mode optional* query / fragment / form_post. Must be form_post if any scope is requested โ€” otherwise optional.
scope optional name, email, both (space/%20-separated), or neither.
state optional Anti-CSRF/anti-replay โ€” echoed back verbatim in the response.
nonce optional Bound into the identity token's nonce claim for replay protection.

Response delivery โ€” depends entirely on response_mode

Mode Delivery
query Real HTTP redirect โ€” Apple 302s the browser to redirect_uri?code=...&state=..., same shape as Google/Facebook/X.
fragment Redirect with params in the URL fragment (#...) โ€” never reaches your server directly, client-side only.
form_post An actual HTTP POST is sent to redirect_uri, body application/x-www-form-urlencoded, containing code, state, and (first login only) user (JSON: name + email).
โš ๏ธ

This is why the callback controller is a @Post, not a @Get. Verbatim from Apple's docs: "If you requested any scopes, the [response_mode] value must be form_post." and "For the form_post value, an HTTP POST request containing the results of the authorization is sent to the redirectURI." Mechanically this is not a server-to-server call and not a 3xx redirect (a redirect can only ever produce a follow-up GET) โ€” Apple returns an HTML page with a hidden, auto-submitting <form method="POST">, and it's the user's browser that performs the actual POST to your redirect_uri as a normal top-level navigation. Since email is required to create/link an account, form_post isn't optional here.

user is only ever sent on the account's first authorization โ€” persist it immediately, it will not come again. Every subsequent login still carries the verified email inside the id_token, just not the name.

2. Client secret (JWT you generate, not Apple)

Apple has no client-secret string to copy โ€” you mint a short-lived ES256 JWT yourself, signed with the private key downloaded from your developer account, and send a fresh one per token request.

Field Location Value
alg header ES256
kid header Your 10-char key ID
iss payload Your 10-char Team ID
iat / exp payload Now / now + โ‰ค15,777,000s (6 months max)
aud payload https://appleid.apple.com
sub payload Your App ID / Services ID (same as client_id)

3. Exchange the code for tokens

POST https://appleid.apple.com/auth/token
Param Req. Notes
client_id required Same value used in the authorize request.
client_secret required The ES256 JWT from step 2.
grant_type required authorization_code (first exchange) or refresh_token (session refresh).
code conditional Required for authorization_code grant. Single-use, 5-minute TTL.
refresh_token conditional Required for refresh_token grant.
redirect_uri conditional Required alongside code, only if you supplied one at authorize time.

Response: { access_token, token_type, expires_in, refresh_token, id_token } for a code exchange; refresh-token requests omit a new refresh_token.

4. Verify the identity token

Your server must check, in this order:

  1. JWS signature (ES256) against Apple's public key, matched by kid
  2. iss is exactly https://appleid.apple.com
  3. aud equals your client_id
  4. exp is in the future
  5. nonce matches what you sent at authorize time (if used)

Only after all five pass should you trust sub (user ID) and email from the token.

JWKS endpoint

GET https://appleid.apple.com/auth/keys

Returns { keys: [...] } โ€” a set of JWKs that can rotate and vary in count. Always select by kid, never assume a fixed key or count; cache with a TTL rather than hardcoding.

Revoke tokens

POST https://appleid.apple.com/auth/revoke
Param Req. Notes
client_id required Must match the original authorize request.
client_secret required Fresh client-secret JWT.
token required The access or refresh token to invalidate.
token_type_hint optional access_token or refresh_token.

Always 200 with an empty body on success โ€” including when the token was already invalid. Call this when a user deletes their account or disconnects Sign in with Apple on your side.

Server-to-server notifications

Apple pushes account-lifecycle events to a webhook URL you register โ€” independent of any user session, can arrive at any time. Payload is a signed JWT (JWS), same key material as identity tokens.

100% Drag to pan ยท scroll/pinch to zoom
sequenceDiagram
    participant Acct as User's Apple Account
    participant A as Apple
    participant WH as Your webhook endpoint

    Acct->>A: Disable/enable Hide My Email, revoke app access, or delete account
    A->>WH: POST (registered notification URL)
body: signed JWS, claim "events" = { type, sub, ... } WH->>A: GET /auth/keys (verify signature, cached) WH->>WH: validate alg/kid, then branch on events.type alt type = email-enabled / email-disabled WH->>WH: update forwarding preference for that user else type = consent-revoked WH->>WH: invalidate stored tokens for that user else type = account-deleted WH->>WH: hard-disable login, invalidate all sessions/tokens end WH-->>A: 200 OK (no body)
events.type Meaning Action
email-enabled User re-enabled Hide My Email forwarding Resume delivering to the private relay address
email-disabled User disabled forwarding Stop sending to that address
consent-revoked User revoked your app's access Invalidate stored tokens for that sub
account-deleted User permanently deleted their Apple Account Invalidate all tokens/sessions; account deletion also silently disables email forwarding โ€” no separate event for that

Endpoint must support TLS 1.2+. One URL can serve multiple apps/teams โ€” dispatch on the JWT's own claims, not the URL.

Provider comparison โ€” OAuth protocol classification

Summary

All four providers in this codebase (apps/backend/apps/api/src/auth/{google,facebook,x,apple}) run the same OAuth 2.0 Authorization Code Grant shape at the top level โ€” redirect out, come back with a code, exchange it server-side for tokens. Underneath that thin shared shell, they diverge on almost every axis that matters. Three of the four (Google, Facebook, X) are plain OAuth 2.0: swap the code for an access token, then spend a second network round trip asking a REST endpoint who the user is, and trust whatever TLS hands back. Apple alone is OpenID Connect: identity arrives as a signed JWT in the same token-exchange response, verified locally against Apple's own public keys โ€” no second call, no blind trust in the transport. X is the only one of the OAuth-2.0-only three that adds PKCE. Apple is the only one that authenticates itself to the provider with a self-signed JWT instead of a static secret, and the only one whose callback is a POST instead of a GET. None of this is arbitrary โ€” each difference traces back to a concrete property the provider's own API forces on you, detailed provider-by-provider below.

Classification table

Provider Protocol Client auth PKCE Callback Identity source
Google OAuth 2.0 Authorization Code
(OIDC-capable, but not used this way here)
Static client_secret No GET redirect, query string REST call: GET oauth2/v2/userinfo with access_token
Facebook OAuth 2.0 Authorization Code
(no OIDC in this flow)
Static app secret No GET redirect, query string REST call: Graph API GET /me with access_token
X (Twitter) OAuth 2.0 Authorization Code + PKCE (S256) HTTP Basic (client_id:client_secret) + PKCE verifier Yes GET redirect, query string REST call: X API v2 GET /2/users/me with Bearer token
Apple OpenID Connect (Authorization Code + ID Token) Signed JWT assertion (ES256, regenerated per request โ€” private_key_jwt-style, no shared secret) No* POST, response_mode=form_post (mandatory once scope requested) Locally-verified ID Token (JWT): JWS signature via JWKS, iss/aud/exp/nonce

*Not a choice โ€” Apple's REST API has no PKCE support to opt into. Neither /auth/authorize nor /auth/token documents a code_challenge/code_verifier param (verified directly against Apple's docs, not assumed), unlike Google/Facebook/X which all accept the standard PKCE params whether or not this integration sends them. See the Apple section below for what fills that gap instead.

Google โ€” plain OAuth 2.0 via the official SDK

Why: Google is a full OpenID Connect provider โ€” it can issue a signed ID Token exactly like Apple does. This integration simply doesn't use that path. It's built on the googleapis npm package's google.auth.OAuth2 helper (apps/backend/apps/api/src/auth/google/google.service.ts), which exposes the classic getToken(code) + separate oauth2('v2').userinfo.get() call. That's the path of least resistance when you're already pulling in Google's own client library for other reasons (it also handles token refresh, retries, etc.) โ€” the tradeoff is an extra network call per login and no cryptographic proof of the claims, just TLS.

How it works here:

  1. oAuthClient.generateAuthUrl({ scope: '.../userinfo.email', state }) builds the redirect URL.
  2. Google 302-redirects the browser back to redirect_uri?code=...&state=... โ€” a normal GET, no form_post involved.
  3. oAuthClient.getToken(code) exchanges the code for { tokens }, authenticating with the static client_secret from GoogleConfig.
  4. google.oauth2('v2').userinfo.get({ oauth_token: tokens.access_token }) makes a second call to fetch email/id โ€” this is the "trust the REST response" step; there's no signature to check.

How to upgrade it to OIDC (not currently done): request scope=openid email, read tokens.id_token from the same response instead of calling userinfo, and verify it locally against https://www.googleapis.com/oauth2/v3/certs (Google's JWKS) the same way apple-http.client.ts already does for Apple โ€” same shape, one fewer network round trip, plus a cryptographic guarantee instead of a TLS-trust one.

Facebook โ€” plain OAuth 2.0, no OIDC available

Why: Unlike Google, the classic Facebook Login / Graph API surface this integration targets was never an OpenID Connect provider to begin with โ€” there's no ID Token to opt into here, only an access token and the Graph API. So "trust a REST call" isn't a shortcut being taken, it's the only option Facebook's classic login flow offers.

How it works here (apps/backend/apps/api/src/auth/facebook/http/facebook-http.client.ts):

  1. constructFbApiAuthUrl builds https://www.facebook.com/v23.0/dialog/oauth?scope=email&state=....
  2. Facebook 302-redirects back with code/state in the query string, same GET shape as Google.
  3. getAccessToken(code) calls Graph's GET oauth/access_token with client_id/client_secret/code as query params (not a POST body like the other three โ€” Facebook's own token endpoint happens to accept it as a GET).
  4. getUserInfo(accessToken) calls Graph's GET /me?fields=id,email,name with the access token โ€” again, no signature, TLS is the only guarantee.

How to harden it: there's genuinely no ID-Token path available here, so the practical hardening isn't "verify a JWT" โ€” it's the same one every OAuth-2.0-only integration should have and doesn't yet: PKCE, matching what X already does. See the X section for the exact recipe.

X (Twitter) โ€” the one with PKCE

Why: X's OAuth 2.0 API v2 makes PKCE effectively mandatory for the confidential-client web flow this integration uses (it's rejected without a valid code_challenge) โ€” unlike Google/Facebook, where PKCE is optional and simply wasn't added. PKCE exists to stop a stolen/intercepted authorization code from being redeemable by anyone who doesn't also possess the original code_verifier, which never leaves your server.

How it works here (apps/backend/apps/api/src/auth/x/http/x-http.client.ts):

  1. generatePkceChallenge(): codeVerifier = randomBytes(32).toString('base64url'), codeChallenge = sha256(codeVerifier) (also base64url).
  2. constructXApiAuthUrl(state, codeChallenge) sends code_challenge + code_challenge_method=S256 alongside the usual state.
  3. The controller stashes codeVerifier in the session as pkceCodeVerifier (SessionOauthLogin/SessionOauthRegistration) โ€” never sent to X, only used at the final step.
  4. X 302-redirects back with code/state, plain GET, same as Google/Facebook.
  5. getAccessToken(code, codeVerifier) exchanges the code, sending the original code_verifier so X can hash it and confirm it matches the code_challenge from step 2 โ€” this is what makes a bare stolen code useless to an attacker.
  6. getUserInfo(accessToken) calls X API v2's GET /2/users/me with a Bearer token โ€” REST/TLS-trust identity, same category as Google/Facebook.

How to add this to Google/Facebook: the recipe is entirely provider-agnostic โ€” generate the verifier/challenge pair the same way, add code_challenge/code_challenge_method=S256 to the authorize URL, store the verifier in the session, and pass it back at token-exchange time. Both Google's and Facebook's token endpoints accept the standard code_verifier param; neither current implementation sends one.

Apple โ€” the only OpenID Connect implementation

Why: Apple requires it, on two fronts at once. First, the client-authentication side: Apple never issues a static client_secret string at all โ€” the developer portal only gives you a private signing key, and you're required to mint a short-lived ES256 JWT yourself for every token request (see "2. Client secret" above). Second, the identity side: Apple's whole product pitch is privacy (private-relay emails, minimal data sharing), and it backs that with a signed ID Token rather than a userinfo endpoint you'd have to call with a bare access token โ€” the token itself is the complete, verifiable identity assertion.

How it works here (apps/backend/apps/api/src/auth/apple/http/apple-http.client.ts):

  1. generateClientSecret() mints a fresh ES256 JWT per token request: iss=Team ID, sub=Services ID, aud=https://appleid.apple.com, 5-minute expiry, signed with your downloaded private key, kid in the header.
  2. constructAppleApiAuthUrl requests scope=name email, which forces response_mode=form_post โ€” so the callback arrives as a browser-submitted POST, not a GET redirect (see the sequence diagram above).
  3. getAccessToken(code) POSTs to /auth/token using the JWT from step 1 as client_secret โ€” same field name as the other three, completely different substance.
  4. getUserInfo(idToken, expectedNonce) is the step none of the other three have any equivalent of: decode the JWT header for kid, fetch/cache Apple's matching public key from /auth/keys, then jwt.verify() the signature (RS256) plus iss/aud/exp, then check the nonce claim against what was stored in session at authorize time. Only after all of that is sub/email trusted โ€” no second network call needed to "ask who the user is," the answer was already inside the verified token.

How to reproduce the ID-Token pattern for Google (Google is the only other provider here capable of it): request scope=openid, read id_token from the token response instead of calling userinfo, decode its kid, fetch/cache Google's JWKS from its OIDC discovery document, and verify with the same jsonwebtoken library already used for Apple โ€” the verification code in apple-http.client.ts's getUserInfo/getApplePublicKey is close to directly reusable, swap the issuer/JWKS URL and the algorithm (Google's OIDC keys are also RS256).

Why no PKCE: not a design choice โ€” Apple's REST API simply has nowhere to put it. Neither /auth/authorize nor /auth/token documents a code_challenge/code_verifier param (checked directly against Apple's own reference pages), so there's no equivalent of X's PKCE step to add here; sending those params anyway would just be silently ignored by Apple's server โ€” no protection, just noise. It isn't a gap in practice: PKCE exists to stop a stolen authorization code from being redeemed by someone who intercepted it but doesn't hold the verifier, and Apple's flow gets the same outcome a different way โ€” a stolen code is still useless without your ES256 client_secret JWT, which only your server can mint, and the returned identity is a cryptographically verified ID Token rather than a bare access token that could be replayed against a userinfo endpoint.

Key takeaways

๐Ÿ”

Only Apple does real OpenID Connect token verification. Google, Facebook, and X are all implemented here as classic OAuth 2.0: get an access token, then make a second authenticated REST call to a /userinfo-shaped endpoint and trust whatever comes back over TLS. Apple is the only provider where the identity claims (sub, email) come from a cryptographically-signed JWT that the backend verifies itself against Apple's published JWKS โ€” no second network round-trip required to establish identity, and no need to trust the transport alone.

๐Ÿ”

Apple's "client secret" isn't a secret at all โ€” it's a self-issued credential. Google/Facebook/X authenticate to their token endpoints with a long-lived static string issued once by the provider. Apple instead requires you to mint a fresh ES256-signed JWT per token request (see "Client secret" above) โ€” functionally the OAuth private_key_jwt client-authentication pattern, where possession of your private signing key stands in for a shared secret. Nothing to leak in a config file that isn't already protected as a private key.

โ†”๏ธ

PKCE and OIDC ID-token verification are solving different problems. X's PKCE protects the authorization code in transit (stops a code interception attack from being redeemable without the verifier). Apple's ID-token verification protects the resulting identity claim itself (stops a forged or replayed identity assertion from being trusted). Google/Facebook get neither in this codebase's implementation โ€” they rely entirely on TLS + a same-request `state` check for their security properties, which is standard for confidential-client server-side OAuth2 but strictly weaker than either PKCE or OIDC token verification on its own.

๐Ÿ“ฎ

Only Apple's callback is a POST. That's a direct consequence of requesting scope=name email โ€” Apple mandates form_post whenever any scope is requested (see the callout in section 1 above), and form_post mechanically requires a POST-capable endpoint on your side, whereas Google/Facebook/X all deliver via a plain GET redirect. This is also why Apple needed its own SameSite=None session-cookie carve-out (see the session-store fix) โ€” Lax, which is sufficient for the other three, does not survive a cross-site POST.

Gotchas worth remembering

1๏ธโƒฃ

user object arrives exactly once โ€” first authorization only. Miss it and there's no re-request path short of the user revoking + re-granting access.

2๏ธโƒฃ

Real user status (LikelyReal / Unknown / Unsupported) is only returned on that same first authorization, and only from iOS 14+/macOS 11+/watchOS 7+/tvOS 14+ clients โ€” treat missing/older as Unsupported, don't block on it.

3๏ธโƒฃ

Email may be empty for Managed Apple Accounts under Apple School Manager (students often have none) โ€” don't hard-require it if you support that population.

4๏ธโƒฃ

Key ID by identifier, not by position or count โ€” the JWKS key set size and contents can change at any time; always match on kid, never assume "the first key" or a fixed count.

5๏ธโƒฃ

Refresh-token liveness checks are rate-limited to about once/day โ€” Apple throttles more frequent verification calls, so don't poll it on every page load.