Skip to main content

Apple OAuth

WIP

Sign in with Apple. OIDC-compliant. iOS apps require this if you ship any other social sign-in.

Setup

  1. Sign in at developer.apple.com with a paid Apple Developer account.
  2. Create an App ID under Certificates, Identifiers & Profiles.
  3. Enable Sign in with Apple for the App ID.
  4. Create a Services ID for your web app. Note the identifier (this is your clientId).
  5. Add your Domains & Subdomains and Return URLs: https://app.example.com/auth/apple/callback.
  6. Create a Sign in with Apple key (a .p8 file). Note the key id and your team id.
import { authApple } from '@gentleduck/auth/providers/oauth/apple'
import { readFileSync } from 'node:fs'

export const auth = new AuthEngine({
  // ...
  providers: [
    authApple({
      clientId: 'com.example.app',                            // Services ID
      teamId: process.env.APPLE_TEAM_ID!,                     // 10-character team id
      keyId: process.env.APPLE_KEY_ID!,                       // 10-character key id
      privateKey: readFileSync('./AuthKey_KEYID.p8', 'utf-8'), // .p8 file contents
      redirectUri: 'https://app.example.com/auth/apple/callback',
      stateSigningSecret: process.env.OAUTH_STATE_SECRET!,
      scopes: ['name', 'email'],
      onSignIn: async ({ profile }) => {
        let identity = await auth.identities.findByProviderSub({
          providerId: 'apple',
          sub: profile.sub,
        })
        if (!identity) {
          identity = await auth.identities.create({
            email: profile.email,
            emailVerified: true,
            profile: {
              displayName: profile.name ?? profile.email.split('@')[0],
            },
          })
          await auth.identities.linkProvider(identity.id, {
            providerId: 'apple',
            sub: profile.sub,
          })
        }
        return { identityId: identity.id }
      },
    }),
  ],
})

How Apple is different

  • No userinfo endpoint. All claims arrive in the id_token (JWT). duck-auth verifies the JWT against Apple's JWKS (https://appleid.apple.com/auth/keys) and reads the claims directly.
  • Name is only sent on first sign-in. Apple includes given_name / family_name only when the user first authorizes your app. On subsequent sign-ins, those fields are missing. Persist them on first sign-in.
  • Private relay emails. Users can choose to hide their real email behind a relay address like xxxx@privaterelay.appleid.com. Mail sent to it is forwarded; the relay maps to the user's real account. You can never see the real email.
  • form_post callback. Apple POSTs the callback as application/x-www-form-urlencoded, not GET. Your callback route needs to accept POST:
// Express
app.post('/auth/apple/callback', authMountSignIn(auth))

// Next.js
// app/auth/apple/callback/route.ts
export const POST = authNextSignIn(auth)

Required: ASCII Apple JS button

Apple's HIG requires a specific button style. duck-auth doesn't ship UI; use Apple's official JS SDK to render the button, or follow the HIG asset bundle.

What the id_token contains

{
  iss: 'https://appleid.apple.com',
  aud: 'com.example.app',
  exp: 1716800900,
  iat: 1716800000,
  sub: 'A1B2C3D4-...',          // stable user id; opaque
  email: 'ada@example.com',     // or relay address
  email_verified: 'true',       // strings, not booleans
  is_private_email: 'false',    // 'true' if relay
  // First sign-in only:
  given_name: 'Ada',
  family_name: 'Lovelace',
}

Common errors

Errorduck-auth codeCause
invalid_clientAUTH/PROVIDER_FAILEDServices ID, team ID, or key ID wrong.
invalid_grantAUTH/PROVIDER_FAILEDCode reused or expired.
unauthorized_clientAUTH/PROVIDER_FAILEDThe Services ID isn't enabled for Sign in with Apple.