Apple

Sign in with Apple

OIDC form_post flow for web and mobile — Apple Developer console setup, JWT client secret, POST callback, first-login name capture, private relay email, API reference, and frontend examples.

Overview

Apple Sign In is optional and controlled by APPLE_AUTH_ENABLED. When disabled, Apple endpoints return 503 Service Unavailable. Apple uses OpenID Connect with response_mode=form_post, meaning the callback is a POST (not a GET). The client secret is a short-lived JWT signed with your .p8 key — the passport-apple library handles this automatically. The user's stable ID (appleId) is the sub claim of the id_token. Apple provides the user's name and email only on the very first login; subsequent logins only deliver the id_token. Email may be a private relay address (*@privaterelay.appleid.com). JWTs are never placed in the redirect URL; the API issues a one-time exchangeCode (Redis, 60 seconds). The same POST /auth/exchange endpoint is shared with other social providers.

  • • Feature-flagged via environment
  • • passport-apple strategy + AppleAuthGuard
  • • id_token decoded to extract sub (appleId) and email
  • • POST callback (form_post) — not a GET redirect like other OAuth2 providers
  • • User name sent by Apple on first login only via req.body.user JSON field
  • • Email may be a private relay address — still usable as account identifier
  • • Client secret is a JWT generated from your .p8 private key — handled by passport-apple
  • • Redirect allowlist prevents open redirects
  • • Same token response shape as POST /auth/login

Apple Developer console

Configure Sign in with Apple at developer.apple.com. You need an App ID, a Services ID (client ID), and a private key.

  1. Open Certificates, Identifiers & Profiles in Apple Developer
  2. Create an App ID (Identifiers → App IDs) and enable 'Sign In with Apple' capability
  3. Create a Services ID (Identifiers → Services IDs) — this is your APPLE_CLIENT_ID (e.g. com.example.service). Enable 'Sign In with Apple', configure Domains & Subdomains and Return URLs to match APPLE_CALLBACK_URL
  4. Create a Key (Keys → +) and enable 'Sign In with Apple'. Download the .p8 file — it is only available once
  5. Note: Team ID (top-right of developer.apple.com/account), Key ID (listed next to the key), Services ID
  6. Paste the .p8 file contents into APPLE_PRIVATE_KEY, replacing newlines with literal \n (Docker-safe)
  7. APPLE_CALLBACK_URL must be an HTTPS URL registered in the Services ID return URLs — localhost is allowed only for development with ngrok or similar

Environment variables

Loaded from .env via src/config/apple-oauth.config.ts (namespace appleOAuth on ConfigService). See .env.example. Store APPLE_PRIVATE_KEY with literal \n between lines — the config replaces them with real newlines at startup.

Environment.env
APPLE_AUTH_ENABLED=true
APPLE_CLIENT_ID=com.example.service
APPLE_TEAM_ID=TEAM1234XX
APPLE_KEY_ID=ABCDEFGHIJ
APPLE_PRIVATE_KEY=-----BEGIN EC PRIVATE KEY-----\nMHQC...\n-----END EC PRIVATE KEY-----
APPLE_CALLBACK_URL=https://api.example.com/api/v1/auth/apple/callback
APPLE_REDIRECT_ALLOWLIST=myapp://success,https://app.example.com/auth/callback
APPLE_OAUTH_DEFAULT_ROLES=

Default roles for new Apple users

New Apple users receive roles: [] by default (same as password register). Set APPLE_OAUTH_DEFAULT_ROLES in .env to a comma-separated list of Role values (e.g. user or user,manager). Existing users linked by appleId keep their current roles.

Sequence

  1. Client opens GET /api/v1/auth/apple?redirect=<allowlisted URL>
  2. User consents on Apple; Apple POSTs to POST /api/v1/auth/apple/callback (form_post response mode)
  3. API decodes id_token (sub → appleId, email), reads name from req.body.user on first login, upserts user, stores exchangeCode in Redis, and redirects to redirect?code=...
  4. Client POSTs { code } to /api/v1/auth/exchange and receives JWTs

API reference

1. Start Sign In

Opens Apple Sign In consent. Query redirect must match a prefix in APPLE_REDIRECT_ALLOWLIST (or omit to use the first allowlist entry). Scope: name email.

HTTPGET /api/v1/auth/apple
GET /api/v1/auth/apple?redirect=http%3A%2F%2Flocalhost%3A5173%2Fauth%2Fcallback

400 invalid redirect · 503 feature disabled

2. Callback (POST — form_post)

Apple POSTs to this endpoint after consent. Unlike other OAuth2 providers, this is a POST request. The state parameter (with the redirect URL) is delivered in req.body.state. The user's name is in req.body.user (JSON string) on first login only. The API decodes the id_token and redirects to your app with ?code=.

HTTPPOST /api/v1/auth/apple/callback
POST /api/v1/auth/apple/callback
Content-Type: application/x-www-form-urlencoded
(sent by Apple — form_post response mode)
→ 302 Location: http://localhost:5173/auth/callback?code=EXCHANGE_CODE

3. Exchange code

Public endpoint shared with other social providers. Consumes the code (single use) and returns the same shape as login.

HTTP RequestPOST /api/v1/auth/exchange
POST /api/v1/auth/exchange
Content-Type: application/json

{
  "code": "EXCHANGE_CODE"
}
HTTP Response200 OK
{
  "user": {
    "id": "...",
    "email": "[email protected]",
    "firstName": "Jane",
    "lastName": "Doe",
    "roles": [],
    "appleId": "apple-sub-001",
    "isActive": true
  },
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "refreshToken": "eyJhbGciOiJIUzI1NiIs..."
}

401 invalid/expired/used code · 503 social auth disabled

Frontend — Web (SPA)

Same pattern as other OAuth providers: initiate login in the browser; your callback route reads code from the query string and calls exchange. Note: Apple's callback is a POST handled server-side — your frontend only ever sees the redirect with ?code=.

JavaScriptStart Apple Sign In
const API = 'http://localhost:3000/api/v1';
const redirect = encodeURIComponent(
  'http://localhost:5173/auth/callback'
);

// Full-page redirect to Nest → Apple → back to SPA
window.location.href = `${API}/auth/apple?redirect=${redirect}`;
JavaScriptCallback page /auth/callback
// /auth/callback on the SPA
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
if (!code) throw new Error('Missing exchange code');

const res = await fetch('http://localhost:3000/api/v1/auth/exchange', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ code }),
});
if (!res.ok) throw new Error(await res.text());

const { user, accessToken, refreshToken } = await res.json();
// persist tokens as your app requires
sessionStorage.setItem('accessToken', accessToken);
// Use appleId as the stable identifier — email may be private relay
console.log('signed in', user.appleId);
  • • Add your frontend callback URL to APPLE_REDIRECT_ALLOWLIST (e.g. https://app.example.com/auth/callback)
  • • APPLE_CALLBACK_URL must be an HTTPS URL pointing at the Nest API (registered in Apple Developer)
  • • Apple only sends name on the first login — store it immediately; it will not be sent again
  • • Email may be a private relay address — use appleId as the stable identifier, not email
  • • Store accessToken / refreshToken securely (memory + httpOnly cookie patterns as you prefer)

Frontend — Mobile (deep link)

Use a custom scheme (or universal link) in the allowlist. After Apple Sign In, the API redirects to myapp://success?code=.... The app opens, parses the code, and calls exchange. On iOS you may also use the native AuthenticationServices ASWebAuthenticationSession.

React NativeOpen Sign In in browser
import * as Linking from 'expo-linking';
import * as WebBrowser from 'expo-web-browser';

const API = 'https://api.example.com/api/v1';
const redirect = encodeURIComponent('myapp://success');

async function signInWithApple() {
  const url = `${API}/auth/apple?redirect=${redirect}`;
  await WebBrowser.openAuthSessionAsync(url, 'myapp://success');
}
React NativeHandle deep link + exchange
import * as Linking from 'expo-linking';

async function exchangeFromUrl(url) {
  const { queryParams } = Linking.parse(url);
  const code = queryParams?.code;
  if (!code) return;

  const res = await fetch(`${API}/auth/exchange`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code }),
  });
  const data = await res.json();
  // data.accessToken, data.refreshToken, data.user
  return data;
}

Linking.addEventListener('url', ({ url }) => {
  exchangeFromUrl(url);
});
  • • Register the scheme in the OS / Expo app.json
  • • Prefer Linking.addEventListener('url', ...) and getInitialURL()
  • • Exchange over HTTPS against your API base URL
  • • Identify users by appleId — not by email (may be private relay or change if user revokes)

Security notes

  • • Redirect URLs must match APPLE_REDIRECT_ALLOWLIST prefixes
  • • Exchange codes are single-use and expire in 60 seconds (Redis)
  • • APPLE_PRIVATE_KEY is a secret — never commit it to version control
  • • The JWT client secret is generated per-request from the private key by passport-apple
  • • Apple sends name and email only on the first consent — subsequent logins only deliver id_token
  • • Private relay emails (*@privaterelay.appleid.com) are real and deliverable, but tied to the user's Apple ID
  • • Users can revoke Apple Sign In from device settings — handle account deactivation via isActive
  • • Apple-only users have password null — password login / change-password will fail until a password is set

User model

Postgres entity / interface fields used by Apple Sign In:

  • • appleId — unique nullable Apple subject ID (sub from id_token)
  • • email — from id_token; may be a private relay address
  • • firstName / lastName — from req.body.user on first login; defaults to 'Apple' / '' on subsequent logins
  • • password — nullable for OAuth-only accounts
  • • isActive — set true for new Apple users
  • • roles — from APPLE_OAUTH_DEFAULT_ROLES env (default [])

Related