Phone Number plugin does not work with Expo integration #1932

Closed
opened 2026-03-13 09:13:20 -05:00 by GiteaMirror · 5 comments
Owner

Originally created by @jinsley8 on GitHub (Sep 15, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Install phone number plugin in auth and auth-client configs in an Expo app.

Try to use authClient.phoneNumber.sendOtp() in any file:

import { authClient } from "@/lib/auth-client";
await authClient.phoneNumber.sendOtp({
    phoneNumber: formattedPhone,
});

It immediately causes an Expo error:

 ERROR  Warning: TypeError: Cannot read property 'subtle' of undefined, js engine: hermes

  18 |             type = api_1.interopComponents.get(type) ?? type;
  19 |         }
> 20 |         return jsx.call(jsx, type, props, ...rest);
     |                        ^
  21 |     };
  22 | }
  23 | //# sourceMappingURL=wrap-jsx.js.map

Call Stack
  call (apps/mobile/<native>)
  apply (apps/mobile/<native>)
  <anonymous> (node_modules/react-native-css-interop/dist/runtime/wrap-jsx.js:20:24)
  ExpoRoot (node_modules/expo-router/build/ExpoRoot.js:82:12)
  call (apps/mobile/<native>)
  apply (apps/mobile/<native>)
  <anonymous> (node_modules/react-native-css-interop/dist/runtime/wrap-jsx.js:20:24)
  App (node_modules/expo-router/build/qualified-entry.js:21:7)
  call (apps/mobile/<native>)
  apply (apps/mobile/<native>)
  <anonymous> (node_modules/react-native-css-interop/dist/runtime/wrap-jsx.js:20:24)
  ErrorOverlay (node_modules/@expo/metro-runtime/src/error-overlay/index.tsx:19:9)
  call (apps/mobile/<native>)
  apply (apps/mobile/<native>)
  <anonymous> (node_modules/react-native-css-interop/dist/runtime/wrap-jsx.js:20:24)
  WithDevTools (node_modules/expo/src/launch/withDevTools.ios.tsx:38:12)

Current vs. Expected behavior

Expected phone number plugin to work with Expo so I can integrate Twilio Verify to allow login with SMS

What version of Better Auth are you using?

1.3.9

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:29 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6000",
    "release": "24.6.0",
    "cpuCount": 10,
    "cpuModel": "Apple M1 Pro",
    "totalMemory": "32.00 GB",
    "freeMemory": "0.34 GB"
  },
  "node": {
    "version": "v22.16.0",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.15.1"
  },
  "frameworks": null,
  "databases": [
    {
      "name": "postgres",
      "version": "^3.4.7"
    },
    {
      "name": "drizzle",
      "version": "catalog:"
    }
  ],
  "betterAuth": {
    "version": "1.0.0",
    "config": {
      "trustedOrigins": [
        "http://localhost:3001"
      ],
      "baseURL": "http://localhost:8787",
      "secret": "[REDACTED]",
      "user": {
        "additionalFields": {
          "firstName": {
            "type": "string",
            "required": false
          },
          "lastName": {
            "type": "string",
            "required": false
          },
          "preferredName": {
            "type": "string",
            "required": false
          },
          "phoneNumberVerified": {
            "type": "boolean",
            "required": false
          }
        }
      },
      "emailAndPassword": {
        "enabled": true,
        "requireEmailVerification": true
      },
      "socialProviders": {
        "google": {
          "clientId": "",
          "clientSecret": ""
        }
      },
      "plugins": [
        {
          "name": "expo",
          "config": {
            "id": "expo",
            "hooks": {
              "after": [
                {}
              ]
            }
          }
        },
        {
          "name": "phone-number",
          "config": {
            "id": "phone-number",
            "endpoints": {},
            "schema": {
              "user": {
                "fields": {
                  "phoneNumber": {
                    "type": "string",
                    "required": false,
                    "unique": true,
                    "sortable": true,
                    "returned": true
                  },
                  "phoneNumberVerified": {
                    "type": "boolean",
                    "required": false,
                    "returned": true,
                    "input": false
                  }
                }
              }
            },
            "rateLimit": [
              {
                "window": 60000,
                "max": 10
              }
            ],
            "$ERROR_CODES": {
              "INVALID_PHONE_NUMBER": "Invalid phone number",
              "PHONE_NUMBER_EXIST": "Phone number already exists",
              "INVALID_PHONE_NUMBER_OR_PASSWORD": "[REDACTED]",
              "UNEXPECTED_ERROR": "Unexpected error",
              "OTP_NOT_FOUND": "OTP not found",
              "OTP_EXPIRED": "OTP expired",
              "INVALID_OTP": "Invalid OTP",
              "PHONE_NUMBER_NOT_VERIFIED": "Phone number not verified"
            }
          }
        },
        {
          "name": "username",
          "config": {
            "id": "username",
            "endpoints": {},
            "schema": {
              "user": {
                "fields": {
                  "username": {
                    "type": "string",
                    "required": false,
                    "sortable": true,
                    "unique": true,
                    "returned": true,
                    "transform": {}
                  },
                  "displayUsername": {
                    "type": "string",
                    "required": false,
                    "transform": {}
                  }
                }
              }
            },
            "hooks": {
              "before": [
                {},
                {}
              ]
            },
            "$ERROR_CODES": {
              "INVALID_USERNAME_OR_PASSWORD": "[REDACTED]",
              "EMAIL_NOT_VERIFIED": "Email not verified",
              "UNEXPECTED_ERROR": "Unexpected error",
              "USERNAME_IS_ALREADY_TAKEN": "Username is already taken. Please try another.",
              "USERNAME_TOO_SHORT": "Username is too short",
              "USERNAME_TOO_LONG": "Username is too long",
              "INVALID_USERNAME": "Username is invalid",
              "INVALID_DISPLAY_USERNAME": "Display username is invalid"
            }
          }
        },
        {
          "name": "admin",
          "config": {
            "id": "admin",
            "hooks": {
              "after": [
                {}
              ]
            },
            "endpoints": {},
            "$ERROR_CODES": {
              "FAILED_TO_CREATE_USER": "Failed to create user",
              "USER_ALREADY_EXISTS": "User already exists. Use another email.",
              "YOU_CANNOT_BAN_YOURSELF": "You cannot ban yourself",
              "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role",
              "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS": "You are not allowed to create users",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS": "You are not allowed to list users",
              "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS": "You are not allowed to list users sessions",
              "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS": "You are not allowed to ban users",
              "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS": "You are not allowed to impersonate users",
              "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS": "You are not allowed to revoke users sessions",
              "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS": "You are not allowed to delete users",
              "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD": "[REDACTED]",
              "BANNED_USER": "You have been banned from this application",
              "YOU_ARE_NOT_ALLOWED_TO_GET_USER": "You are not allowed to get user",
              "NO_DATA_TO_UPDATE": "No data to update",
              "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS": "You are not allowed to update users",
              "YOU_CANNOT_REMOVE_YOURSELF": "You cannot remove yourself"
            },
            "schema": {
              "user": {
                "fields": {
                  "role": {
                    "type": "string",
                    "required": false,
                    "input": false
                  },
                  "banned": {
                    "type": "boolean",
                    "defaultValue": false,
                    "required": false,
                    "input": false
                  },
                  "banReason": {
                    "type": "string",
                    "required": false,
                    "input": false
                  },
                  "banExpires": {
                    "type": "date",
                    "required": false,
                    "input": false
                  }
                }
              },
              "session": {
                "fields": {
                  "impersonatedBy": {
                    "type": "string",
                    "required": false
                  }
                }
              }
            },
            "options": {
              "defaultRole": "user",
              "adminRoles": [
                "superadmin",
                "admin",
                "game-ops",
                "support"
              ],
              "adminUserIds": []
            }
          }
        },
        {
          "name": "email-otp",
          "config": {
            "id": "email-otp",
            "endpoints": {},
            "hooks": {
              "after": [
                {}
              ]
            },
            "$ERROR_CODES": {
              "OTP_EXPIRED": "otp expired",
              "INVALID_OTP": "Invalid OTP",
              "INVALID_EMAIL": "Invalid email",
              "USER_NOT_FOUND": "User not found",
              "TOO_MANY_ATTEMPTS": "Too many attempts"
            },
            "rateLimit": [
              {
                "window": 60,
                "max": 3
              },
              {
                "window": 60,
                "max": 3
              },
              {
                "window": 60,
                "max": 3
              },
              {
                "window": 60,
                "max": 3
              }
            ]
          }
        },
        {
          "name": "stripe",
          "config": {
            "id": "stripe",
            "endpoints": {},
            "schema": {
              "user": {
                "fields": {
                  "stripeCustomerId": {
                    "type": "string",
                    "required": false
                  }
                }
              }
            }
          }
        }
      ]
    }
  }
}

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

Client

Auth config (if applicable)

import type { Session as BetterAuthSession } from 'better-auth';
import { expo } from '@better-auth/expo';
import { stripe } from '@better-auth/stripe';
import { betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import { customSession, oneTap, phoneNumber, username } from 'better-auth/plugins';
import Stripe from 'stripe';

import type { Database } from '@db';
import { schema } from '@db';

import { createEnhancedUsernameValidator } from './utils/username-validator.ts';
import { sendPhoneOTP } from './services/twilio-service.ts';

interface MobileAuthConfig {
	baseURL: string;
	secret: string;
	database: Database;
	trustedOrigins: string[];
	googleClientId?: string;
	googleClientSecret?: string;
	stripeSecretKey?: string;
	stripeWebhookSecret?: string;
	// Twilio configuration for SMS OTP
	twilioApiKey?: string;
	twilioApiKeySecret?: string;
	twilioVerifyServiceSid?: string;
}

interface BaseUser {
	id: string;
	name: string;
	email: string;
	emailVerified: boolean;
	image?: string | null;
	createdAt: Date;
	updatedAt: Date;
	phoneNumber?: string | null;
	phoneNumberVerified?: boolean | null;
	username?: string | null;
	displayUsername?: string | null;
	stripeCustomerId?: string;
	firstName?: string | null;
	lastName?: string | null;
	preferredName?: string | null;
}

// Session type for better-auth custom session
interface CustomSession {
	user: BaseUser;
	session: {
		id: string;
		userId: string;
		expiresAt: Date;
		ipAddress?: string;
		userAgent?: string;
	};
}

export function createMobileAuth(config: MobileAuthConfig): ReturnType<typeof betterAuth> {
	// Get username validator - enhanced if requested and available, otherwise basic
	const usernameValidator = createEnhancedUsernameValidator();

	// Create Stripe client if credentials are provided
	const stripeClient = config.stripeSecretKey
		? new Stripe(config.stripeSecretKey, {
				apiVersion: '2025-08-27.basil',
			})
		: undefined;

	return betterAuth({
		appName: 'app Name',
		baseURL: config.baseURL,
		secret: config.secret,
		database: drizzleAdapter(config.database, {
			provider: 'pg',
			schema,
			usePlural: false,
			debugLogs: true,
		}),
		trustedOrigins: config.trustedOrigins,
		user: {
			// Ensure these fields are part of the User model
			additionalFields: {
				firstName: { type: 'string', required: false },
				lastName: { type: 'string', required: false },
				preferredName: { type: 'string', required: false },
				phoneNumberVerified: { type: 'boolean', required: false },
			},
		},
		emailAndPassword: {
			enabled: true,
			requireEmailVerification: true,
		},
		socialProviders:
			config.googleClientId && config.googleClientSecret
				? {
						google: {
							prompt: 'select_account',
							clientId: config.googleClientId,
							clientSecret: config.googleClientSecret,
							scope: ['email', 'profile'],
							// For mobile, we need to use a static redirect URI that matches the deep link
							// The Expo plugin will handle the actual callback processing
							// redirectURI: `${config.baseURL}/api/auth/callback/google`,
							// No domain restrictions for mobile users
							mapProfileToUser: (profile) => {
								console.log('GOOGLE PROFILE', profile);
								const email = profile.email?.toLowerCase();

								return {
									email: email,
									name: profile.name,
									firstName: profile.given_name,
									lastName: profile.family_name,
									image: profile.picture,
									emailVerified: profile.email_verified || false,
								};
							},
						},
					}
				: undefined,
		session: {
			expiresIn: 60 * 60 * 24 * 30, // 30 days (longer for mobile)
			updateAge: 60 * 60 * 24, // 1 day
		},
		plugins: [
			expo(),
			phoneNumber({
				otpLength: 6,
				expiresIn: 900, // 15 minutes
				requireVerification: true,
				sendOTP: async ({ phoneNumber, code }, request) => {
					console.log('phoneNumber OTP: PHONE NUMBER', phoneNumber);
					console.log('phoneNumber OTP: CODE', code);
					console.log('phoneNumber OTP: REQUEST', request);
					// Use Twilio service if credentials are provided
					if (config.twilioApiKey && config.twilioApiKeySecret && config.twilioVerifyServiceSid) {
						await sendPhoneOTP(phoneNumber, {
							apiKey: config.twilioApiKey,
							apiKeySecret: config.twilioApiKeySecret,
							verifyServiceSid: config.twilioVerifyServiceSid,
						});
					} else {
						// Fallback for development or when Twilio is not configured
						console.log(`📱 Development OTP for ${phoneNumber}: ${code}`);
					}
				},
			}),
			username({
				// Allow users to set username and login with username/password
				minUsernameLength: 3,
				maxUsernameLength: 20,
				usernameValidator,
			}),
			// Customize the session response to include key user fields
			customSession(async ({ user, session }: { user: BaseUser; session: BetterAuthSession }) => {
				console.log('CUSTOM SESSION MOBILE-AUTH', user);
				return {
					user: {
						...user,
						phoneNumberVerified: Boolean(user.phoneNumberVerified),
						firstName: user.firstName ?? null,
						lastName: user.lastName ?? null,
						preferredName: user.preferredName ?? null,
					},
					session,
				} as CustomSession;
			}),
		] as Parameters<typeof betterAuth>[0]['plugins'],
		logger: {
			disabled: false,
			level: 'error',
			log: (level, message) => {
				console.log(message, `level : ${level}`);
			},
		},
		advanced: {
			database: {
				generateId: false,
				useNumberId: false,
			},
		},
	});
}

Additional context

Could this be the cause?

The error occurs because Better Auth requires crypto.subtle (Web Crypto API) which isn't available in React Native's Hermes engine.
Originally created by @jinsley8 on GitHub (Sep 15, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce Install phone number plugin in auth and auth-client configs in an Expo app. Try to use `authClient.phoneNumber.sendOtp()` in any file: ``` import { authClient } from "@/lib/auth-client"; await authClient.phoneNumber.sendOtp({ phoneNumber: formattedPhone, }); ``` It immediately causes an Expo error: ``` ERROR Warning: TypeError: Cannot read property 'subtle' of undefined, js engine: hermes 18 | type = api_1.interopComponents.get(type) ?? type; 19 | } > 20 | return jsx.call(jsx, type, props, ...rest); | ^ 21 | }; 22 | } 23 | //# sourceMappingURL=wrap-jsx.js.map Call Stack call (apps/mobile/<native>) apply (apps/mobile/<native>) <anonymous> (node_modules/react-native-css-interop/dist/runtime/wrap-jsx.js:20:24) ExpoRoot (node_modules/expo-router/build/ExpoRoot.js:82:12) call (apps/mobile/<native>) apply (apps/mobile/<native>) <anonymous> (node_modules/react-native-css-interop/dist/runtime/wrap-jsx.js:20:24) App (node_modules/expo-router/build/qualified-entry.js:21:7) call (apps/mobile/<native>) apply (apps/mobile/<native>) <anonymous> (node_modules/react-native-css-interop/dist/runtime/wrap-jsx.js:20:24) ErrorOverlay (node_modules/@expo/metro-runtime/src/error-overlay/index.tsx:19:9) call (apps/mobile/<native>) apply (apps/mobile/<native>) <anonymous> (node_modules/react-native-css-interop/dist/runtime/wrap-jsx.js:20:24) WithDevTools (node_modules/expo/src/launch/withDevTools.ios.tsx:38:12) ``` ### Current vs. Expected behavior Expected phone number plugin to work with Expo so I can integrate Twilio Verify to allow login with SMS ### What version of Better Auth are you using? 1.3.9 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 24.6.0: Mon Jul 14 11:30:29 PDT 2025; root:xnu-11417.140.69~1/RELEASE_ARM64_T6000", "release": "24.6.0", "cpuCount": 10, "cpuModel": "Apple M1 Pro", "totalMemory": "32.00 GB", "freeMemory": "0.34 GB" }, "node": { "version": "v22.16.0", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.15.1" }, "frameworks": null, "databases": [ { "name": "postgres", "version": "^3.4.7" }, { "name": "drizzle", "version": "catalog:" } ], "betterAuth": { "version": "1.0.0", "config": { "trustedOrigins": [ "http://localhost:3001" ], "baseURL": "http://localhost:8787", "secret": "[REDACTED]", "user": { "additionalFields": { "firstName": { "type": "string", "required": false }, "lastName": { "type": "string", "required": false }, "preferredName": { "type": "string", "required": false }, "phoneNumberVerified": { "type": "boolean", "required": false } } }, "emailAndPassword": { "enabled": true, "requireEmailVerification": true }, "socialProviders": { "google": { "clientId": "", "clientSecret": "" } }, "plugins": [ { "name": "expo", "config": { "id": "expo", "hooks": { "after": [ {} ] } } }, { "name": "phone-number", "config": { "id": "phone-number", "endpoints": {}, "schema": { "user": { "fields": { "phoneNumber": { "type": "string", "required": false, "unique": true, "sortable": true, "returned": true }, "phoneNumberVerified": { "type": "boolean", "required": false, "returned": true, "input": false } } } }, "rateLimit": [ { "window": 60000, "max": 10 } ], "$ERROR_CODES": { "INVALID_PHONE_NUMBER": "Invalid phone number", "PHONE_NUMBER_EXIST": "Phone number already exists", "INVALID_PHONE_NUMBER_OR_PASSWORD": "[REDACTED]", "UNEXPECTED_ERROR": "Unexpected error", "OTP_NOT_FOUND": "OTP not found", "OTP_EXPIRED": "OTP expired", "INVALID_OTP": "Invalid OTP", "PHONE_NUMBER_NOT_VERIFIED": "Phone number not verified" } } }, { "name": "username", "config": { "id": "username", "endpoints": {}, "schema": { "user": { "fields": { "username": { "type": "string", "required": false, "sortable": true, "unique": true, "returned": true, "transform": {} }, "displayUsername": { "type": "string", "required": false, "transform": {} } } } }, "hooks": { "before": [ {}, {} ] }, "$ERROR_CODES": { "INVALID_USERNAME_OR_PASSWORD": "[REDACTED]", "EMAIL_NOT_VERIFIED": "Email not verified", "UNEXPECTED_ERROR": "Unexpected error", "USERNAME_IS_ALREADY_TAKEN": "Username is already taken. Please try another.", "USERNAME_TOO_SHORT": "Username is too short", "USERNAME_TOO_LONG": "Username is too long", "INVALID_USERNAME": "Username is invalid", "INVALID_DISPLAY_USERNAME": "Display username is invalid" } } }, { "name": "admin", "config": { "id": "admin", "hooks": { "after": [ {} ] }, "endpoints": {}, "$ERROR_CODES": { "FAILED_TO_CREATE_USER": "Failed to create user", "USER_ALREADY_EXISTS": "User already exists. Use another email.", "YOU_CANNOT_BAN_YOURSELF": "You cannot ban yourself", "YOU_ARE_NOT_ALLOWED_TO_CHANGE_USERS_ROLE": "You are not allowed to change users role", "YOU_ARE_NOT_ALLOWED_TO_CREATE_USERS": "You are not allowed to create users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS": "You are not allowed to list users", "YOU_ARE_NOT_ALLOWED_TO_LIST_USERS_SESSIONS": "You are not allowed to list users sessions", "YOU_ARE_NOT_ALLOWED_TO_BAN_USERS": "You are not allowed to ban users", "YOU_ARE_NOT_ALLOWED_TO_IMPERSONATE_USERS": "You are not allowed to impersonate users", "YOU_ARE_NOT_ALLOWED_TO_REVOKE_USERS_SESSIONS": "You are not allowed to revoke users sessions", "YOU_ARE_NOT_ALLOWED_TO_DELETE_USERS": "You are not allowed to delete users", "YOU_ARE_NOT_ALLOWED_TO_SET_USERS_PASSWORD": "[REDACTED]", "BANNED_USER": "You have been banned from this application", "YOU_ARE_NOT_ALLOWED_TO_GET_USER": "You are not allowed to get user", "NO_DATA_TO_UPDATE": "No data to update", "YOU_ARE_NOT_ALLOWED_TO_UPDATE_USERS": "You are not allowed to update users", "YOU_CANNOT_REMOVE_YOURSELF": "You cannot remove yourself" }, "schema": { "user": { "fields": { "role": { "type": "string", "required": false, "input": false }, "banned": { "type": "boolean", "defaultValue": false, "required": false, "input": false }, "banReason": { "type": "string", "required": false, "input": false }, "banExpires": { "type": "date", "required": false, "input": false } } }, "session": { "fields": { "impersonatedBy": { "type": "string", "required": false } } } }, "options": { "defaultRole": "user", "adminRoles": [ "superadmin", "admin", "game-ops", "support" ], "adminUserIds": [] } } }, { "name": "email-otp", "config": { "id": "email-otp", "endpoints": {}, "hooks": { "after": [ {} ] }, "$ERROR_CODES": { "OTP_EXPIRED": "otp expired", "INVALID_OTP": "Invalid OTP", "INVALID_EMAIL": "Invalid email", "USER_NOT_FOUND": "User not found", "TOO_MANY_ATTEMPTS": "Too many attempts" }, "rateLimit": [ { "window": 60, "max": 3 }, { "window": 60, "max": 3 }, { "window": 60, "max": 3 }, { "window": 60, "max": 3 } ] } }, { "name": "stripe", "config": { "id": "stripe", "endpoints": {}, "schema": { "user": { "fields": { "stripeCustomerId": { "type": "string", "required": false } } } } } } ] } } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript import type { Session as BetterAuthSession } from 'better-auth'; import { expo } from '@better-auth/expo'; import { stripe } from '@better-auth/stripe'; import { betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { customSession, oneTap, phoneNumber, username } from 'better-auth/plugins'; import Stripe from 'stripe'; import type { Database } from '@db'; import { schema } from '@db'; import { createEnhancedUsernameValidator } from './utils/username-validator.ts'; import { sendPhoneOTP } from './services/twilio-service.ts'; interface MobileAuthConfig { baseURL: string; secret: string; database: Database; trustedOrigins: string[]; googleClientId?: string; googleClientSecret?: string; stripeSecretKey?: string; stripeWebhookSecret?: string; // Twilio configuration for SMS OTP twilioApiKey?: string; twilioApiKeySecret?: string; twilioVerifyServiceSid?: string; } interface BaseUser { id: string; name: string; email: string; emailVerified: boolean; image?: string | null; createdAt: Date; updatedAt: Date; phoneNumber?: string | null; phoneNumberVerified?: boolean | null; username?: string | null; displayUsername?: string | null; stripeCustomerId?: string; firstName?: string | null; lastName?: string | null; preferredName?: string | null; } // Session type for better-auth custom session interface CustomSession { user: BaseUser; session: { id: string; userId: string; expiresAt: Date; ipAddress?: string; userAgent?: string; }; } export function createMobileAuth(config: MobileAuthConfig): ReturnType<typeof betterAuth> { // Get username validator - enhanced if requested and available, otherwise basic const usernameValidator = createEnhancedUsernameValidator(); // Create Stripe client if credentials are provided const stripeClient = config.stripeSecretKey ? new Stripe(config.stripeSecretKey, { apiVersion: '2025-08-27.basil', }) : undefined; return betterAuth({ appName: 'app Name', baseURL: config.baseURL, secret: config.secret, database: drizzleAdapter(config.database, { provider: 'pg', schema, usePlural: false, debugLogs: true, }), trustedOrigins: config.trustedOrigins, user: { // Ensure these fields are part of the User model additionalFields: { firstName: { type: 'string', required: false }, lastName: { type: 'string', required: false }, preferredName: { type: 'string', required: false }, phoneNumberVerified: { type: 'boolean', required: false }, }, }, emailAndPassword: { enabled: true, requireEmailVerification: true, }, socialProviders: config.googleClientId && config.googleClientSecret ? { google: { prompt: 'select_account', clientId: config.googleClientId, clientSecret: config.googleClientSecret, scope: ['email', 'profile'], // For mobile, we need to use a static redirect URI that matches the deep link // The Expo plugin will handle the actual callback processing // redirectURI: `${config.baseURL}/api/auth/callback/google`, // No domain restrictions for mobile users mapProfileToUser: (profile) => { console.log('GOOGLE PROFILE', profile); const email = profile.email?.toLowerCase(); return { email: email, name: profile.name, firstName: profile.given_name, lastName: profile.family_name, image: profile.picture, emailVerified: profile.email_verified || false, }; }, }, } : undefined, session: { expiresIn: 60 * 60 * 24 * 30, // 30 days (longer for mobile) updateAge: 60 * 60 * 24, // 1 day }, plugins: [ expo(), phoneNumber({ otpLength: 6, expiresIn: 900, // 15 minutes requireVerification: true, sendOTP: async ({ phoneNumber, code }, request) => { console.log('phoneNumber OTP: PHONE NUMBER', phoneNumber); console.log('phoneNumber OTP: CODE', code); console.log('phoneNumber OTP: REQUEST', request); // Use Twilio service if credentials are provided if (config.twilioApiKey && config.twilioApiKeySecret && config.twilioVerifyServiceSid) { await sendPhoneOTP(phoneNumber, { apiKey: config.twilioApiKey, apiKeySecret: config.twilioApiKeySecret, verifyServiceSid: config.twilioVerifyServiceSid, }); } else { // Fallback for development or when Twilio is not configured console.log(`📱 Development OTP for ${phoneNumber}: ${code}`); } }, }), username({ // Allow users to set username and login with username/password minUsernameLength: 3, maxUsernameLength: 20, usernameValidator, }), // Customize the session response to include key user fields customSession(async ({ user, session }: { user: BaseUser; session: BetterAuthSession }) => { console.log('CUSTOM SESSION MOBILE-AUTH', user); return { user: { ...user, phoneNumberVerified: Boolean(user.phoneNumberVerified), firstName: user.firstName ?? null, lastName: user.lastName ?? null, preferredName: user.preferredName ?? null, }, session, } as CustomSession; }), ] as Parameters<typeof betterAuth>[0]['plugins'], logger: { disabled: false, level: 'error', log: (level, message) => { console.log(message, `level : ${level}`); }, }, advanced: { database: { generateId: false, useNumberId: false, }, }, }); } ``` ### Additional context Could this be the cause? ``` The error occurs because Better Auth requires crypto.subtle (Web Crypto API) which isn't available in React Native's Hermes engine. ```
GiteaMirror added the expo label 2026-03-13 09:13:20 -05:00
Author
Owner

@dosubot[bot] commented on GitHub (Sep 15, 2025):

Hi @jinsley8! I'm Dosu and I’m helping the better-auth team.

The error happens because Better Auth (and its dependencies like jose) require the Web Crypto API (crypto.subtle), which isn't available in Expo's Hermes engine by default. To fix this, you need to polyfill the crypto object using expo-crypto before importing any Better Auth plugins. Here’s how you can do it:

  1. Install expo-crypto:

    npx expo install expo-crypto
    
  2. Create a file (e.g., crypto-polyfill.js) with:

    import * as ExpoCrypto from 'expo-crypto';
    
    if (typeof global.crypto === 'undefined') {
      global.crypto = {};
    }
    
    global.crypto.getRandomValues = ExpoCrypto.getRandomValues;
    global.crypto.randomUUID = ExpoCrypto.randomUUID;
    
    if (typeof globalThis !== 'undefined') {
      globalThis.crypto = global.crypto;
    }
    
  3. Import this polyfill at the top level of your app (before any Better Auth imports):

    import '@/crypto-polyfill'
    

If you’re on Better Auth v1.3.9, this workaround should resolve the missing crypto.subtle issue and allow the phone number plugin to work in Expo/Hermes environments. For best results and future compatibility, consider upgrading to Better Auth v1.3.10-beta.4 or newer, which has improved support for Expo and crypto polyfills [source] [source].

Alternatively, you can use react-native-webcrypto and override jose to ^6.0.0 if you run into issues with jose specifically [source].

Let me know if you need more details or run into any other issues!

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 15, 2025): <!-- Greeting --> Hi @jinsley8! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> The error happens because Better Auth (and its dependencies like jose) require the Web Crypto API (crypto.subtle), which isn't available in Expo's Hermes engine by default. To fix this, you need to polyfill the crypto object using expo-crypto before importing any Better Auth plugins. Here’s how you can do it: 1. Install expo-crypto: ```bash npx expo install expo-crypto ``` 2. Create a file (e.g., `crypto-polyfill.js`) with: ```javascript import * as ExpoCrypto from 'expo-crypto'; if (typeof global.crypto === 'undefined') { global.crypto = {}; } global.crypto.getRandomValues = ExpoCrypto.getRandomValues; global.crypto.randomUUID = ExpoCrypto.randomUUID; if (typeof globalThis !== 'undefined') { globalThis.crypto = global.crypto; } ``` 3. Import this polyfill at the top level of your app (before any Better Auth imports): ```javascript import '@/crypto-polyfill' ``` If you’re on Better Auth v1.3.9, this workaround should resolve the missing crypto.subtle issue and allow the phone number plugin to work in Expo/Hermes environments. For best results and future compatibility, consider upgrading to Better Auth v1.3.10-beta.4 or newer, which has improved support for Expo and crypto polyfills [[source]](https://github.com/better-auth/better-auth/issues/4471) [[source]](https://github.com/better-auth/better-auth/issues/4496). Alternatively, you can use react-native-webcrypto and override jose to ^6.0.0 if you run into issues with jose specifically [[source]](https://github.com/better-auth/better-auth/issues/4422). Let me know if you need more details or run into any other issues! <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/42b817c0-6cce-4184-924c-37f96fab589d?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/42b817c0-6cce-4184-924c-37f96fab589d?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/42b817c0-6cce-4184-924c-37f96fab589d?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/42b817c0-6cce-4184-924c-37f96fab589d?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/42b817c0-6cce-4184-924c-37f96fab589d?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/42b817c0-6cce-4184-924c-37f96fab589d?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/42b817c0-6cce-4184-924c-37f96fab589d?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4679)
Author
Owner

@jinsley8 commented on GitHub (Sep 15, 2025):

That does not fix the issue. I have better-auth v1.3.9 installed.

@jinsley8 commented on GitHub (Sep 15, 2025): That does not fix the issue. I have `better-auth` v1.3.9 installed.
Author
Owner

@dosubot[bot] commented on GitHub (Sep 15, 2025):

The crypto.subtle error in Expo/Hermes can't be fixed in Better Auth v1.3.9 with any polyfill—Expo/Hermes doesn't provide a full Web Crypto API, and v1.3.9 relies on crypto.subtle for session and token management. A fix for this was merged after v1.3.9 (PR #4620), which updates Better Auth to use a new approach compatible with Expo/Hermes. To resolve this, you'll need to upgrade to v1.3.10-beta.4 or newer and follow the recommended polyfill steps. v1.3.9 can't be made reliably compatible with Expo/Hermes due to these architectural limitations.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Chat with Dosu Join Discord Share on X

@dosubot[bot] commented on GitHub (Sep 15, 2025): <!-- Answer --> The crypto.subtle error in Expo/Hermes can't be fixed in Better Auth v1.3.9 with any polyfill—Expo/Hermes doesn't provide a full Web Crypto API, and v1.3.9 relies on crypto.subtle for session and token management. A fix for this was merged after v1.3.9 ([PR #4620](https://github.com/better-auth/better-auth/pull/4620)), which updates Better Auth to use a new approach compatible with Expo/Hermes. To resolve this, you'll need to upgrade to v1.3.10-beta.4 or newer and follow the recommended polyfill steps. v1.3.9 can't be made reliably compatible with Expo/Hermes due to these architectural limitations. <!-- Dosu Comment Footer --> *To reply, just mention [@dosu](https://go.dosu.dev/dosubot).* --- <sup>How did I do? [Good](https://app.dosu.dev/response-feedback/40fe8e92-780e-40b1-be3f-7c89c6ec7b3b?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/40fe8e92-780e-40b1-be3f-7c89c6ec7b3b?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/40fe8e92-780e-40b1-be3f-7c89c6ec7b3b?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/40fe8e92-780e-40b1-be3f-7c89c6ec7b3b?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/40fe8e92-780e-40b1-be3f-7c89c6ec7b3b?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/40fe8e92-780e-40b1-be3f-7c89c6ec7b3b?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/40fe8e92-780e-40b1-be3f-7c89c6ec7b3b?feedback_type=other)</sup>&nbsp;&nbsp;[![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Join Discord](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&label=)](https://go.dosu.dev/discord-bot)&nbsp;[![Share on X](https://img.shields.io/badge/X-share-black)](https://twitter.com/intent/tweet?text=%40dosu_ai%20helped%20me%20solve%20this%20issue!&url=https%3A//github.com/better-auth/better-auth/issues/4679)
Author
Owner

@jinsley8 commented on GitHub (Sep 15, 2025):

This comment helps - https://github.com/better-auth/better-auth/issues/4471#issuecomment-3288361311

...import the crypto-polyfill.js from both main _layout.tsx and auth-client.ts in the top of the file. It fails it not imported in auth-client.ts as well.

// crypto-polyfill.js
import * as ExpoCrypto from 'expo-crypto';

if (typeof global.crypto === 'undefined') {
  global.crypto = {} as Crypto;
}

global.crypto.getRandomValues = ExpoCrypto.getRandomValues;
global.crypto.randomUUID = ExpoCrypto.randomUUID;

if (typeof globalThis !== 'undefined') {
  globalThis.crypto = global.crypto;
}

It gets rid of the error, I have not tested whether the phone OTP actually works yet.

@jinsley8 commented on GitHub (Sep 15, 2025): This comment helps - https://github.com/better-auth/better-auth/issues/4471#issuecomment-3288361311 `...import the crypto-polyfill.js from both main _layout.tsx and auth-client.ts in the top of the file.` It fails it not imported in `auth-client.ts` as well. ```js // crypto-polyfill.js import * as ExpoCrypto from 'expo-crypto'; if (typeof global.crypto === 'undefined') { global.crypto = {} as Crypto; } global.crypto.getRandomValues = ExpoCrypto.getRandomValues; global.crypto.randomUUID = ExpoCrypto.randomUUID; if (typeof globalThis !== 'undefined') { globalThis.crypto = global.crypto; } ``` It gets rid of the error, I have not tested whether the phone OTP actually works yet.
Author
Owner

@jinsley8 commented on GitHub (Sep 15, 2025):

Upgrading better-auth to v1.3.10-beta.7 and removing the polyfill now works.

@jinsley8 commented on GitHub (Sep 15, 2025): Upgrading `better-auth` to v1.3.10-beta.7 and removing the polyfill now works.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1932