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
1. Request an authorization
| 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
| 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:
- JWS signature (ES256) against Apple's public key, matched by
kid issis exactlyhttps://appleid.apple.comaudequals yourclient_idexpis in the futurenoncematches 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
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
| 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.
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 |
|---|---|---|---|---|---|
| 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 |
|
| 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:
oAuthClient.generateAuthUrl({ scope: '.../userinfo.email', state })builds the redirect URL.- Google 302-redirects the browser back to
redirect_uri?code=...&state=...โ a normal GET, noform_postinvolved. oAuthClient.getToken(code)exchanges the code for{ tokens }, authenticating with the staticclient_secretfromGoogleConfig.google.oauth2('v2').userinfo.get({ oauth_token: tokens.access_token })makes a second call to fetchemail/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):
constructFbApiAuthUrlbuildshttps://www.facebook.com/v23.0/dialog/oauth?scope=email&state=....- Facebook 302-redirects back with
code/statein the query string, same GET shape as Google. getAccessToken(code)calls Graph'sGET oauth/access_tokenwithclient_id/client_secret/codeas query params (not a POST body like the other three โ Facebook's own token endpoint happens to accept it as a GET).getUserInfo(accessToken)calls Graph'sGET /me?fields=id,email,namewith 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):
generatePkceChallenge():codeVerifier = randomBytes(32).toString('base64url'),codeChallenge = sha256(codeVerifier)(also base64url).constructXApiAuthUrl(state, codeChallenge)sendscode_challenge+code_challenge_method=S256alongside the usualstate.- The controller stashes
codeVerifierin the session aspkceCodeVerifier(SessionOauthLogin/SessionOauthRegistration) โ never sent to X, only used at the final step. - X 302-redirects back with
code/state, plain GET, same as Google/Facebook. getAccessToken(code, codeVerifier)exchanges the code, sending the originalcode_verifierso X can hash it and confirm it matches thecode_challengefrom step 2 โ this is what makes a bare stolencodeuseless to an attacker.getUserInfo(accessToken)calls X API v2'sGET /2/users/mewith 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):
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,kidin the header.constructAppleApiAuthUrlrequestsscope=name email, which forcesresponse_mode=form_postโ so the callback arrives as a browser-submitted POST, not a GET redirect (see the sequence diagram above).getAccessToken(code)POSTs to/auth/tokenusing the JWT from step 1 asclient_secretโ same field name as the other three, completely different substance.getUserInfo(idToken, expectedNonce)is the step none of the other three have any equivalent of: decode the JWT header forkid, fetch/cache Apple's matching public key from/auth/keys, thenjwt.verify()the signature (RS256) plusiss/aud/exp, then check thenonceclaim against what was stored in session at authorize time. Only after all of that issub/emailtrusted โ 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
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.
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.
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.
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.
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.