[GH-ISSUE #1522] Apple Authentication client secrets will eventually expire #17433

Closed
opened 2026-04-15 15:34:02 -05:00 by GiteaMirror · 16 comments
Owner

Originally created by @etler on GitHub (Feb 21, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/1522

Originally assigned to: @ping-maxwell on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  • Set up better auth with apple authentication.
  • Simulate an apple client secret expiring by generating a JWT with a short expiration time
  • Attempt to log in with apple social auth via the auth client

Current vs. Expected behavior

Current: The auth server eventually becomes invalid for apple authentication

Expected: The auth server should not unexpectedly begin failing at a point in the future

What version of Better Auth are you using?

1.1.16

Provide environment information

- Device: Pixel 7 Pro
- OS: Android 15
- App Platform: React Native with Expo managed development build
- Plugin: Expo
- Default Browser: Chrome

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

export const auth = betterAuth({
  appName: "App",
  baseURL: host,
  basePath: "/auth",
  secret: sessionSecret,
  database: new Pool({
    connectionString: dbUrl,
  }),
  logger: {
    disabled: false,
    leve: "debug",
  },
  user: {
    // "user" is a reserved keyword in postgres so we are using plural names for tables
    modelName: "users",
    deleteUser: {
      enabled: true,
      afterDelete: async (user) => {
        await deleteMember(user.email)
      },
    },
  },
  session: {
    modelName: "sessions",
  },
  account: {
    modelName: "accounts",
    accountLinking: {
      enabled: true,
    },
  },
  socialProviders: {
    google: {
      clientId: googleClientId,
      clientSecret: googleClientSecret,
      scope: ["openid", "profile", "email"],
    },
    apple: {
      clientId: appleClientId,
      clientSecret: makeAppleClientSecret({
        clientId: appleClientId,
        teamId: appleTeamId,
        keyId: appleKeyId,
        key: appleKey,
      }),
    },
  },
  plugins: [expo()],
  trustedOrigins: [siteHostname, mobileHostname],
  advanced: {
    cookiePrefix: cookieNamespace,
  },
  secondaryStorage: {
    get: async (key) => cache.get(key),
    set: async (key, value, ttl) => cache.set(key, value, ttl),
    delete: async (key) => cache.delete(key),
  },
})

Additional context

I realized this as I was migrating a previous auth system to better auth. In our previous auth system we generated the JWT on the fly so we had a short expiration time. I didn't notice it when I ported the JWT creation utility for the apple client secret and was having issues with the server becoming invalid.

According to the apple client secret documentation the max expiration is 6 months, and while that's a long time, it will lead to an auth server eventually randomly breaking in a hard to debug way as the error message is also pretty vague.

Also, the better auth documentation for apple auth does not mention this aspect of the expiration date for the JWT client secret value, and in the example, the client secret is pulled from an environment variable. If one were to implement it according to the doc instructions and created a JWT on a one off basis and stored it as a static environment variable value in configuration, it would be easy to make the mistake of not noticing that the value will eventually expire.

The library itself also does not provide a way to generate new client secrets on the fly and only takes a constant string at initialization time, meaning even if you generate a new client secret with a max 6 month expiration time, if the server stays up for a long time, it will eventually fail unexpectedly.

This situation could be prevented if better auth were able to take a generator function as an alternate value for clientSecret to be run on the fly, and the documentation updated to provide an example that includes a generator function to produce a new JWT client secret on each request with additional explanation for why that is needed with the apple client secret.

Originally created by @etler on GitHub (Feb 21, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/1522 Originally assigned to: @ping-maxwell on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce * Set up better auth with [apple authentication](https://www.better-auth.com/docs/authentication/apple). * Simulate an apple client secret expiring by generating a JWT with a short expiration time * Attempt to log in with apple social auth via the auth client ### Current vs. Expected behavior Current: The auth server eventually becomes invalid for apple authentication Expected: The auth server should not unexpectedly begin failing at a point in the future ### What version of Better Auth are you using? 1.1.16 ### Provide environment information ```bash - Device: Pixel 7 Pro - OS: Android 15 - App Platform: React Native with Expo managed development build - Plugin: Expo - Default Browser: Chrome ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript export const auth = betterAuth({ appName: "App", baseURL: host, basePath: "/auth", secret: sessionSecret, database: new Pool({ connectionString: dbUrl, }), logger: { disabled: false, leve: "debug", }, user: { // "user" is a reserved keyword in postgres so we are using plural names for tables modelName: "users", deleteUser: { enabled: true, afterDelete: async (user) => { await deleteMember(user.email) }, }, }, session: { modelName: "sessions", }, account: { modelName: "accounts", accountLinking: { enabled: true, }, }, socialProviders: { google: { clientId: googleClientId, clientSecret: googleClientSecret, scope: ["openid", "profile", "email"], }, apple: { clientId: appleClientId, clientSecret: makeAppleClientSecret({ clientId: appleClientId, teamId: appleTeamId, keyId: appleKeyId, key: appleKey, }), }, }, plugins: [expo()], trustedOrigins: [siteHostname, mobileHostname], advanced: { cookiePrefix: cookieNamespace, }, secondaryStorage: { get: async (key) => cache.get(key), set: async (key, value, ttl) => cache.set(key, value, ttl), delete: async (key) => cache.delete(key), }, }) ``` ### Additional context I realized this as I was migrating a previous auth system to better auth. In our previous auth system we generated the JWT on the fly so we had a short expiration time. I didn't notice it when I ported the JWT creation utility for the apple client secret and was having issues with the server becoming invalid. According to the [apple client secret documentation](https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret) the max expiration is 6 months, and while that's a long time, it will lead to an auth server eventually randomly breaking in a hard to debug way as the error message is also pretty vague. Also, the [better auth documentation for apple auth](https://www.better-auth.com/docs/authentication/apple) does not mention this aspect of the expiration date for the JWT client secret value, and in the example, the client secret is pulled from an environment variable. If one were to implement it according to the doc instructions and created a JWT on a one off basis and stored it as a static environment variable value in configuration, it would be easy to make the mistake of not noticing that the value will eventually expire. The library itself also does not provide a way to generate new client secrets on the fly and only takes a constant string at initialization time, meaning even if you generate a new client secret with a max 6 month expiration time, if the server stays up for a long time, it will eventually fail unexpectedly. This situation could be prevented if better auth were able to take a generator function as an alternate value for `clientSecret` to be run on the fly, and the documentation updated to provide an example that includes a generator function to produce a new JWT client secret on each request with additional explanation for why that is needed with the apple client secret.
GiteaMirror added the lockedbug labels 2026-04-15 15:34:02 -05:00
Author
Owner

@jancbeck commented on GitHub (Mar 22, 2025):

thanks, I just started using apple auth and I appreciate at least knowing that this will happen.

<!-- gh-comment-id:2745150781 --> @jancbeck commented on GitHub (Mar 22, 2025): thanks, I just started using apple auth and I appreciate at least knowing that this will happen.
Author
Owner

@AndrewPrifer commented on GitHub (Apr 18, 2025):

Just ran into this too. +1 for the generator function with the signature () => string | Promise<string>

<!-- gh-comment-id:2816004027 --> @AndrewPrifer commented on GitHub (Apr 18, 2025): Just ran into this too. +1 for the generator function with the signature `() => string | Promise<string>`
Author
Owner

@noah-haub commented on GitHub (Jun 29, 2025):

Just started implementing this and realized it as well.

To me this makes the library pretty much unusable.

100% would also need this to be implemented if possible :)

<!-- gh-comment-id:3016388384 --> @noah-haub commented on GitHub (Jun 29, 2025): Just started implementing this and realized it as well. To me this makes the library pretty much unusable. 100% would also need this to be implemented if possible :)
Author
Owner

@AndrewPrifer commented on GitHub (Jun 29, 2025):

@noah-haub I implemented a modified, correct version of the Apple social provider as a plugin, I’ll post it here when I’m back at the computer.

<!-- gh-comment-id:3016392353 --> @AndrewPrifer commented on GitHub (Jun 29, 2025): @noah-haub I implemented a modified, correct version of the Apple social provider as a plugin, I’ll post it here when I’m back at the computer.
Author
Owner

@noah-haub commented on GitHub (Jun 29, 2025):

@AndrewPrifer oh wow that would be a life safer man 🙌

<!-- gh-comment-id:3016393323 --> @noah-haub commented on GitHub (Jun 29, 2025): @AndrewPrifer oh wow that would be a life safer man 🙌
Author
Owner

@AndrewPrifer commented on GitHub (Jul 2, 2025):

@noah-haub here it is (sorry for the spam everyone). This implementation also abstracts away creating the client secret with the load bearing part being the createAppleClientSecret function so you just provide what Apple gives you and it does the rest. Which I think I copied wholesale from arctic iirc, if you're unhappy with oslo, I'm sure there's a Jose equivalent. There is also no async stuff, there's no reason not to inline the credentials as strings. I use it like this:

plugins: [
  appleProvider({
    clientId: process.env.APPLE_CLIENT_ID as string,
    teamId: process.env.APPLE_TEAM_ID as string,
    keyId: process.env.APPLE_KEY_ID as string,
    pkcs8PrivateKey: process.env.APPLE_CERT as string,
  }),
],

Plugin:

import { betterFetch } from '@better-fetch/fetch';
import { decodeBase64IgnorePadding } from '@oslojs/encoding';
import { encodeJWT, createJWTSignatureMessage } from '@oslojs/jwt';
import {
  createAuthorizationURL,
  validateAuthorizationCode,
  type OAuthProvider,
} from 'better-auth/oauth2';
import { refreshAccessToken } from 'better-auth/oauth2';
import type { BetterAuthPlugin } from 'better-auth/types';
import { APIError } from 'better-call';
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from 'jose';

interface AppleNonConformUser {
  name: {
    firstName: string;
    lastName: string;
  };
  email: string;
}

interface AppleProfile {
  /**
   * The subject registered claim identifies the principal that’s the subject
   * of the identity token. Because this token is for your app, the value is
   * the unique identifier for the user.
   */
  sub: string;
  /**
   * A String value representing the user's email address.
   * The email address is either the user's real email address or the proxy
   * address, depending on their status private email relay service.
   */
  email: string;
  /**
   * A string or Boolean value that indicates whether the service verifies
   * the email. The value can either be a string ("true" or "false") or a
   * Boolean (true or false). The system may not verify email addresses for
   * Sign in with Apple at Work & School users, and this claim is "false" or
   * false for those users.
   */
  email_verified: boolean | 'true' | 'false';
  /**
   * A string or Boolean value that indicates whether the email that the user
   * shares is the proxy address. The value can either be a string ("true" or
   * "false") or a Boolean (true or false).
   */
  is_private_email: boolean;
  /**
   * An Integer value that indicates whether the user appears to be a real
   * person. Use the value of this claim to mitigate fraud. The possible
   * values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal). For
   * more information, see ASUserDetectionStatus. This claim is present only
   * in iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14
   * and later. The claim isn’t present or supported for web-based apps.
   */
  real_user_status: number;
  /**
   * The user’s full name in the format provided during the authorization
   * process.
   */
  name: string;
  /**
   * The URL to the user's profile picture.
   */
  picture: string;
  user?: AppleNonConformUser;
}

interface AppleProviderOptions {
  clientId: string;
  teamId: string;
  keyId: string;
  pkcs8PrivateKey: string;
  redirectURI?: string;
  scope?: {
    email?: boolean;
    name?: boolean;
  };
  mapProfileToUser?: (profile: AppleProfile) => Record<string, string>;
}

/**
 * A plugin that generates Apple client secrets on the fly using your Apple credentials.
 */
export const appleProvider = (options: AppleProviderOptions) => {
  return {
    id: 'apple-provider',
    init: (ctx) => {
      function getClientSecret() {
        return createAppleClientSecret({
          clientId: options.clientId,
          teamId: options.teamId,
          keyId: options.keyId,
          pkcs8PrivateKey: options.pkcs8PrivateKey,
        });
      }
      const _scope = options.scope
        ? Object.entries(options.scope)
            .filter(([_, value]) => value)
            .map(([key]) => key)
        : ['email', 'name'];
      const optionsWithScope = {
        ...options,
        scope: _scope,
      };
      const tokenEndpoint = 'https://appleid.apple.com/auth/token';
      const appleProvider = {
        id: 'apple',
        name: 'Apple',
        async createAuthorizationURL({ state, scopes, redirectURI }) {
          scopes && _scope.push(...scopes);
          const clientSecret = await getClientSecret();
          const url = await createAuthorizationURL({
            id: 'apple',
            options: {
              ...optionsWithScope,
              clientSecret,
            },
            authorizationEndpoint: 'https://appleid.apple.com/auth/authorize',
            scopes: _scope,
            state,
            redirectURI,
            responseMode: 'form_post',
          });
          return url;
        },
        validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
          const clientSecret = await getClientSecret();
          return validateAuthorizationCode({
            code,
            codeVerifier,
            redirectURI,
            options: {
              ...optionsWithScope,
              clientSecret,
            },
            tokenEndpoint,
          });
        },
        async verifyIdToken(token, nonce) {
          const decodedHeader = decodeProtectedHeader(token);
          const { kid, alg: jwtAlg } = decodedHeader;
          if (!kid || !jwtAlg) return false;
          const publicKey = await getApplePublicKey(kid);
          const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
            algorithms: [jwtAlg],
            issuer: 'https://appleid.apple.com',
            audience: options.clientId,
            maxTokenAge: '1h',
          });
          ['email_verified', 'is_private_email'].forEach((field) => {
            if (jwtClaims[field] !== undefined) {
              jwtClaims[field] = Boolean(jwtClaims[field]);
            }
          });
          if (nonce && jwtClaims.nonce !== nonce) {
            return false;
          }
          return !!jwtClaims;
        },
        refreshAccessToken: async (refreshToken) => {
          const clientSecret = await getClientSecret();
          return refreshAccessToken({
            refreshToken,
            options: {
              clientId: options.clientId,
              clientSecret,
            },
            tokenEndpoint: 'https://appleid.apple.com/auth/token',
          });
        },
        async getUserInfo(token) {
          if (!token.idToken) {
            return null;
          }
          const profile = decodeJwt<AppleProfile>(token.idToken);
          if (!profile) {
            return null;
          }
          const name = token.user
            ? `${token.user.name?.firstName} ${token.user.name?.lastName}`
            : profile.name || profile.email;
          const userMap = await options.mapProfileToUser?.(profile);
          return {
            user: {
              id: profile.sub,
              name,
              emailVerified: profile.email_verified === 'true' || profile.email_verified === true,
              email: profile.email,
              ...userMap,
            },
            data: profile,
          };
        },
        options: {
          ...optionsWithScope,
          clientSecret: '',
        },
      } satisfies OAuthProvider<AppleProfile>;
      return {
        context: {
          socialProviders: [
            ...ctx.socialProviders.filter((provider) => provider.id !== 'apple'),
            appleProvider,
          ],
        },
      };
    },
  } satisfies BetterAuthPlugin;
};

const getApplePublicKey = async (kid: string) => {
  const APPLE_BASE_URL = 'https://appleid.apple.com';
  const JWKS_APPLE_URI = '/auth/keys';
  const { data } = await betterFetch<{
    keys: {
      kid: string;
      alg: string;
      kty: string;
      use: string;
      n: string;
      e: string;
    }[];
  }>(`${APPLE_BASE_URL}${JWKS_APPLE_URI}`);
  if (!data?.keys) {
    throw new APIError('BAD_REQUEST', {
      message: 'Keys not found',
    });
  }
  const jwk = data.keys.find((key) => key.kid === kid);
  if (!jwk) {
    throw new Error(`JWK with kid ${kid} not found`);
  }
  return await importJWK(jwk, jwk.alg);
};

export async function createAppleClientSecret({
  clientId,
  teamId,
  keyId,
  pkcs8PrivateKey,
}: {
  clientId: string;
  teamId: string;
  keyId: string;
  pkcs8PrivateKey: string;
}): Promise<string> {
  const privateKey = await crypto.subtle.importKey(
    'pkcs8',
    decodeBase64IgnorePadding(pkcs8PrivateKey),
    {
      name: 'ECDSA',
      namedCurve: 'P-256',
    },
    false,
    ['sign']
  );
  const now = Math.floor(Date.now() / 1000);
  const headerJSON = JSON.stringify({
    typ: 'JWT',
    alg: 'ES256',
    kid: keyId,
  });
  const payloadJSON = JSON.stringify({
    iss: teamId,
    exp: now + 5 * 60,
    aud: ['https://appleid.apple.com'],
    sub: clientId,
    iat: now,
  });
  const signature = new Uint8Array(
    await crypto.subtle.sign(
      {
        name: 'ECDSA',
        hash: 'SHA-256',
      },
      privateKey,
      createJWTSignatureMessage(headerJSON, payloadJSON)
    )
  );
  const token = encodeJWT(headerJSON, payloadJSON, signature);
  return token;
}
<!-- gh-comment-id:3026478618 --> @AndrewPrifer commented on GitHub (Jul 2, 2025): @noah-haub here it is (sorry for the spam everyone). This implementation also abstracts away creating the client secret with the load bearing part being the `createAppleClientSecret` function so you just provide what Apple gives you and it does the rest. Which I think I copied wholesale from arctic iirc, if you're unhappy with oslo, I'm sure there's a Jose equivalent. There is also no async stuff, there's no reason not to inline the credentials as strings. I use it like this: ```ts plugins: [ appleProvider({ clientId: process.env.APPLE_CLIENT_ID as string, teamId: process.env.APPLE_TEAM_ID as string, keyId: process.env.APPLE_KEY_ID as string, pkcs8PrivateKey: process.env.APPLE_CERT as string, }), ], ``` Plugin: ```ts import { betterFetch } from '@better-fetch/fetch'; import { decodeBase64IgnorePadding } from '@oslojs/encoding'; import { encodeJWT, createJWTSignatureMessage } from '@oslojs/jwt'; import { createAuthorizationURL, validateAuthorizationCode, type OAuthProvider, } from 'better-auth/oauth2'; import { refreshAccessToken } from 'better-auth/oauth2'; import type { BetterAuthPlugin } from 'better-auth/types'; import { APIError } from 'better-call'; import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from 'jose'; interface AppleNonConformUser { name: { firstName: string; lastName: string; }; email: string; } interface AppleProfile { /** * The subject registered claim identifies the principal that’s the subject * of the identity token. Because this token is for your app, the value is * the unique identifier for the user. */ sub: string; /** * A String value representing the user's email address. * The email address is either the user's real email address or the proxy * address, depending on their status private email relay service. */ email: string; /** * A string or Boolean value that indicates whether the service verifies * the email. The value can either be a string ("true" or "false") or a * Boolean (true or false). The system may not verify email addresses for * Sign in with Apple at Work & School users, and this claim is "false" or * false for those users. */ email_verified: boolean | 'true' | 'false'; /** * A string or Boolean value that indicates whether the email that the user * shares is the proxy address. The value can either be a string ("true" or * "false") or a Boolean (true or false). */ is_private_email: boolean; /** * An Integer value that indicates whether the user appears to be a real * person. Use the value of this claim to mitigate fraud. The possible * values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal). For * more information, see ASUserDetectionStatus. This claim is present only * in iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14 * and later. The claim isn’t present or supported for web-based apps. */ real_user_status: number; /** * The user’s full name in the format provided during the authorization * process. */ name: string; /** * The URL to the user's profile picture. */ picture: string; user?: AppleNonConformUser; } interface AppleProviderOptions { clientId: string; teamId: string; keyId: string; pkcs8PrivateKey: string; redirectURI?: string; scope?: { email?: boolean; name?: boolean; }; mapProfileToUser?: (profile: AppleProfile) => Record<string, string>; } /** * A plugin that generates Apple client secrets on the fly using your Apple credentials. */ export const appleProvider = (options: AppleProviderOptions) => { return { id: 'apple-provider', init: (ctx) => { function getClientSecret() { return createAppleClientSecret({ clientId: options.clientId, teamId: options.teamId, keyId: options.keyId, pkcs8PrivateKey: options.pkcs8PrivateKey, }); } const _scope = options.scope ? Object.entries(options.scope) .filter(([_, value]) => value) .map(([key]) => key) : ['email', 'name']; const optionsWithScope = { ...options, scope: _scope, }; const tokenEndpoint = 'https://appleid.apple.com/auth/token'; const appleProvider = { id: 'apple', name: 'Apple', async createAuthorizationURL({ state, scopes, redirectURI }) { scopes && _scope.push(...scopes); const clientSecret = await getClientSecret(); const url = await createAuthorizationURL({ id: 'apple', options: { ...optionsWithScope, clientSecret, }, authorizationEndpoint: 'https://appleid.apple.com/auth/authorize', scopes: _scope, state, redirectURI, responseMode: 'form_post', }); return url; }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { const clientSecret = await getClientSecret(); return validateAuthorizationCode({ code, codeVerifier, redirectURI, options: { ...optionsWithScope, clientSecret, }, tokenEndpoint, }); }, async verifyIdToken(token, nonce) { const decodedHeader = decodeProtectedHeader(token); const { kid, alg: jwtAlg } = decodedHeader; if (!kid || !jwtAlg) return false; const publicKey = await getApplePublicKey(kid); const { payload: jwtClaims } = await jwtVerify(token, publicKey, { algorithms: [jwtAlg], issuer: 'https://appleid.apple.com', audience: options.clientId, maxTokenAge: '1h', }); ['email_verified', 'is_private_email'].forEach((field) => { if (jwtClaims[field] !== undefined) { jwtClaims[field] = Boolean(jwtClaims[field]); } }); if (nonce && jwtClaims.nonce !== nonce) { return false; } return !!jwtClaims; }, refreshAccessToken: async (refreshToken) => { const clientSecret = await getClientSecret(); return refreshAccessToken({ refreshToken, options: { clientId: options.clientId, clientSecret, }, tokenEndpoint: 'https://appleid.apple.com/auth/token', }); }, async getUserInfo(token) { if (!token.idToken) { return null; } const profile = decodeJwt<AppleProfile>(token.idToken); if (!profile) { return null; } const name = token.user ? `${token.user.name?.firstName} ${token.user.name?.lastName}` : profile.name || profile.email; const userMap = await options.mapProfileToUser?.(profile); return { user: { id: profile.sub, name, emailVerified: profile.email_verified === 'true' || profile.email_verified === true, email: profile.email, ...userMap, }, data: profile, }; }, options: { ...optionsWithScope, clientSecret: '', }, } satisfies OAuthProvider<AppleProfile>; return { context: { socialProviders: [ ...ctx.socialProviders.filter((provider) => provider.id !== 'apple'), appleProvider, ], }, }; }, } satisfies BetterAuthPlugin; }; const getApplePublicKey = async (kid: string) => { const APPLE_BASE_URL = 'https://appleid.apple.com'; const JWKS_APPLE_URI = '/auth/keys'; const { data } = await betterFetch<{ keys: { kid: string; alg: string; kty: string; use: string; n: string; e: string; }[]; }>(`${APPLE_BASE_URL}${JWKS_APPLE_URI}`); if (!data?.keys) { throw new APIError('BAD_REQUEST', { message: 'Keys not found', }); } const jwk = data.keys.find((key) => key.kid === kid); if (!jwk) { throw new Error(`JWK with kid ${kid} not found`); } return await importJWK(jwk, jwk.alg); }; export async function createAppleClientSecret({ clientId, teamId, keyId, pkcs8PrivateKey, }: { clientId: string; teamId: string; keyId: string; pkcs8PrivateKey: string; }): Promise<string> { const privateKey = await crypto.subtle.importKey( 'pkcs8', decodeBase64IgnorePadding(pkcs8PrivateKey), { name: 'ECDSA', namedCurve: 'P-256', }, false, ['sign'] ); const now = Math.floor(Date.now() / 1000); const headerJSON = JSON.stringify({ typ: 'JWT', alg: 'ES256', kid: keyId, }); const payloadJSON = JSON.stringify({ iss: teamId, exp: now + 5 * 60, aud: ['https://appleid.apple.com'], sub: clientId, iat: now, }); const signature = new Uint8Array( await crypto.subtle.sign( { name: 'ECDSA', hash: 'SHA-256', }, privateKey, createJWTSignatureMessage(headerJSON, payloadJSON) ) ); const token = encodeJWT(headerJSON, payloadJSON, signature); return token; } ```
Author
Owner

@noah-haub commented on GitHub (Jul 3, 2025):

@AndrewPrifer Awesome man, thank you so much for this 🙏

<!-- gh-comment-id:3031156792 --> @noah-haub commented on GitHub (Jul 3, 2025): @AndrewPrifer Awesome man, thank you so much for this 🙏
Author
Owner

@phoenix-error commented on GitHub (Sep 26, 2025):

When will better-auth implement this into their API?

<!-- gh-comment-id:3339185779 --> @phoenix-error commented on GitHub (Sep 26, 2025): When will better-auth implement this into their API?
Author
Owner

@sachithrrra commented on GitHub (Nov 13, 2025):

this makes the library unusable for expo apps imo. apple sign-in is required for app store if you use oauth only. it will be great if there's a direct option without needing a custom plugin :)

<!-- gh-comment-id:3525680964 --> @sachithrrra commented on GitHub (Nov 13, 2025): this makes the library unusable for expo apps imo. apple sign-in is required for app store if you use oauth only. it will be great if there's a direct option without needing a custom plugin :)
Author
Owner

@sachithrrra commented on GitHub (Nov 13, 2025):

@noah-haub here it is (sorry for the spam everyone). This implementation also abstracts away creating the client secret with the load bearing part being the createAppleClientSecret function so you just provide what Apple gives you and it does the rest. Which I think I copied wholesale from arctic iirc, if you're unhappy with oslo, I'm sure there's a Jose equivalent. There is also no async stuff, there's no reason not to inline the credentials as strings. I use it like this:

plugins: [
appleProvider({
clientId: process.env.APPLE_CLIENT_ID as string,
teamId: process.env.APPLE_TEAM_ID as string,
keyId: process.env.APPLE_KEY_ID as string,
pkcs8PrivateKey: process.env.APPLE_CERT as string,
}),
],
Plugin:

import { betterFetch } from '@better-fetch/fetch';
import { decodeBase64IgnorePadding } from '@oslojs/encoding';
import { encodeJWT, createJWTSignatureMessage } from '@oslojs/jwt';
import {
createAuthorizationURL,
validateAuthorizationCode,
type OAuthProvider,
} from 'better-auth/oauth2';
import { refreshAccessToken } from 'better-auth/oauth2';
import type { BetterAuthPlugin } from 'better-auth/types';
import { APIError } from 'better-call';
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from 'jose';

interface AppleNonConformUser {
name: {
firstName: string;
lastName: string;
};
email: string;
}

interface AppleProfile {
/**

  • The subject registered claim identifies the principal that’s the subject
  • of the identity token. Because this token is for your app, the value is
  • the unique identifier for the user.
    /
    sub: string;
    /
    *
  • A String value representing the user's email address.
  • The email address is either the user's real email address or the proxy
  • address, depending on their status private email relay service.
    /
    email: string;
    /
    *
  • A string or Boolean value that indicates whether the service verifies
  • the email. The value can either be a string ("true" or "false") or a
  • Boolean (true or false). The system may not verify email addresses for
  • Sign in with Apple at Work & School users, and this claim is "false" or
  • false for those users.
    /
    email_verified: boolean | 'true' | 'false';
    /
    *
  • A string or Boolean value that indicates whether the email that the user
  • shares is the proxy address. The value can either be a string ("true" or
  • "false") or a Boolean (true or false).
    /
    is_private_email: boolean;
    /
    *
  • An Integer value that indicates whether the user appears to be a real
  • person. Use the value of this claim to mitigate fraud. The possible
  • values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal). For
  • more information, see ASUserDetectionStatus. This claim is present only
  • in iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14
  • and later. The claim isn’t present or supported for web-based apps.
    /
    real_user_status: number;
    /
    *
  • The user’s full name in the format provided during the authorization
  • process.
    /
    name: string;
    /
    *
  • The URL to the user's profile picture.
    */
    picture: string;
    user?: AppleNonConformUser;
    }

interface AppleProviderOptions {
clientId: string;
teamId: string;
keyId: string;
pkcs8PrivateKey: string;
redirectURI?: string;
scope?: {
email?: boolean;
name?: boolean;
};
mapProfileToUser?: (profile: AppleProfile) => Record<string, string>;
}

/**

  • A plugin that generates Apple client secrets on the fly using your Apple credentials.
    */
    export const appleProvider = (options: AppleProviderOptions) => {
    return {
    id: 'apple-provider',
    init: (ctx) => {
    function getClientSecret() {
    return createAppleClientSecret({
    clientId: options.clientId,
    teamId: options.teamId,
    keyId: options.keyId,
    pkcs8PrivateKey: options.pkcs8PrivateKey,
    });
    }
    const scope = options.scope
    ? Object.entries(options.scope)
    .filter(([
    , value]) => value)
    .map(([key]) => key)
    : ['email', 'name'];
    const optionsWithScope = {
    ...options,
    scope: _scope,
    };
    const tokenEndpoint = 'https://appleid.apple.com/auth/token';
    const appleProvider = {
    id: 'apple',
    name: 'Apple',
    async createAuthorizationURL({ state, scopes, redirectURI }) {
    scopes && _scope.push(...scopes);
    const clientSecret = await getClientSecret();
    const url = await createAuthorizationURL({
    id: 'apple',
    options: {
    ...optionsWithScope,
    clientSecret,
    },
    authorizationEndpoint: 'https://appleid.apple.com/auth/authorize',
    scopes: _scope,
    state,
    redirectURI,
    responseMode: 'form_post',
    });
    return url;
    },
    validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => {
    const clientSecret = await getClientSecret();
    return validateAuthorizationCode({
    code,
    codeVerifier,
    redirectURI,
    options: {
    ...optionsWithScope,
    clientSecret,
    },
    tokenEndpoint,
    });
    },
    async verifyIdToken(token, nonce) {
    const decodedHeader = decodeProtectedHeader(token);
    const { kid, alg: jwtAlg } = decodedHeader;
    if (!kid || !jwtAlg) return false;
    const publicKey = await getApplePublicKey(kid);
    const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
    algorithms: [jwtAlg],
    issuer: 'https://appleid.apple.com',
    audience: options.clientId,
    maxTokenAge: '1h',
    });
    ['email_verified', 'is_private_email'].forEach((field) => {
    if (jwtClaims[field] !== undefined) {
    jwtClaims[field] = Boolean(jwtClaims[field]);
    }
    });
    if (nonce && jwtClaims.nonce !== nonce) {
    return false;
    }
    return !!jwtClaims;
    },
    refreshAccessToken: async (refreshToken) => {
    const clientSecret = await getClientSecret();
    return refreshAccessToken({
    refreshToken,
    options: {
    clientId: options.clientId,
    clientSecret,
    },
    tokenEndpoint: 'https://appleid.apple.com/auth/token',
    });
    },
    async getUserInfo(token) {
    if (!token.idToken) {
    return null;
    }
    const profile = decodeJwt(token.idToken);
    if (!profile) {
    return null;
    }
    const name = token.user
    ? ${token.user.name?.firstName} ${token.user.name?.lastName}
    : profile.name || profile.email;
    const userMap = await options.mapProfileToUser?.(profile);
    return {
    user: {
    id: profile.sub,
    name,
    emailVerified: profile.email_verified === 'true' || profile.email_verified === true,
    email: profile.email,
    ...userMap,
    },
    data: profile,
    };
    },
    options: {
    ...optionsWithScope,
    clientSecret: '',
    },
    } satisfies OAuthProvider;
    return {
    context: {
    socialProviders: [
    ...ctx.socialProviders.filter((provider) => provider.id !== 'apple'),
    appleProvider,
    ],
    },
    };
    },
    } satisfies BetterAuthPlugin;
    };

const getApplePublicKey = async (kid: string) => {
const APPLE_BASE_URL = 'https://appleid.apple.com';
const JWKS_APPLE_URI = '/auth/keys';
const { data } = await betterFetch<{
keys: {
kid: string;
alg: string;
kty: string;
use: string;
n: string;
e: string;
}[];
}>(${APPLE_BASE_URL}${JWKS_APPLE_URI});
if (!data?.keys) {
throw new APIError('BAD_REQUEST', {
message: 'Keys not found',
});
}
const jwk = data.keys.find((key) => key.kid === kid);
if (!jwk) {
throw new Error(JWK with kid ${kid} not found);
}
return await importJWK(jwk, jwk.alg);
};

export async function createAppleClientSecret({
clientId,
teamId,
keyId,
pkcs8PrivateKey,
}: {
clientId: string;
teamId: string;
keyId: string;
pkcs8PrivateKey: string;
}): Promise {
const privateKey = await crypto.subtle.importKey(
'pkcs8',
decodeBase64IgnorePadding(pkcs8PrivateKey),
{
name: 'ECDSA',
namedCurve: 'P-256',
},
false,
['sign']
);
const now = Math.floor(Date.now() / 1000);
const headerJSON = JSON.stringify({
typ: 'JWT',
alg: 'ES256',
kid: keyId,
});
const payloadJSON = JSON.stringify({
iss: teamId,
exp: now + 5 * 60,
aud: ['https://appleid.apple.com'],
sub: clientId,
iat: now,
});
const signature = new Uint8Array(
await crypto.subtle.sign(
{
name: 'ECDSA',
hash: 'SHA-256',
},
privateKey,
createJWTSignatureMessage(headerJSON, payloadJSON)
)
);
const token = encodeJWT(headerJSON, payloadJSON, signature);
return token;
}

I tried this and it always gets error # SERVER_ERROR: JWTClaimValidationFailed: unexpected "aud" claim value

https://www.better-auth.com/docs/authentication/apple mentions that On native iOS, it doesn't use the service ID but the app ID (bundle ID) as client ID, so if using the service ID as clientId in signIn.social with idToken, it throws an error: JWTClaimValidationFailed: unexpected "aud" claim value. So you need to provide the appBundleIdentifier when you want to sign in with Apple using the ID Token.

then I updated code to this and result is still same:

import { betterFetch } from "@better-fetch/fetch";
import { decodeBase64IgnorePadding } from "@oslojs/encoding";
import { createJWTSignatureMessage, encodeJWT } from "@oslojs/jwt";
import {
  createAuthorizationURL,
  refreshAccessToken,
  validateAuthorizationCode,
  type OAuthProvider,
} from "better-auth/oauth2";
import type { BetterAuthPlugin } from "better-auth/types";
import { APIError } from "better-call";
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";

interface AppleNonConformUser {
  name: {
    firstName: string;
    lastName: string;
  };
  email: string;
}

interface AppleProfile {
  /**
   * The subject registered claim identifies the principal that’s the subject
   * of the identity token. Because this token is for your app, the value is
   * the unique identifier for the user.
   */
  sub: string;
  /**
   * A String value representing the user's email address.
   * The email address is either the user's real email address or the proxy
   * address, depending on their status private email relay service.
   */
  email: string;
  /**
   * A string or Boolean value that indicates whether the service verifies
   * the email. The value can either be a string ("true" or "false") or a
   * Boolean (true or false). The system may not verify email addresses for
   * Sign in with Apple at Work & School users, and this claim is "false" or
   * false for those users.
   */
  email_verified: boolean | "true" | "false";
  /**
   * A string or Boolean value that indicates whether the email that the user
   * shares is the proxy address. The value can either be a string ("true" or
   * "false") or a Boolean (true or false).
   */
  is_private_email: boolean;
  /**
   * An Integer value that indicates whether the user appears to be a real
   * person. Use the value of this claim to mitigate fraud. The possible
   * values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal). For
   * more information, see ASUserDetectionStatus. This claim is present only
   * in iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14
   * and later. The claim isn’t present or supported for web-based apps.
   */
  real_user_status: number;
  /**
   * The user’s full name in the format provided during the authorization
   * process.
   */
  name: string;
  /**
   * The URL to the user's profile picture.
   */
  picture: string;
  user?: AppleNonConformUser;
}

interface AppleProviderOptions {
  clientId: string;
  teamId: string;
  keyId: string;
  pkcs8PrivateKey: string;
  redirectURI?: string;
  appBundleIdentifier?: string;
  scope?: {
    email?: boolean;
    name?: boolean;
  };
  mapProfileToUser?: (profile: AppleProfile) => Record<string, string>;
}

/**
 * A plugin that generates Apple client secrets on the fly using your Apple credentials.
 */
export const appleProvider = (options: AppleProviderOptions) => {
  return {
    id: "apple-provider",
    init: (ctx) => {
      function getClientSecret() {
        return createAppleClientSecret({
          clientId: options.clientId,
          teamId: options.teamId,
          keyId: options.keyId,
          pkcs8PrivateKey: options.pkcs8PrivateKey,
        });
      }
      const _scope = options.scope
        ? Object.entries(options.scope)
            .filter(([_, value]) => value)
            .map(([key]) => key)
        : ["email", "name"];
      const optionsWithScope = {
        ...options,
        scope: _scope,
      };
      const tokenEndpoint = "https://appleid.apple.com/auth/token";
      const appleProvider = {
        id: "apple",
        name: "Apple",
        async createAuthorizationURL({ state, scopes, redirectURI }) {
          scopes && _scope.push(...scopes);
          const clientSecret = await getClientSecret();
          const url = await createAuthorizationURL({
            id: "apple",
            options: {
              ...optionsWithScope,
              clientSecret,
            },
            authorizationEndpoint: "https://appleid.apple.com/auth/authorize",
            scopes: _scope,
            state,
            redirectURI,
            responseMode: "form_post",
          });
          return url;
        },
        validateAuthorizationCode: async ({
          code,
          codeVerifier,
          redirectURI,
        }) => {
          const clientSecret = await getClientSecret();
          return validateAuthorizationCode({
            code,
            codeVerifier,
            redirectURI,
            options: {
              ...optionsWithScope,
              clientSecret,
            },
            tokenEndpoint,
          });
        },
        async verifyIdToken(token, nonce) {
          const decodedHeader = decodeProtectedHeader(token);
          const { kid, alg: jwtAlg } = decodedHeader;
          if (!kid || !jwtAlg) return false;
          const publicKey = await getApplePublicKey(kid);
          
          // Use appBundleIdentifier for iOS apps only
          const audience = options.appBundleIdentifier;
          
          const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
            algorithms: [jwtAlg],
            issuer: "https://appleid.apple.com",
            audience: audience,
            maxTokenAge: "1h",
          });
          ["email_verified", "is_private_email"].forEach((field) => {
            if (jwtClaims[field] !== undefined) {
              jwtClaims[field] = Boolean(jwtClaims[field]);
            }
          });
          if (nonce && jwtClaims.nonce !== nonce) {
            return false;
          }
          return !!jwtClaims;
        },
        refreshAccessToken: async (refreshToken) => {
          const clientSecret = await getClientSecret();
          return refreshAccessToken({
            refreshToken,
            options: {
              clientId: options.clientId,
              clientSecret,
            },
            tokenEndpoint: "https://appleid.apple.com/auth/token",
          });
        },
        async getUserInfo(token) {
          if (!token.idToken) {
            return null;
          }
          const profile = decodeJwt<AppleProfile>(token.idToken);
          if (!profile) {
            return null;
          }
          const name = token.user
            ? `${token.user.name?.firstName} ${token.user.name?.lastName}`
            : profile.name || profile.email;
          const userMap = await options.mapProfileToUser?.(profile);
          return {
            user: {
              id: profile.sub,
              name,
              emailVerified:
                profile.email_verified === "true" ||
                profile.email_verified === true,
              email: profile.email,
              ...userMap,
            },
            data: profile,
          };
        },
        options: {
          ...optionsWithScope,
          clientSecret: "",
        },
      } satisfies OAuthProvider<AppleProfile>;
      return {
        context: {
          socialProviders: [
            ...ctx.socialProviders.filter(
              (provider) => provider.id !== "apple"
            ),
            appleProvider,
          ],
        },
      };
    },
  } satisfies BetterAuthPlugin;
};

const getApplePublicKey = async (kid: string) => {
  const APPLE_BASE_URL = "https://appleid.apple.com";
  const JWKS_APPLE_URI = "/auth/keys";
  const { data } = await betterFetch<{
    keys: {
      kid: string;
      alg: string;
      kty: string;
      use: string;
      n: string;
      e: string;
    }[];
  }>(`${APPLE_BASE_URL}${JWKS_APPLE_URI}`);
  if (!data?.keys) {
    throw new APIError("BAD_REQUEST", {
      message: "Keys not found",
    });
  }
  const jwk = data.keys.find((key) => key.kid === kid);
  if (!jwk) {
    throw new Error(`JWK with kid ${kid} not found`);
  }
  return await importJWK(jwk, jwk.alg);
};

export async function createAppleClientSecret({
  clientId,
  teamId,
  keyId,
  pkcs8PrivateKey,
}: {
  clientId: string;
  teamId: string;
  keyId: string;
  pkcs8PrivateKey: string;
}): Promise<string> {
  const privateKey = await crypto.subtle.importKey(
    "pkcs8",
    decodeBase64IgnorePadding(pkcs8PrivateKey),
    {
      name: "ECDSA",
      namedCurve: "P-256",
    },
    false,
    ["sign"]
  );
  const now = Math.floor(Date.now() / 1000);
  const headerJSON = JSON.stringify({
    typ: "JWT",
    alg: "ES256",
    kid: keyId,
  });
  const payloadJSON = JSON.stringify({
    iss: teamId,
    exp: now + 5 * 60,
    aud: ["https://appleid.apple.com"],
    sub: clientId,
    iat: now,
  });
  const signature = new Uint8Array(
    await crypto.subtle.sign(
      {
        name: "ECDSA",
        hash: "SHA-256",
      },
      privateKey,
      createJWTSignatureMessage(headerJSON, payloadJSON)
    )
  );
  const token = encodeJWT(headerJSON, payloadJSON, signature);
  return token;
}
import { expo } from "@better-auth/expo";
import { db } from "@rm-server/db";
import * as schema from "@rm-server/db/schema/auth";
import { betterAuth, type BetterAuthOptions } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { env } from "cloudflare:workers";
import { appleProvider } from "./apple-provider";

export const auth = betterAuth<BetterAuthOptions>({
  database: drizzleAdapter(db, {
    provider: "sqlite",

    schema: schema,
  }),
  trustedOrigins: ["myapp://", "exp://*", "https://appleid.apple.com"],
  // uncomment cookieCache setting when ready to deploy to Cloudflare using *.workers.dev domains
  // session: {
  //   cookieCache: {
  //     enabled: true,
  //     maxAge: 60,
  //   },
  // },
  secret: env.BETTER_AUTH_SECRET,
  baseURL: env.BETTER_AUTH_URL,
  plugins: [
    expo({ disableOriginOverride: true }),
    appleProvider({
      clientId: env.APPLE_CLIENT_ID!,
      teamId: env.APPLE_TEAM_ID!,
      keyId: env.APPLE_KEY_ID!,
      pkcs8PrivateKey: env.APPLE_CERT!,
      appBundleIdentifier: env.APPLE_APP_BUNDLE_IDENTIFIER,
    }),
  ],
  user: {
    deleteUser: {
      enabled: true,
    },
  },
  socialProviders: {
    google: {
      enabled: true,
      clientId: env.GOOGLE_CLIENT_ID!,
      clientSecret: env.GOOGLE_CLIENT_SECRET!,
    },
    apple: {
      enabled: true,
      clientId: env.APPLE_CLIENT_ID!,
      clientSecret: "dynamic", // Will be overridden by the custom plugin
    },
  },
  logger: {
    level: "info",
    disabled: false,
  },
});

client:

await authClient.signIn.social({
        provider: "apple",
        idToken: {
          token: identityToken,
          nonce: nonce || undefined,
        },
        callbackURL: "/(app)/(tabs)/home",
      });

updated appBundleIdentifier between myapp and host.exp.Exponent too. error is the same.

any help would be appreciated!

<!-- gh-comment-id:3527441690 --> @sachithrrra commented on GitHub (Nov 13, 2025): > [@noah-haub](https://github.com/noah-haub) here it is (sorry for the spam everyone). This implementation also abstracts away creating the client secret with the load bearing part being the `createAppleClientSecret` function so you just provide what Apple gives you and it does the rest. Which I think I copied wholesale from arctic iirc, if you're unhappy with oslo, I'm sure there's a Jose equivalent. There is also no async stuff, there's no reason not to inline the credentials as strings. I use it like this: > > plugins: [ > appleProvider({ > clientId: process.env.APPLE_CLIENT_ID as string, > teamId: process.env.APPLE_TEAM_ID as string, > keyId: process.env.APPLE_KEY_ID as string, > pkcs8PrivateKey: process.env.APPLE_CERT as string, > }), > ], > Plugin: > > import { betterFetch } from '@better-fetch/fetch'; > import { decodeBase64IgnorePadding } from '@oslojs/encoding'; > import { encodeJWT, createJWTSignatureMessage } from '@oslojs/jwt'; > import { > createAuthorizationURL, > validateAuthorizationCode, > type OAuthProvider, > } from 'better-auth/oauth2'; > import { refreshAccessToken } from 'better-auth/oauth2'; > import type { BetterAuthPlugin } from 'better-auth/types'; > import { APIError } from 'better-call'; > import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from 'jose'; > > interface AppleNonConformUser { > name: { > firstName: string; > lastName: string; > }; > email: string; > } > > interface AppleProfile { > /** > * The subject registered claim identifies the principal that’s the subject > * of the identity token. Because this token is for your app, the value is > * the unique identifier for the user. > */ > sub: string; > /** > * A String value representing the user's email address. > * The email address is either the user's real email address or the proxy > * address, depending on their status private email relay service. > */ > email: string; > /** > * A string or Boolean value that indicates whether the service verifies > * the email. The value can either be a string ("true" or "false") or a > * Boolean (true or false). The system may not verify email addresses for > * Sign in with Apple at Work & School users, and this claim is "false" or > * false for those users. > */ > email_verified: boolean | 'true' | 'false'; > /** > * A string or Boolean value that indicates whether the email that the user > * shares is the proxy address. The value can either be a string ("true" or > * "false") or a Boolean (true or false). > */ > is_private_email: boolean; > /** > * An Integer value that indicates whether the user appears to be a real > * person. Use the value of this claim to mitigate fraud. The possible > * values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal). For > * more information, see ASUserDetectionStatus. This claim is present only > * in iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14 > * and later. The claim isn’t present or supported for web-based apps. > */ > real_user_status: number; > /** > * The user’s full name in the format provided during the authorization > * process. > */ > name: string; > /** > * The URL to the user's profile picture. > */ > picture: string; > user?: AppleNonConformUser; > } > > interface AppleProviderOptions { > clientId: string; > teamId: string; > keyId: string; > pkcs8PrivateKey: string; > redirectURI?: string; > scope?: { > email?: boolean; > name?: boolean; > }; > mapProfileToUser?: (profile: AppleProfile) => Record<string, string>; > } > > /** > * A plugin that generates Apple client secrets on the fly using your Apple credentials. > */ > export const appleProvider = (options: AppleProviderOptions) => { > return { > id: 'apple-provider', > init: (ctx) => { > function getClientSecret() { > return createAppleClientSecret({ > clientId: options.clientId, > teamId: options.teamId, > keyId: options.keyId, > pkcs8PrivateKey: options.pkcs8PrivateKey, > }); > } > const _scope = options.scope > ? Object.entries(options.scope) > .filter(([_, value]) => value) > .map(([key]) => key) > : ['email', 'name']; > const optionsWithScope = { > ...options, > scope: _scope, > }; > const tokenEndpoint = 'https://appleid.apple.com/auth/token'; > const appleProvider = { > id: 'apple', > name: 'Apple', > async createAuthorizationURL({ state, scopes, redirectURI }) { > scopes && _scope.push(...scopes); > const clientSecret = await getClientSecret(); > const url = await createAuthorizationURL({ > id: 'apple', > options: { > ...optionsWithScope, > clientSecret, > }, > authorizationEndpoint: 'https://appleid.apple.com/auth/authorize', > scopes: _scope, > state, > redirectURI, > responseMode: 'form_post', > }); > return url; > }, > validateAuthorizationCode: async ({ code, codeVerifier, redirectURI }) => { > const clientSecret = await getClientSecret(); > return validateAuthorizationCode({ > code, > codeVerifier, > redirectURI, > options: { > ...optionsWithScope, > clientSecret, > }, > tokenEndpoint, > }); > }, > async verifyIdToken(token, nonce) { > const decodedHeader = decodeProtectedHeader(token); > const { kid, alg: jwtAlg } = decodedHeader; > if (!kid || !jwtAlg) return false; > const publicKey = await getApplePublicKey(kid); > const { payload: jwtClaims } = await jwtVerify(token, publicKey, { > algorithms: [jwtAlg], > issuer: 'https://appleid.apple.com', > audience: options.clientId, > maxTokenAge: '1h', > }); > ['email_verified', 'is_private_email'].forEach((field) => { > if (jwtClaims[field] !== undefined) { > jwtClaims[field] = Boolean(jwtClaims[field]); > } > }); > if (nonce && jwtClaims.nonce !== nonce) { > return false; > } > return !!jwtClaims; > }, > refreshAccessToken: async (refreshToken) => { > const clientSecret = await getClientSecret(); > return refreshAccessToken({ > refreshToken, > options: { > clientId: options.clientId, > clientSecret, > }, > tokenEndpoint: 'https://appleid.apple.com/auth/token', > }); > }, > async getUserInfo(token) { > if (!token.idToken) { > return null; > } > const profile = decodeJwt<AppleProfile>(token.idToken); > if (!profile) { > return null; > } > const name = token.user > ? `${token.user.name?.firstName} ${token.user.name?.lastName}` > : profile.name || profile.email; > const userMap = await options.mapProfileToUser?.(profile); > return { > user: { > id: profile.sub, > name, > emailVerified: profile.email_verified === 'true' || profile.email_verified === true, > email: profile.email, > ...userMap, > }, > data: profile, > }; > }, > options: { > ...optionsWithScope, > clientSecret: '', > }, > } satisfies OAuthProvider<AppleProfile>; > return { > context: { > socialProviders: [ > ...ctx.socialProviders.filter((provider) => provider.id !== 'apple'), > appleProvider, > ], > }, > }; > }, > } satisfies BetterAuthPlugin; > }; > > const getApplePublicKey = async (kid: string) => { > const APPLE_BASE_URL = 'https://appleid.apple.com'; > const JWKS_APPLE_URI = '/auth/keys'; > const { data } = await betterFetch<{ > keys: { > kid: string; > alg: string; > kty: string; > use: string; > n: string; > e: string; > }[]; > }>(`${APPLE_BASE_URL}${JWKS_APPLE_URI}`); > if (!data?.keys) { > throw new APIError('BAD_REQUEST', { > message: 'Keys not found', > }); > } > const jwk = data.keys.find((key) => key.kid === kid); > if (!jwk) { > throw new Error(`JWK with kid ${kid} not found`); > } > return await importJWK(jwk, jwk.alg); > }; > > export async function createAppleClientSecret({ > clientId, > teamId, > keyId, > pkcs8PrivateKey, > }: { > clientId: string; > teamId: string; > keyId: string; > pkcs8PrivateKey: string; > }): Promise<string> { > const privateKey = await crypto.subtle.importKey( > 'pkcs8', > decodeBase64IgnorePadding(pkcs8PrivateKey), > { > name: 'ECDSA', > namedCurve: 'P-256', > }, > false, > ['sign'] > ); > const now = Math.floor(Date.now() / 1000); > const headerJSON = JSON.stringify({ > typ: 'JWT', > alg: 'ES256', > kid: keyId, > }); > const payloadJSON = JSON.stringify({ > iss: teamId, > exp: now + 5 * 60, > aud: ['https://appleid.apple.com'], > sub: clientId, > iat: now, > }); > const signature = new Uint8Array( > await crypto.subtle.sign( > { > name: 'ECDSA', > hash: 'SHA-256', > }, > privateKey, > createJWTSignatureMessage(headerJSON, payloadJSON) > ) > ); > const token = encodeJWT(headerJSON, payloadJSON, signature); > return token; > } I tried this and it always gets error ```# SERVER_ERROR: JWTClaimValidationFailed: unexpected "aud" claim value``` https://www.better-auth.com/docs/authentication/apple mentions that ```On native iOS, it doesn't use the service ID but the app ID (bundle ID) as client ID, so if using the service ID as clientId in signIn.social with idToken, it throws an error: JWTClaimValidationFailed: unexpected "aud" claim value. So you need to provide the appBundleIdentifier when you want to sign in with Apple using the ID Token.``` then I updated code to this and result is still same: ```javascript import { betterFetch } from "@better-fetch/fetch"; import { decodeBase64IgnorePadding } from "@oslojs/encoding"; import { createJWTSignatureMessage, encodeJWT } from "@oslojs/jwt"; import { createAuthorizationURL, refreshAccessToken, validateAuthorizationCode, type OAuthProvider, } from "better-auth/oauth2"; import type { BetterAuthPlugin } from "better-auth/types"; import { APIError } from "better-call"; import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; interface AppleNonConformUser { name: { firstName: string; lastName: string; }; email: string; } interface AppleProfile { /** * The subject registered claim identifies the principal that’s the subject * of the identity token. Because this token is for your app, the value is * the unique identifier for the user. */ sub: string; /** * A String value representing the user's email address. * The email address is either the user's real email address or the proxy * address, depending on their status private email relay service. */ email: string; /** * A string or Boolean value that indicates whether the service verifies * the email. The value can either be a string ("true" or "false") or a * Boolean (true or false). The system may not verify email addresses for * Sign in with Apple at Work & School users, and this claim is "false" or * false for those users. */ email_verified: boolean | "true" | "false"; /** * A string or Boolean value that indicates whether the email that the user * shares is the proxy address. The value can either be a string ("true" or * "false") or a Boolean (true or false). */ is_private_email: boolean; /** * An Integer value that indicates whether the user appears to be a real * person. Use the value of this claim to mitigate fraud. The possible * values are: 0 (or Unsupported), 1 (or Unknown), 2 (or LikelyReal). For * more information, see ASUserDetectionStatus. This claim is present only * in iOS 14 and later, macOS 11 and later, watchOS 7 and later, tvOS 14 * and later. The claim isn’t present or supported for web-based apps. */ real_user_status: number; /** * The user’s full name in the format provided during the authorization * process. */ name: string; /** * The URL to the user's profile picture. */ picture: string; user?: AppleNonConformUser; } interface AppleProviderOptions { clientId: string; teamId: string; keyId: string; pkcs8PrivateKey: string; redirectURI?: string; appBundleIdentifier?: string; scope?: { email?: boolean; name?: boolean; }; mapProfileToUser?: (profile: AppleProfile) => Record<string, string>; } /** * A plugin that generates Apple client secrets on the fly using your Apple credentials. */ export const appleProvider = (options: AppleProviderOptions) => { return { id: "apple-provider", init: (ctx) => { function getClientSecret() { return createAppleClientSecret({ clientId: options.clientId, teamId: options.teamId, keyId: options.keyId, pkcs8PrivateKey: options.pkcs8PrivateKey, }); } const _scope = options.scope ? Object.entries(options.scope) .filter(([_, value]) => value) .map(([key]) => key) : ["email", "name"]; const optionsWithScope = { ...options, scope: _scope, }; const tokenEndpoint = "https://appleid.apple.com/auth/token"; const appleProvider = { id: "apple", name: "Apple", async createAuthorizationURL({ state, scopes, redirectURI }) { scopes && _scope.push(...scopes); const clientSecret = await getClientSecret(); const url = await createAuthorizationURL({ id: "apple", options: { ...optionsWithScope, clientSecret, }, authorizationEndpoint: "https://appleid.apple.com/auth/authorize", scopes: _scope, state, redirectURI, responseMode: "form_post", }); return url; }, validateAuthorizationCode: async ({ code, codeVerifier, redirectURI, }) => { const clientSecret = await getClientSecret(); return validateAuthorizationCode({ code, codeVerifier, redirectURI, options: { ...optionsWithScope, clientSecret, }, tokenEndpoint, }); }, async verifyIdToken(token, nonce) { const decodedHeader = decodeProtectedHeader(token); const { kid, alg: jwtAlg } = decodedHeader; if (!kid || !jwtAlg) return false; const publicKey = await getApplePublicKey(kid); // Use appBundleIdentifier for iOS apps only const audience = options.appBundleIdentifier; const { payload: jwtClaims } = await jwtVerify(token, publicKey, { algorithms: [jwtAlg], issuer: "https://appleid.apple.com", audience: audience, maxTokenAge: "1h", }); ["email_verified", "is_private_email"].forEach((field) => { if (jwtClaims[field] !== undefined) { jwtClaims[field] = Boolean(jwtClaims[field]); } }); if (nonce && jwtClaims.nonce !== nonce) { return false; } return !!jwtClaims; }, refreshAccessToken: async (refreshToken) => { const clientSecret = await getClientSecret(); return refreshAccessToken({ refreshToken, options: { clientId: options.clientId, clientSecret, }, tokenEndpoint: "https://appleid.apple.com/auth/token", }); }, async getUserInfo(token) { if (!token.idToken) { return null; } const profile = decodeJwt<AppleProfile>(token.idToken); if (!profile) { return null; } const name = token.user ? `${token.user.name?.firstName} ${token.user.name?.lastName}` : profile.name || profile.email; const userMap = await options.mapProfileToUser?.(profile); return { user: { id: profile.sub, name, emailVerified: profile.email_verified === "true" || profile.email_verified === true, email: profile.email, ...userMap, }, data: profile, }; }, options: { ...optionsWithScope, clientSecret: "", }, } satisfies OAuthProvider<AppleProfile>; return { context: { socialProviders: [ ...ctx.socialProviders.filter( (provider) => provider.id !== "apple" ), appleProvider, ], }, }; }, } satisfies BetterAuthPlugin; }; const getApplePublicKey = async (kid: string) => { const APPLE_BASE_URL = "https://appleid.apple.com"; const JWKS_APPLE_URI = "/auth/keys"; const { data } = await betterFetch<{ keys: { kid: string; alg: string; kty: string; use: string; n: string; e: string; }[]; }>(`${APPLE_BASE_URL}${JWKS_APPLE_URI}`); if (!data?.keys) { throw new APIError("BAD_REQUEST", { message: "Keys not found", }); } const jwk = data.keys.find((key) => key.kid === kid); if (!jwk) { throw new Error(`JWK with kid ${kid} not found`); } return await importJWK(jwk, jwk.alg); }; export async function createAppleClientSecret({ clientId, teamId, keyId, pkcs8PrivateKey, }: { clientId: string; teamId: string; keyId: string; pkcs8PrivateKey: string; }): Promise<string> { const privateKey = await crypto.subtle.importKey( "pkcs8", decodeBase64IgnorePadding(pkcs8PrivateKey), { name: "ECDSA", namedCurve: "P-256", }, false, ["sign"] ); const now = Math.floor(Date.now() / 1000); const headerJSON = JSON.stringify({ typ: "JWT", alg: "ES256", kid: keyId, }); const payloadJSON = JSON.stringify({ iss: teamId, exp: now + 5 * 60, aud: ["https://appleid.apple.com"], sub: clientId, iat: now, }); const signature = new Uint8Array( await crypto.subtle.sign( { name: "ECDSA", hash: "SHA-256", }, privateKey, createJWTSignatureMessage(headerJSON, payloadJSON) ) ); const token = encodeJWT(headerJSON, payloadJSON, signature); return token; } ``` ```javascript import { expo } from "@better-auth/expo"; import { db } from "@rm-server/db"; import * as schema from "@rm-server/db/schema/auth"; import { betterAuth, type BetterAuthOptions } from "better-auth"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { env } from "cloudflare:workers"; import { appleProvider } from "./apple-provider"; export const auth = betterAuth<BetterAuthOptions>({ database: drizzleAdapter(db, { provider: "sqlite", schema: schema, }), trustedOrigins: ["myapp://", "exp://*", "https://appleid.apple.com"], // uncomment cookieCache setting when ready to deploy to Cloudflare using *.workers.dev domains // session: { // cookieCache: { // enabled: true, // maxAge: 60, // }, // }, secret: env.BETTER_AUTH_SECRET, baseURL: env.BETTER_AUTH_URL, plugins: [ expo({ disableOriginOverride: true }), appleProvider({ clientId: env.APPLE_CLIENT_ID!, teamId: env.APPLE_TEAM_ID!, keyId: env.APPLE_KEY_ID!, pkcs8PrivateKey: env.APPLE_CERT!, appBundleIdentifier: env.APPLE_APP_BUNDLE_IDENTIFIER, }), ], user: { deleteUser: { enabled: true, }, }, socialProviders: { google: { enabled: true, clientId: env.GOOGLE_CLIENT_ID!, clientSecret: env.GOOGLE_CLIENT_SECRET!, }, apple: { enabled: true, clientId: env.APPLE_CLIENT_ID!, clientSecret: "dynamic", // Will be overridden by the custom plugin }, }, logger: { level: "info", disabled: false, }, }); ``` client: ```javascript await authClient.signIn.social({ provider: "apple", idToken: { token: identityToken, nonce: nonce || undefined, }, callbackURL: "/(app)/(tabs)/home", }); ``` updated appBundleIdentifier between ``myapp`` and ``host.exp.Exponent`` too. error is the same. any help would be appreciated!
Author
Owner

@raymondpattend commented on GitHub (Jan 18, 2026):

Bump to this, still clearly a major issue.

<!-- gh-comment-id:3764966760 --> @raymondpattend commented on GitHub (Jan 18, 2026): Bump to this, still clearly a major issue.
Author
Owner

@emiryumak commented on GitHub (Mar 2, 2026):

Any updates?

<!-- gh-comment-id:3983880844 --> @emiryumak commented on GitHub (Mar 2, 2026): Any updates?
Author
Owner

@boostvolt commented on GitHub (Mar 9, 2026):

No custom plugin needed. The built-in provider + awaitable config pattern (#4829) handles everything:

import { importPKCS8, SignJWT } from "jose";

async function generateAppleClientSecret(clientId, teamId, keyId, privateKey) {
  const key = await importPKCS8(privateKey, "ES256");
  const now = Math.floor(Date.now() / 1000);
  return new SignJWT({})
    .setProtectedHeader({ alg: "ES256", kid: keyId })
    .setIssuer(teamId)
    .setSubject(clientId)
    .setAudience("https://appleid.apple.com")
    .setIssuedAt(now)
    .setExpirationTime(now + 180 * 24 * 60 * 60)
    .sign(key);
}

// auth config
socialProviders: {
  apple: async () => ({
    clientId: env.APPLE_CLIENT_ID,
    clientSecret: await generateAppleClientSecret(
      env.APPLE_CLIENT_ID,
      env.APPLE_TEAM_ID,
      env.APPLE_KEY_ID,
      env.APPLE_PRIVATE_KEY,
    ),
    appBundleIdentifier: env.APPLE_APP_BUNDLE_ID,
  }),
},

Fresh secret on every deploy, appBundleIdentifier fixes the native iOS aud mismatch. ~20 lines, jose only dep (already used internally by better-auth).

<!-- gh-comment-id:4025925307 --> @boostvolt commented on GitHub (Mar 9, 2026): No custom plugin needed. The built-in provider + awaitable config pattern ([#4829](https://github.com/better-auth/better-auth/pull/4829)) handles everything: ```ts import { importPKCS8, SignJWT } from "jose"; async function generateAppleClientSecret(clientId, teamId, keyId, privateKey) { const key = await importPKCS8(privateKey, "ES256"); const now = Math.floor(Date.now() / 1000); return new SignJWT({}) .setProtectedHeader({ alg: "ES256", kid: keyId }) .setIssuer(teamId) .setSubject(clientId) .setAudience("https://appleid.apple.com") .setIssuedAt(now) .setExpirationTime(now + 180 * 24 * 60 * 60) .sign(key); } // auth config socialProviders: { apple: async () => ({ clientId: env.APPLE_CLIENT_ID, clientSecret: await generateAppleClientSecret( env.APPLE_CLIENT_ID, env.APPLE_TEAM_ID, env.APPLE_KEY_ID, env.APPLE_PRIVATE_KEY, ), appBundleIdentifier: env.APPLE_APP_BUNDLE_ID, }), }, ``` Fresh secret on every deploy, `appBundleIdentifier` fixes the native iOS `aud` mismatch. ~20 lines, `jose` only dep (already used internally by better-auth).
Author
Owner

@ping-maxwell commented on GitHub (Mar 25, 2026):

I'm closing this issue, I'll update the documentation to show @boostvolt's solution.

<!-- gh-comment-id:4125884559 --> @ping-maxwell commented on GitHub (Mar 25, 2026): I'm closing this issue, I'll update the documentation to show @boostvolt's solution.
Author
Owner

@AndrewPrifer commented on GitHub (Mar 25, 2026):

I'm happy that this issue is receiving attention and I'm glad to see solutions proposed, but the currently accepted solution (and the one now proposed in the docs) doesn't address the core concern of the issue--which is that Apple secrets expire in 6 months, making it necessary to regenerate them at reliable intervals. While the accepted solution does make generating the secret more ergonomic (not reliant on e.g. a CI step), it is not a durable solution to the above problem because it is not guaranteed that the server will be restarted/redeployed every 6 months. I propose reopening this issue until this problem is addressed. At the minimum for this to be fixed, better-auth needs to provide an obvious way to reconfigure the Apple client secret, and it needs to be noted in the docs that this needs to happen every <6 months. However, I'd rather this happen automatically and not turn into a foot gun.

(You can find a correct solution here. If frequently regenerating the secret is a concern, it should be cached with a lifetime of 6 months.)

<!-- gh-comment-id:4126222652 --> @AndrewPrifer commented on GitHub (Mar 25, 2026): I'm happy that this issue is receiving attention and I'm glad to see solutions proposed, but the currently accepted solution (and the one now proposed in the docs) doesn't address the core concern of the issue--which is that Apple secrets expire in 6 months, making it necessary to regenerate them at reliable intervals. While the accepted solution does make generating the secret more ergonomic (not reliant on e.g. a CI step), it is not a durable solution to the above problem because it is not guaranteed that the server will be restarted/redeployed every 6 months. I propose reopening this issue until this problem is addressed. At the minimum for this to be fixed, better-auth needs to provide an obvious way to reconfigure the Apple client secret, and it needs to be noted in the docs that this needs to happen every <6 months. However, I'd rather this happen automatically and not turn into a foot gun. (You can find a correct solution [here](https://github.com/better-auth/better-auth/issues/1522#issuecomment-3026478618). If frequently regenerating the secret is a concern, it should be cached with a lifetime of 6 months.)
Author
Owner

@github-actions[bot] commented on GitHub (Apr 2, 2026):

This issue has been locked as it was closed more than 7 days ago. If you're experiencing a similar problem or you have additional context, please open a new issue and reference this one.

<!-- gh-comment-id:4173718861 --> @github-actions[bot] commented on GitHub (Apr 2, 2026): This issue has been locked as it was closed more than 7 days ago. If you're experiencing a similar problem or you have additional context, please open a new issue and reference this one.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#17433