[GH-ISSUE #6810] [BUG] [Expo] Fail in useSession due to invalid SecureStore cookie #10637

Closed
opened 2026-04-13 06:53:08 -05:00 by GiteaMirror · 27 comments
Owner

Originally created by @MarkLee425 on GitHub (Dec 16, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/6810

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

In expo development build and expo go, when i use ios for testing google sso, better auth successfully saved a cookie inside SecureStore, but the format is like this:
{"null":{"value":"","expires":null},"etigo.state":{"value":"i_BhlUCZY-M3ol74KfE_zxZiqfCv-11s.l%2FM%2BJlmkYs4D1THnd41a%2Fu5B5wOjhvDhUulGEBrCDVI%3D","expires":"2025-12-20T10:09:37.426Z"}}

I am not sure if it is because of invalid cookie format or something else, eventually it will always get NULL from authClient.useSession.

For expo web, it works with no issues.

Current vs. Expected behavior

Just found the issue:
Somehow in backend, console logging the cookie before sending to expo app is
etigo.session_token=4YXFfpe99PBGu9xrgP6pRS8PdFIwnqFF.C%2BrrkiE7DUTHvZwOnx134VmRsp4Z92MCYZ0VOTkenG4%3D; etigo.state=i_BhlUCZY-M3ol74KfE_zxZiqfCv-11s.l%2FM%2BJlmkYs4D1THnd41a%2Fu5B5wOjhvDhUulGEBrCDVI%3D

But eventually in expo securestore, it will become
{"null":{"value":"","expires":null},"etigo.state":{"value":"TEjfBa5W4mii9rdKTe09lJ8upQgLZ47v.QUBHaetxm%2F0%2BaJff7Ucb9Nm8tk2eOQ5xyDniIKHUy1s%3D","expires":"2025-12-20T10:36:10.857Z"}}

The session token is missing

Expected behavior:
{"etigo.session_token":{"value":"the-token","expires":"2025-12-20T10:36:10.857Z"},"etigo.state":{"value":"TEjfBa5W4mii9rdKTe09lJ8upQgLZ47v.QUBHaetxm%2F0%2BaJff7Ucb9Nm8tk2eOQ5xyDniIKHUy1s%3D","expires":"2025-12-20T10:36:10.857Z"}}

What version of Better Auth are you using?

1.4.7

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.1.0: Mon Oct 20 19:33:00 PDT 2025; root:xnu-12377.41.6~2/RELEASE_ARM64_T6020",
    "release": "25.1.0",
    "cpuCount": 12,
    "cpuModel": "Apple M2 Max",
    "totalMemory": "32.00 GB",
    "freeMemory": "0.35 GB"
  },
  "node": {
    "version": "v24.1.0",
    "env": "development"
  },
  "packageManager": {
    "name": "npm",
    "version": "11.3.0"
  },
  "frameworks": [
    {
      "name": "react",
      "version": "catalog:"
    }
  ],
  "databases": null,
  "betterAuth": {
    "version": "1.4.7",
    "config": null
  }
}

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

Client

Auth config (if applicable)

// auth-client.ts in expo app
import { expoClient } from "@better-auth/expo/client";
import { preferencesClient } from "@better-auth-extended/preferences/client";
import {
    inferAdditionalFields,
    magicLinkClient,
    oneTapClient,
    phoneNumberClient,
} from "better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import * as SecureStore from "expo-secure-store";
import { auth } from "../../server/src/lib/auth";
import { config } from "./config";
import Constants from "expo-constants";

export const authClient = createAuthClient({
    baseURL: `${config.EXPO_PUBLIC_SERVER_URL}/auth`,
    plugins: [
        expoClient({
            scheme: Constants.expoConfig?.scheme as string,
            storagePrefix: Constants.expoConfig?.scheme as string,
            cookiePrefix: Constants.expoConfig?.scheme as string,
            storage: SecureStore,
        }),
        oneTapClient({
            clientId: config.EXPO_PUBLIC_GOOGLE_AUTH_CLIENT_ID,
            autoSelect: false,
            cancelOnTapOutside: true,
            context: "signin",
            promptOptions: {
                baseDelay: 1000,
                maxAttempts: 5,
            },
        }),
        inferAdditionalFields<typeof auth>(),
        magicLinkClient(),
        phoneNumberClient(),
        preferencesClient(),
    ],
});

// auth.ts in fastify server
export const auth = betterAuth({
	appName: "etigo",
	basePath: "/auth",
	database: pool,
	trustedOrigins: [
		config.CLIENT_URL,
		config.NATIVE_WEB_URL,
		`${config.NATIVE_APP_URL}*`,
		...(config.APP_ENV === "development"
			? [
					"exp://*/*", // Trust all Expo development URLs
					"exp://10.0.0.*:*/*", // Trust 10.0.0.x IP range
					"exp://192.168.*.*:*/*", // Trust 192.168.x.x IP range
					"exp://172.*.*.*:*/*", // Trust 172.x.x.x IP range
					"exp://localhost:*/*", // Trust localhost
				]
			: []),
	],
	telemetry: {
		enabled: false, // Disable telemetry for security reasons
	},
	rateLimit: {
		storage: "secondary-storage",
		modelName: "rateLimit",
		window: 60, // 1 minute
		max: 100, // Increased default limit
		customRules: {
			// Social authentication - main sign-in method
			"/sign-in/social": {
				window: 60, // 1 minute
				max: 8, // Reasonable limit for social sign-ins
			},
			// Magic link authentication
			"/sign-in/magic-link": {
				window: 300, // 5 minutes - prevent magic link spam
				max: 3, // Very strict to prevent email bombing
			},
			"/magic-link/verify": {
				window: 60, // 1 minute
				max: 10, // Allow multiple verification attempts
			},
			// Phone number authentication
			"/phone-number/send-otp": {
				window: 300, // 5 minutes - prevent SMS spam
				max: 3, // Very strict to prevent SMS bombing
			},
			"/phone-number/verify": {
				window: 300, // 5 minutes
				max: 5, // Allow some retries for OTP verification
			},
			// Common auth operations
			"/sign-out": {
				window: 60, // 1 minute
				max: 20, // More lenient for sign-outs (legitimate use case)
			},
			"/verify-email": {
				window: 300, // 5 minutes
				max: 5, // Allow some retries for email verification
			},
			"get-session": {
				window: 60, // 1 minute
				max: 100, // High limit for session checks (frequent legitimate use)
			},
		},
	},
	user: {
		modelName: "auth.user",
		changeEmail: {
			enabled: false,
		},
		fields: {
			emailVerified: "email_verified",
			createdAt: "created_at",
			updatedAt: "updated_at",
		},
		additionalFields: {
			role: {
				type: "string",
				required: false,
				defaultValue: "user",
				input: false,
			},
			revoked_at: {
				type: "date",
				required: false,
				input: false,
				defaultValue: null,
			},
		},
		deleteUser: {
			enabled: true, // remove later to implement soft delete
			// TODO: send email to confirm
		},
	},
	session: {
		modelName: "session",
		storeSessionInDatabase: false,
		freshAge: 60 * 60 * 24,
		cookieCache: {
			enabled: true,
			maxAge: 5 * 60,
		},
	},
	account: {
		modelName: "auth.account",
		fields: {
			userId: "user_id",
			accountId: "account_id",
			providerId: "provider_id",
			accessToken: "access_token",
			refreshToken: "refresh_token",
			accessTokenExpiresAt: "access_token_expires_at",
			refreshTokenExpiresAt: "refresh_token_expires_at",
			idToken: "id_token",
			createdAt: "created_at",
			updatedAt: "updated_at",
		},
		updateAccountOnSignIn: true,
		accountLinking: {
			enabled: true,
			trustedProviders: ["google", "facebook", "apple"],
		},
	},
	verification: {
		modelName: "auth.verification",
		fields: {
			expiresAt: "expires_at",
			createdAt: "created_at",
			updatedAt: "updated_at",
		},
		disableCleanup: true,
	},
	emailAndPassword: { enabled: false },
	socialProviders: {
		google: {
			clientId: config.GOOGLE_AUTH_CLIENT_ID,
			clientSecret: config.GOOGLE_AUTH_CLIENT_SECRET,
			prompt: "select_account",
			scope: ["openid", "profile", "email"],
		},
		facebook: {
			clientId: config.FACEBOOK_AUTH_CLIENT_ID,
			clientSecret: config.FACEBOOK_AUTH_CLIENT_SECRET,
			scope: ["email", "public_profile"],
			fields: ["id", "name", "picture", "email"],
			getUserInfo: async (token) => {
				const response = await fetch(
					`https://graph.facebook.com/v20.0/me?fields=id,name,email,picture&access_token=${token.accessToken}`,
				);
				const profile = (await response.json()) as {
					id: string;
					name?: string;
					email?: string;
					picture?: { data: { url: string } };
				};

				return {
					user: {
						id: profile.id,
						name: profile.name,
						email: profile.email,
						emailVerified: true,
						image: profile.picture?.data.url,
					},
					data: {
						id: profile.id,
						name: profile.name,
						email: profile.email,
						emailVerified: true,
						image: profile.picture?.data.url,
					},
				};
			},
		},
	},
	secondaryStorage: {
		get: async (key) => {
			const redisClient = getRedisClient();
			const value = await redisClient.get(key);
			return value;
		},
		set: async (key, value, ttl) => {
			const redisClient = getRedisClient();
			if (ttl) await redisClient.set(key, value, { EX: ttl });
			else await redisClient.set(key, value);
		},
		delete: async (key) => {
			const redisClient = getRedisClient();
			await redisClient.del(key);
		},
	},
	advanced: {
		database: {
			generateId: () => {
				return crypto.randomUUID() as string;
			},
		},
		useSecureCookies: config.APP_ENV === "production",
		disableCSRFCheck: false,
		crossSubDomainCookies: {
			enabled: config.APP_ENV === "production",
		},
		cookiePrefix: config.SESSION_PREFIX,
		defaultCookieAttributes: {
			sameSite: config.APP_ENV === "production" ? "lax" : "none",
			secure: config.APP_ENV === "production",
			httpOnly: true,
		},
	},
	plugins: [
		expo(),
		openAPI(),
		admin({
			defaultRole: "user",
			adminRoles: ["admin"],
			schema: {
				user: {
					fields: {
						banReason: "ban_reason",
						banExpires: "ban_expires",
					},
				},
			},
		}),
		oneTap(),
		magicLink({
			disableSignUp: false,
			rateLimit: {
				window: 60, // 1 minute
				max: 5, // limit each IP to 5 requests per windowMs
			},
			expiresIn: 10 * 60, // 10 minutes
			sendMagicLink: async ({ email, url }) => {
				await sendVerificationEmail(email, url);
			},
		}),
		phoneNumber({
			schema: {
				user: {
					fields: {
						phoneNumber: "phone_number",
						phoneNumberVerified: "phone_number_verified",
					},
				},
			},
			allowedAttempts: 3,
			otpLength: 6,
			expiresIn: 10 * 60, // 10 minutes
			phoneNumberValidator: (phoneNumber) => {
				return isValidPhoneNumber(phoneNumber);
			},
			sendOTP: async ({ phoneNumber, code }) => {
				// await sendSMSVerificationCode(phoneNumber, code);
			},
		}),
		preferences({
			// schema: {
			// 	modelName: "main.preference",
			// 	fields: {
			// 		userId: "user_id",
			// 		scopeId: "scope_id",
			// 		scope: "scope",
			// 		key: "key",
			// 		value: "value",
			// 		updatedAt: "updated_at",
			// 	},
			// },
			scopes: {
				user: createPreferenceScope({
					preferences: {
						offers_push_notification: { type: z.boolean() },
						offers_email: { type: z.boolean() },
						offers_sms: { type: z.boolean() },
						offers_chat_app: { type: z.boolean() },
						updates_push_notification: { type: z.boolean() },
						updates_email: { type: z.boolean() },
						updates_sms: { type: z.boolean() },
						// This is only for temporary storage before integrating with payment gateway
						payment_methods: {
							type: z.array(
								z.object({
									id: z.uuidv4(),
									type: PaymentMethodEnum,
									card_number: z
										.string()
										.min(13)
										.max(16)
										.regex(/^\d+$/, "Card number must contain only digits"),
									expires_at: z
										.string()
										.length(5)
										.refine((v) => {
											const splittedValue = v.split("/");
											return (
												splittedValue.length === 2 &&
												splittedValue[0].length === 2 &&
												splittedValue[1].length === 2
											);
										}, "Invalid expiration date"),
									cvc: z.string().length(3).optional(),
									is_default: z.boolean(),
								}),
							),
						},
					},
					defaultValues: {
						offers_push_notification: true,
						offers_email: true,
						offers_sms: true,
						offers_chat_app: true,
						updates_push_notification: true,
						updates_email: true,
						updates_sms: true,
						payment_methods: [],
					},
					groups: {
						communications: {
							preferences: {
								offers_push_notification: true,
								offers_email: true,
								offers_sms: true,
								offers_chat_app: true,
								updates_push_notification: true,
								updates_email: true,
								updates_sms: true,
							},
							operations: ["read", "write"],
						},
						payment_methods: {
							preferences: {
								payment_methods: true,
							},
							operations: ["read", "write"],
						},
					},
				}),
			},
		}),
		...(config.APP_ENV === "production"
			? []
			: [
					// captcha({
					// 	provider: 'cloudflare-turnstile',
					// 	secretKey: config.TURNSTILE_SECRET_KEY,
					// 	endpoints: ['/sign-up/email', '/sign-in/email', '/forget-password', '/sign-in/social'],
					// }),
				]),
	],
})

// sign in form in expo

	const onSubmit = async (data: AuthFormData) => {
		onLoadingChange?.(true);
		switch (data.type) {
			case "email": {
				const emailData = data as { type: "email"; email: string };
				const { error } = await authClient.signIn.magicLink({
					email: emailData.email,
					callbackURL: Platform.select({
						ios: config.EXPO_PUBLIC_NATIVE_APP_URL,
						android: config.EXPO_PUBLIC_NATIVE_APP_URL,
						web: config.EXPO_PUBLIC_NATIVE_WEB_URL,
					}),
					newUserCallbackURL: Platform.select({
						ios: config.EXPO_PUBLIC_NATIVE_APP_URL,
						android: config.EXPO_PUBLIC_NATIVE_APP_URL,
						web: config.EXPO_PUBLIC_NATIVE_WEB_URL,
					}),
				});

				if (error) {
					onLoadingChange?.(false);
					toast.error(
						error.message ||
							t(`auth.${mode}.form.submit-button.unexpected-error`),
					);
					return;
				}
				toast.success("Our verification email has been sent to your inbox.");
				onLoadingChange?.(false);
				break;
			}
			case "phone": {
				const phoneData = data as { type: "phone"; phone: string };
				if (
					!(
						z.e164().safeParse(`+63${phoneData.phone}`).success &&
						isValidPhoneNumber(`+63${phoneData.phone}`) &&
						isPossiblePhoneNumber(`+63${phoneData.phone}`)
					)
				) {
					onLoadingChange?.(false);
					setError("phone", { message: "Invalid phone number" });
					return;
				}

				const { error } = await authClient.phoneNumber.sendOtp({
					phoneNumber: `+63${phoneData.phone}`,
				});

				if (error) {
					onLoadingChange?.(false);
					toast.error(
						error.message ||
							t(`auth.${mode}.form.submit-button.unexpected-error`),
					);
					return;
				}

				onLoadingChange?.(false);
				router.push("/(auth)/verify-otp");

				break;
			}
			default: {
				throw new Error("Invalid type");
			}
		}
	};

// redirect after sign in
const { data: session, isPending } = authClient.useSession();

	console.log("AppLayout session: ", session);

	if (isPending) {
		return null;
	}

	if (!session) {
		return <Redirect href="/(auth)/sign-in" />;
	}

Additional context

I have debugged and found that this bug is in expo before or when expo stores it in SecureStore. The cookies before server passing to expo has no issue.

Originally created by @MarkLee425 on GitHub (Dec 16, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/6810 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce In expo development build and expo go, when i use ios for testing google sso, better auth successfully saved a cookie inside SecureStore, but the format is like this: `{"null":{"value":"","expires":null},"etigo.state":{"value":"i_BhlUCZY-M3ol74KfE_zxZiqfCv-11s.l%2FM%2BJlmkYs4D1THnd41a%2Fu5B5wOjhvDhUulGEBrCDVI%3D","expires":"2025-12-20T10:09:37.426Z"}}` I am not sure if it is because of invalid cookie format or something else, eventually it will always get NULL from authClient.useSession. For expo web, it works with no issues. ### Current vs. Expected behavior Just found the issue: Somehow in backend, console logging the cookie before sending to expo app is etigo.session_token=4YXFfpe99PBGu9xrgP6pRS8PdFIwnqFF.C%2BrrkiE7DUTHvZwOnx134VmRsp4Z92MCYZ0VOTkenG4%3D; etigo.state=i_BhlUCZY-M3ol74KfE_zxZiqfCv-11s.l%2FM%2BJlmkYs4D1THnd41a%2Fu5B5wOjhvDhUulGEBrCDVI%3D But eventually in expo securestore, it will become `{"null":{"value":"","expires":null},"etigo.state":{"value":"TEjfBa5W4mii9rdKTe09lJ8upQgLZ47v.QUBHaetxm%2F0%2BaJff7Ucb9Nm8tk2eOQ5xyDniIKHUy1s%3D","expires":"2025-12-20T10:36:10.857Z"}} ` The session token is missing Expected behavior: `{"etigo.session_token":{"value":"the-token","expires":"2025-12-20T10:36:10.857Z"},"etigo.state":{"value":"TEjfBa5W4mii9rdKTe09lJ8upQgLZ47v.QUBHaetxm%2F0%2BaJff7Ucb9Nm8tk2eOQ5xyDniIKHUy1s%3D","expires":"2025-12-20T10:36:10.857Z"}}` ### What version of Better Auth are you using? 1.4.7 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.1.0: Mon Oct 20 19:33:00 PDT 2025; root:xnu-12377.41.6~2/RELEASE_ARM64_T6020", "release": "25.1.0", "cpuCount": 12, "cpuModel": "Apple M2 Max", "totalMemory": "32.00 GB", "freeMemory": "0.35 GB" }, "node": { "version": "v24.1.0", "env": "development" }, "packageManager": { "name": "npm", "version": "11.3.0" }, "frameworks": [ { "name": "react", "version": "catalog:" } ], "databases": null, "betterAuth": { "version": "1.4.7", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript // auth-client.ts in expo app import { expoClient } from "@better-auth/expo/client"; import { preferencesClient } from "@better-auth-extended/preferences/client"; import { inferAdditionalFields, magicLinkClient, oneTapClient, phoneNumberClient, } from "better-auth/client/plugins"; import { createAuthClient } from "better-auth/react"; import * as SecureStore from "expo-secure-store"; import { auth } from "../../server/src/lib/auth"; import { config } from "./config"; import Constants from "expo-constants"; export const authClient = createAuthClient({ baseURL: `${config.EXPO_PUBLIC_SERVER_URL}/auth`, plugins: [ expoClient({ scheme: Constants.expoConfig?.scheme as string, storagePrefix: Constants.expoConfig?.scheme as string, cookiePrefix: Constants.expoConfig?.scheme as string, storage: SecureStore, }), oneTapClient({ clientId: config.EXPO_PUBLIC_GOOGLE_AUTH_CLIENT_ID, autoSelect: false, cancelOnTapOutside: true, context: "signin", promptOptions: { baseDelay: 1000, maxAttempts: 5, }, }), inferAdditionalFields<typeof auth>(), magicLinkClient(), phoneNumberClient(), preferencesClient(), ], }); // auth.ts in fastify server export const auth = betterAuth({ appName: "etigo", basePath: "/auth", database: pool, trustedOrigins: [ config.CLIENT_URL, config.NATIVE_WEB_URL, `${config.NATIVE_APP_URL}*`, ...(config.APP_ENV === "development" ? [ "exp://*/*", // Trust all Expo development URLs "exp://10.0.0.*:*/*", // Trust 10.0.0.x IP range "exp://192.168.*.*:*/*", // Trust 192.168.x.x IP range "exp://172.*.*.*:*/*", // Trust 172.x.x.x IP range "exp://localhost:*/*", // Trust localhost ] : []), ], telemetry: { enabled: false, // Disable telemetry for security reasons }, rateLimit: { storage: "secondary-storage", modelName: "rateLimit", window: 60, // 1 minute max: 100, // Increased default limit customRules: { // Social authentication - main sign-in method "/sign-in/social": { window: 60, // 1 minute max: 8, // Reasonable limit for social sign-ins }, // Magic link authentication "/sign-in/magic-link": { window: 300, // 5 minutes - prevent magic link spam max: 3, // Very strict to prevent email bombing }, "/magic-link/verify": { window: 60, // 1 minute max: 10, // Allow multiple verification attempts }, // Phone number authentication "/phone-number/send-otp": { window: 300, // 5 minutes - prevent SMS spam max: 3, // Very strict to prevent SMS bombing }, "/phone-number/verify": { window: 300, // 5 minutes max: 5, // Allow some retries for OTP verification }, // Common auth operations "/sign-out": { window: 60, // 1 minute max: 20, // More lenient for sign-outs (legitimate use case) }, "/verify-email": { window: 300, // 5 minutes max: 5, // Allow some retries for email verification }, "get-session": { window: 60, // 1 minute max: 100, // High limit for session checks (frequent legitimate use) }, }, }, user: { modelName: "auth.user", changeEmail: { enabled: false, }, fields: { emailVerified: "email_verified", createdAt: "created_at", updatedAt: "updated_at", }, additionalFields: { role: { type: "string", required: false, defaultValue: "user", input: false, }, revoked_at: { type: "date", required: false, input: false, defaultValue: null, }, }, deleteUser: { enabled: true, // remove later to implement soft delete // TODO: send email to confirm }, }, session: { modelName: "session", storeSessionInDatabase: false, freshAge: 60 * 60 * 24, cookieCache: { enabled: true, maxAge: 5 * 60, }, }, account: { modelName: "auth.account", fields: { userId: "user_id", accountId: "account_id", providerId: "provider_id", accessToken: "access_token", refreshToken: "refresh_token", accessTokenExpiresAt: "access_token_expires_at", refreshTokenExpiresAt: "refresh_token_expires_at", idToken: "id_token", createdAt: "created_at", updatedAt: "updated_at", }, updateAccountOnSignIn: true, accountLinking: { enabled: true, trustedProviders: ["google", "facebook", "apple"], }, }, verification: { modelName: "auth.verification", fields: { expiresAt: "expires_at", createdAt: "created_at", updatedAt: "updated_at", }, disableCleanup: true, }, emailAndPassword: { enabled: false }, socialProviders: { google: { clientId: config.GOOGLE_AUTH_CLIENT_ID, clientSecret: config.GOOGLE_AUTH_CLIENT_SECRET, prompt: "select_account", scope: ["openid", "profile", "email"], }, facebook: { clientId: config.FACEBOOK_AUTH_CLIENT_ID, clientSecret: config.FACEBOOK_AUTH_CLIENT_SECRET, scope: ["email", "public_profile"], fields: ["id", "name", "picture", "email"], getUserInfo: async (token) => { const response = await fetch( `https://graph.facebook.com/v20.0/me?fields=id,name,email,picture&access_token=${token.accessToken}`, ); const profile = (await response.json()) as { id: string; name?: string; email?: string; picture?: { data: { url: string } }; }; return { user: { id: profile.id, name: profile.name, email: profile.email, emailVerified: true, image: profile.picture?.data.url, }, data: { id: profile.id, name: profile.name, email: profile.email, emailVerified: true, image: profile.picture?.data.url, }, }; }, }, }, secondaryStorage: { get: async (key) => { const redisClient = getRedisClient(); const value = await redisClient.get(key); return value; }, set: async (key, value, ttl) => { const redisClient = getRedisClient(); if (ttl) await redisClient.set(key, value, { EX: ttl }); else await redisClient.set(key, value); }, delete: async (key) => { const redisClient = getRedisClient(); await redisClient.del(key); }, }, advanced: { database: { generateId: () => { return crypto.randomUUID() as string; }, }, useSecureCookies: config.APP_ENV === "production", disableCSRFCheck: false, crossSubDomainCookies: { enabled: config.APP_ENV === "production", }, cookiePrefix: config.SESSION_PREFIX, defaultCookieAttributes: { sameSite: config.APP_ENV === "production" ? "lax" : "none", secure: config.APP_ENV === "production", httpOnly: true, }, }, plugins: [ expo(), openAPI(), admin({ defaultRole: "user", adminRoles: ["admin"], schema: { user: { fields: { banReason: "ban_reason", banExpires: "ban_expires", }, }, }, }), oneTap(), magicLink({ disableSignUp: false, rateLimit: { window: 60, // 1 minute max: 5, // limit each IP to 5 requests per windowMs }, expiresIn: 10 * 60, // 10 minutes sendMagicLink: async ({ email, url }) => { await sendVerificationEmail(email, url); }, }), phoneNumber({ schema: { user: { fields: { phoneNumber: "phone_number", phoneNumberVerified: "phone_number_verified", }, }, }, allowedAttempts: 3, otpLength: 6, expiresIn: 10 * 60, // 10 minutes phoneNumberValidator: (phoneNumber) => { return isValidPhoneNumber(phoneNumber); }, sendOTP: async ({ phoneNumber, code }) => { // await sendSMSVerificationCode(phoneNumber, code); }, }), preferences({ // schema: { // modelName: "main.preference", // fields: { // userId: "user_id", // scopeId: "scope_id", // scope: "scope", // key: "key", // value: "value", // updatedAt: "updated_at", // }, // }, scopes: { user: createPreferenceScope({ preferences: { offers_push_notification: { type: z.boolean() }, offers_email: { type: z.boolean() }, offers_sms: { type: z.boolean() }, offers_chat_app: { type: z.boolean() }, updates_push_notification: { type: z.boolean() }, updates_email: { type: z.boolean() }, updates_sms: { type: z.boolean() }, // This is only for temporary storage before integrating with payment gateway payment_methods: { type: z.array( z.object({ id: z.uuidv4(), type: PaymentMethodEnum, card_number: z .string() .min(13) .max(16) .regex(/^\d+$/, "Card number must contain only digits"), expires_at: z .string() .length(5) .refine((v) => { const splittedValue = v.split("/"); return ( splittedValue.length === 2 && splittedValue[0].length === 2 && splittedValue[1].length === 2 ); }, "Invalid expiration date"), cvc: z.string().length(3).optional(), is_default: z.boolean(), }), ), }, }, defaultValues: { offers_push_notification: true, offers_email: true, offers_sms: true, offers_chat_app: true, updates_push_notification: true, updates_email: true, updates_sms: true, payment_methods: [], }, groups: { communications: { preferences: { offers_push_notification: true, offers_email: true, offers_sms: true, offers_chat_app: true, updates_push_notification: true, updates_email: true, updates_sms: true, }, operations: ["read", "write"], }, payment_methods: { preferences: { payment_methods: true, }, operations: ["read", "write"], }, }, }), }, }), ...(config.APP_ENV === "production" ? [] : [ // captcha({ // provider: 'cloudflare-turnstile', // secretKey: config.TURNSTILE_SECRET_KEY, // endpoints: ['/sign-up/email', '/sign-in/email', '/forget-password', '/sign-in/social'], // }), ]), ], }) // sign in form in expo const onSubmit = async (data: AuthFormData) => { onLoadingChange?.(true); switch (data.type) { case "email": { const emailData = data as { type: "email"; email: string }; const { error } = await authClient.signIn.magicLink({ email: emailData.email, callbackURL: Platform.select({ ios: config.EXPO_PUBLIC_NATIVE_APP_URL, android: config.EXPO_PUBLIC_NATIVE_APP_URL, web: config.EXPO_PUBLIC_NATIVE_WEB_URL, }), newUserCallbackURL: Platform.select({ ios: config.EXPO_PUBLIC_NATIVE_APP_URL, android: config.EXPO_PUBLIC_NATIVE_APP_URL, web: config.EXPO_PUBLIC_NATIVE_WEB_URL, }), }); if (error) { onLoadingChange?.(false); toast.error( error.message || t(`auth.${mode}.form.submit-button.unexpected-error`), ); return; } toast.success("Our verification email has been sent to your inbox."); onLoadingChange?.(false); break; } case "phone": { const phoneData = data as { type: "phone"; phone: string }; if ( !( z.e164().safeParse(`+63${phoneData.phone}`).success && isValidPhoneNumber(`+63${phoneData.phone}`) && isPossiblePhoneNumber(`+63${phoneData.phone}`) ) ) { onLoadingChange?.(false); setError("phone", { message: "Invalid phone number" }); return; } const { error } = await authClient.phoneNumber.sendOtp({ phoneNumber: `+63${phoneData.phone}`, }); if (error) { onLoadingChange?.(false); toast.error( error.message || t(`auth.${mode}.form.submit-button.unexpected-error`), ); return; } onLoadingChange?.(false); router.push("/(auth)/verify-otp"); break; } default: { throw new Error("Invalid type"); } } }; // redirect after sign in const { data: session, isPending } = authClient.useSession(); console.log("AppLayout session: ", session); if (isPending) { return null; } if (!session) { return <Redirect href="/(auth)/sign-in" />; } ``` ### Additional context I have debugged and found that this bug is in expo before or when expo stores it in SecureStore. The cookies before server passing to expo has no issue.
GiteaMirror added the locked label 2026-04-13 06:53:08 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Dec 16, 2025):

tag @better-auth-agent if you would like an answer from the Agent

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3662870699 --> @better-auth-agent[bot] commented on GitHub (Dec 16, 2025): tag @better-auth-agent if you would like an answer from the Agent _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@better-auth-agent[bot] commented on GitHub (Dec 16, 2025):

It looks like the SecureStore is not receiving the session token as expected. Here are a few troubleshooting steps you might try:

• Double-check that the cookiePrefix, storagePrefix, and related settings in your expo client configuration exactly match those for the session cookie on the backend. Consistency in these values is critical so the token isn’t lost or mis-mapped when stored (this is indicated by the missing key in SecureStore).

• Verify that no middleware or transformation occurs on the cookie before it’s written to SecureStore. You might add logging immediately before and after the Expo storage call to ensure that the token isn’t being overwritten or stripped.

• Check if a custom getter/setter for the expoClient plugin can be implemented, so you can intercept and verify the cookie format. This might help ensure that the session token is properly preserved.

• Review if any recent changes in Expo or SecureStore have affected how cookies are handled, and consider testing with a simplified config using a hardcoded storagePrefix value to isolate the problem.

Does this help clarify the next steps for you? Tag @better-auth-agent if you would like an answer from the Agent.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3662872711 --> @better-auth-agent[bot] commented on GitHub (Dec 16, 2025): It looks like the SecureStore is not receiving the session token as expected. Here are a few troubleshooting steps you might try: • Double-check that the cookiePrefix, storagePrefix, and related settings in your expo client configuration exactly match those for the session cookie on the backend. Consistency in these values is critical so the token isn’t lost or mis-mapped when stored (this is indicated by the missing key in SecureStore). • Verify that no middleware or transformation occurs on the cookie before it’s written to SecureStore. You might add logging immediately before and after the Expo storage call to ensure that the token isn’t being overwritten or stripped. • Check if a custom getter/setter for the expoClient plugin can be implemented, so you can intercept and verify the cookie format. This might help ensure that the session token is properly preserved. • Review if any recent changes in Expo or SecureStore have affected how cookies are handled, and consider testing with a simplified config using a hardcoded storagePrefix value to isolate the problem. Does this help clarify the next steps for you? Tag @better-auth-agent if you would like an answer from the Agent. <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@MarkLee425 commented on GitHub (Dec 16, 2025):

Found the issue:

Backend somehow will send two response to expo app:

The first one
cookie etigo.state=V8GXbNM5dD4lrYXwypMCX_8ljQazxo_5.CDHMar5aufEnquvc7PPMPnUzhzSp5pfgzWZoSHk8GtQ%3D; etigo.session_data=eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiaXBBZGRyZXNzIjoiIiwidXNlckFnZW50IjoiTW96aWxsYS81LjAgKGlQaG9uZTsgQ1BVIGlQaG9uZSBPUyAxOF83IGxpa2UgTWFjIE9TIFgpIEFwcGxlV2ViS2l0LzYwNS4xLjE1IChLSFRNTCwgbGlrZSBHZWNrbykgVmVyc2lvbi8yNi4yIE1vYmlsZS8xNUUxNDggU2FmYXJpLzYwNC4xIiwiZXhwaXJlc0F0IjoiMjAyNS0xMi0yM1QyMzo0NTowNS4wMzlaIiwidXNlcklkIjoiZDEyZTZhNmQtZTM1YS00MzliLTg4MjgtNjE4NjgyMWVjMzk0IiwidG9rZW4iOiJkQjJWaHhLa3E3RWp3NVJscXFuYVk3c3R2WDJ1Wk9OMCIsImNyZWF0ZWRBdCI6IjIwMjUtMTItMTZUMjM6NDU6MDUuMDM5WiIsInVwZGF0ZWRBdCI6IjIwMjUtMTItMTZUMjM6NDU6MDUuMDM5WiJ9LCJ1c2VyIjp7Im5hbWUiOiJMaWdodCBMaWdodCIsImVtYWlsIjoibGVlaG9rd29uZzA0MjVAZ21haWwuY29tIiwiZW1haWxWZXJpZmllZCI6dHJ1ZSwiaW1hZ2UiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKdVNUS1MxcC1JRy0wNlpwcW1zQzFQVEUtLTBYVFBwU3l0U1FuZ3ZxM1dzNEJmX3EzOD1zOTYtYyIsImNyZWF0ZWRBdCI6IjIwMjUtMTItMTVUMTg6MzA6MjguNjc0WiIsInVwZGF0ZWRBdCI6IjIwMjUtMTItMTVUMTg6MzA6MjguNjc0WiIsInJvbGUiOiJ1c2VyIiwiYmFubmVkIjpmYWxzZSwiYmFuUmVhc29uIjpudWxsLCJiYW5FeHBpcmVzIjpudWxsLCJwaG9uZU51bWJlciI6bnVsbCwicGhvbmVOdW1iZXJWZXJpZmllZCI6bnVsbCwicmV2b2tlZF9hdCI6bnVsbCwiaWQiOiJkMTJlNmE2ZC1lMzVhLTQzOWItODgyOC02MTg2ODIxZWMzOTQifSwidXBkYXRlZEF0IjoxNzY1OTI4NzA1MDQzLCJ2ZXJzaW9uIjoiMSJ9LCJleHBpcmVzQXQiOjE3NjU5MjkwMDUwNDMsInNpZ25hdHVyZSI6IkMtWkxxeUhrT2pyckpvYU9Qa3R0VTl6d1BtWUtSMG1reTY1UDBzMzhOWGsifQ; etigo.session_token=dB2VhxKkq7Ejw5RlqqnaY7stvX2uZON0.iDD%2FHlBFpC9VxrCbPoPaVVvawq93NvmdYPdjjBeLNKA%3D

The second one:
cookie ;

etigo.state=V8GXbNM5dD4lrYXwypMCX_8ljQazxo_5.CDHMar5aufEnquvc7PPMPnUzhzSp5pfgzWZoSHk8GtQ%3D; null=

I think the second one just replaced the correct one, resulting in saving the invalid session in SecureStore and get NULL in authClient.useSession

Code to debug:
after: createAuthMiddleware(async (ctx) => { console.log("cookie", ctx.headers?.get("cookie")); } }),

<!-- gh-comment-id:3662938025 --> @MarkLee425 commented on GitHub (Dec 16, 2025): Found the issue: Backend somehow will send two response to expo app: The first one `cookie etigo.state=V8GXbNM5dD4lrYXwypMCX_8ljQazxo_5.CDHMar5aufEnquvc7PPMPnUzhzSp5pfgzWZoSHk8GtQ%3D; etigo.session_data=eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiaXBBZGRyZXNzIjoiIiwidXNlckFnZW50IjoiTW96aWxsYS81LjAgKGlQaG9uZTsgQ1BVIGlQaG9uZSBPUyAxOF83IGxpa2UgTWFjIE9TIFgpIEFwcGxlV2ViS2l0LzYwNS4xLjE1IChLSFRNTCwgbGlrZSBHZWNrbykgVmVyc2lvbi8yNi4yIE1vYmlsZS8xNUUxNDggU2FmYXJpLzYwNC4xIiwiZXhwaXJlc0F0IjoiMjAyNS0xMi0yM1QyMzo0NTowNS4wMzlaIiwidXNlcklkIjoiZDEyZTZhNmQtZTM1YS00MzliLTg4MjgtNjE4NjgyMWVjMzk0IiwidG9rZW4iOiJkQjJWaHhLa3E3RWp3NVJscXFuYVk3c3R2WDJ1Wk9OMCIsImNyZWF0ZWRBdCI6IjIwMjUtMTItMTZUMjM6NDU6MDUuMDM5WiIsInVwZGF0ZWRBdCI6IjIwMjUtMTItMTZUMjM6NDU6MDUuMDM5WiJ9LCJ1c2VyIjp7Im5hbWUiOiJMaWdodCBMaWdodCIsImVtYWlsIjoibGVlaG9rd29uZzA0MjVAZ21haWwuY29tIiwiZW1haWxWZXJpZmllZCI6dHJ1ZSwiaW1hZ2UiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NKdVNUS1MxcC1JRy0wNlpwcW1zQzFQVEUtLTBYVFBwU3l0U1FuZ3ZxM1dzNEJmX3EzOD1zOTYtYyIsImNyZWF0ZWRBdCI6IjIwMjUtMTItMTVUMTg6MzA6MjguNjc0WiIsInVwZGF0ZWRBdCI6IjIwMjUtMTItMTVUMTg6MzA6MjguNjc0WiIsInJvbGUiOiJ1c2VyIiwiYmFubmVkIjpmYWxzZSwiYmFuUmVhc29uIjpudWxsLCJiYW5FeHBpcmVzIjpudWxsLCJwaG9uZU51bWJlciI6bnVsbCwicGhvbmVOdW1iZXJWZXJpZmllZCI6bnVsbCwicmV2b2tlZF9hdCI6bnVsbCwiaWQiOiJkMTJlNmE2ZC1lMzVhLTQzOWItODgyOC02MTg2ODIxZWMzOTQifSwidXBkYXRlZEF0IjoxNzY1OTI4NzA1MDQzLCJ2ZXJzaW9uIjoiMSJ9LCJleHBpcmVzQXQiOjE3NjU5MjkwMDUwNDMsInNpZ25hdHVyZSI6IkMtWkxxeUhrT2pyckpvYU9Qa3R0VTl6d1BtWUtSMG1reTY1UDBzMzhOWGsifQ; etigo.session_token=dB2VhxKkq7Ejw5RlqqnaY7stvX2uZON0.iDD%2FHlBFpC9VxrCbPoPaVVvawq93NvmdYPdjjBeLNKA%3D` The second one: cookie ; `etigo.state=V8GXbNM5dD4lrYXwypMCX_8ljQazxo_5.CDHMar5aufEnquvc7PPMPnUzhzSp5pfgzWZoSHk8GtQ%3D; null=` I think the second one just replaced the correct one, resulting in saving the invalid session in SecureStore and get NULL in authClient.useSession Code to debug: `after: createAuthMiddleware(async (ctx) => { console.log("cookie", ctx.headers?.get("cookie")); } }),`
Author
Owner

@better-auth-agent[bot] commented on GitHub (Dec 16, 2025):

Hi there! It looks like the issue is that the SecureStore JSON only contains the "etigo.state" key while the session token (with the "etigo.session_token" key) is missing. This usually indicates a breakdown in how the cookie from the backend is being parsed and stored by the Expo client plugin.

Here are a few troubleshooting steps to consider:
• Verify that the expo client configuration is correctly passing the scheme to both storagePrefix and cookiePrefix. Mismatched prefixes can cause the session token cookie to not be recognized and stored properly.
• Double‑check that your backend is indeed sending the complete cookie header (including both etigo.session_token and etigo.state) before it reaches Expo. Logging the raw cookie header at the server, as you have, is a good start.
• Ensure that SecureStore is correctly handling JSON values. You might want to add additional logging in the client code right after reading from SecureStore to confirm whether the session token is being filtered out during parsing.
• As a temporary workaround, try clearing SecureStore completely before re‑authenticating to see if the issue reproduces consistently.

Could you let us know if adjusting the cookie and storage prefix settings or clearing SecureStore makes any difference for you? Tag @better-auth-agent if you would like an answer from the Agent.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3662939771 --> @better-auth-agent[bot] commented on GitHub (Dec 16, 2025): Hi there! It looks like the issue is that the SecureStore JSON only contains the "etigo.state" key while the session token (with the "etigo.session_token" key) is missing. This usually indicates a breakdown in how the cookie from the backend is being parsed and stored by the Expo client plugin. Here are a few troubleshooting steps to consider: • Verify that the expo client configuration is correctly passing the scheme to both storagePrefix and cookiePrefix. Mismatched prefixes can cause the session token cookie to not be recognized and stored properly. • Double‑check that your backend is indeed sending the complete cookie header (including both etigo.session_token and etigo.state) before it reaches Expo. Logging the raw cookie header at the server, as you have, is a good start. • Ensure that SecureStore is correctly handling JSON values. You might want to add additional logging in the client code right after reading from SecureStore to confirm whether the session token is being filtered out during parsing. • As a temporary workaround, try clearing SecureStore completely before re‑authenticating to see if the issue reproduces consistently. Could you let us know if adjusting the cookie and storage prefix settings or clearing SecureStore makes any difference for you? Tag @better-auth-agent if you would like an answer from the Agent. <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) • [Discord](https://discord.gg/fG2XXEuQX3) • [GitHub](https://github.com/Skyvern-AI/Skyvern) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/skyvern_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/fG2XXEuQX3) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/Skyvern-AI/Skyvern)
Author
Owner

@MarkLee425 commented on GitHub (Dec 17, 2025):

It is probably the issue causing inside expo and expo client plugin. The complete cookie cannot be sent to expo app.

<!-- gh-comment-id:3663015897 --> @MarkLee425 commented on GitHub (Dec 17, 2025): It is probably the issue causing inside expo and expo client plugin. The complete cookie cannot be sent to expo app.
Author
Owner

@MarkLee425 commented on GitHub (Dec 17, 2025):

I have created a public repo for anyone here to test:

https://github.com/MarkLee425/better-auth-expo-issue-testing/tree/main
This is basically generated by better-t-stack.

bun install && bun db:start && bun db:push
Please add your GOOGLE_AUTH_CLIENT_ID and GOOGLE_AUTH_CLIENT_SECRET in the /apps/server/.env file and you can start the expo go to test ios by running bun dev with i to trigger ios. You can clearly see the server log that the null= issue still exists with the latest version of better auth expo. Tried to downgrade to 1.3.12, but the issue still remains.

But it is working well for email+password, it does generate valid session. The problem lies in google sso. Haven't tested for other sso providers. Will test them soon.

<!-- gh-comment-id:3665654523 --> @MarkLee425 commented on GitHub (Dec 17, 2025): I have created a public repo for anyone here to test: `https://github.com/MarkLee425/better-auth-expo-issue-testing/tree/main` This is basically generated by better-t-stack. `bun install && bun db:start && bun db:push` Please add your `GOOGLE_AUTH_CLIENT_ID` and `GOOGLE_AUTH_CLIENT_SECRET` in the `/apps/server/.env` file and you can start the expo go to test ios by running `bun dev` with `i` to trigger ios. You can clearly see the server log that the `null=` issue still exists with the latest version of better auth expo. Tried to downgrade to 1.3.12, but the issue still remains. But it is working well for email+password, it does generate valid session. The problem lies in google sso. Haven't tested for other sso providers. Will test them soon.
Author
Owner

@MarkLee425 commented on GitHub (Dec 19, 2025):

Hi, anyone have any idea to solve this issue?

<!-- gh-comment-id:3675176405 --> @MarkLee425 commented on GitHub (Dec 19, 2025): Hi, anyone have any idea to solve this issue?
Author
Owner

@ping-maxwell commented on GitHub (Dec 23, 2025):

Hello @MarkLee425 I will try to take a look, I'm not super familiar with Expo and all but I'll try my best

<!-- gh-comment-id:3687963569 --> @ping-maxwell commented on GitHub (Dec 23, 2025): Hello @MarkLee425 I will try to take a look, I'm not super familiar with Expo and all but I'll try my best
Author
Owner

@MarkLee425 commented on GitHub (Dec 23, 2025):

Thanks so much. Because I have many projects at the same time, and this project is about to launch. So I cannot do a pr to fix it and I am a bit worried 😅
You can also take reference to https://github.com/MarkLee425/better-auth-expo-issue-testing and test the error there. Thanks.

<!-- gh-comment-id:3688157564 --> @MarkLee425 commented on GitHub (Dec 23, 2025): Thanks so much. Because I have many projects at the same time, and this project is about to launch. So I cannot do a pr to fix it and I am a bit worried 😅 You can also take reference to `https://github.com/MarkLee425/better-auth-expo-issue-testing` and test the error there. Thanks.
Author
Owner

@bytaesu commented on GitHub (Dec 24, 2025):

Hi @MarkLee425,

I haven't tested the double request issue yet, but I submitted a fix PR for the incorrect cookie name handling. This might incidentally resolve the problem, though an underlying issue could still remain.

<!-- gh-comment-id:3690341049 --> @bytaesu commented on GitHub (Dec 24, 2025): Hi @MarkLee425, I haven't tested the double request issue yet, but I submitted a fix PR for the incorrect cookie name handling. This might incidentally resolve the problem, though an underlying issue could still remain.
Author
Owner

@MarkLee425 commented on GitHub (Dec 25, 2025):

Hi @MarkLee425,

I haven't tested the double request issue yet, but I submitted a fix PR for the incorrect cookie name handling. This might incidentally resolve the problem, though an underlying issue could still remain.

Thanks for your help.

<!-- gh-comment-id:3691754521 --> @MarkLee425 commented on GitHub (Dec 25, 2025): > Hi [@MarkLee425](https://github.com/MarkLee425), > > I haven't tested the double request issue yet, but I submitted a fix PR for the incorrect cookie name handling. This might incidentally resolve the problem, though an underlying issue could still remain. Thanks for your help.
Author
Owner

@MarkLee425 commented on GitHub (Jan 5, 2026):

Hi @MarkLee425,

I haven't tested the double request issue yet, but I submitted a fix PR for the incorrect cookie name handling. This might incidentally resolve the problem, though an underlying issue could still remain.

Hi, I have tested with the latest version which included this fix PR, but the same issue still remains. And I also found that Magic Link in Expo also has the same issue.

<!-- gh-comment-id:3710561905 --> @MarkLee425 commented on GitHub (Jan 5, 2026): > Hi [@MarkLee425](https://github.com/MarkLee425), > > I haven't tested the double request issue yet, but I submitted a fix PR for the incorrect cookie name handling. This might incidentally resolve the problem, though an underlying issue could still remain. Hi, I have tested with the latest version which included this fix PR, but the same issue still remains. And I also found that Magic Link in Expo also has the same issue.
Author
Owner

@Wizzel1 commented on GitHub (Jan 8, 2026):

I have the same issue

<!-- gh-comment-id:3723507618 --> @Wizzel1 commented on GitHub (Jan 8, 2026): I have the same issue
Author
Owner

@MarkLee425 commented on GitHub (Jan 14, 2026):

@dosu I have tested the better auth expo plugin, and seems like it has issue to set sign cookies in the plugin, while the first set cookie function returns a correct value.

<!-- gh-comment-id:3748320088 --> @MarkLee425 commented on GitHub (Jan 14, 2026): @dosu I have tested the better auth expo plugin, and seems like it has issue to set sign cookies in the plugin, while the first set cookie function returns a correct value.
Author
Owner

@dosubot[bot] commented on GitHub (Jan 14, 2026):

You're right—this is a known issue with the better-auth Expo plugin. The root cause is usually a race condition or overwrite: after social sign-in, the backend sometimes sends two Set-Cookie responses in quick succession. The first contains the correct session_token, but the second is malformed or missing the token (like null=), which overwrites the valid cookie in SecureStore and causes the session to disappear in useSession. This is especially common with Google SSO and Magic Link flows in Expo Go/iOS.

Recent fixes have improved cookie parsing and storage in the Expo plugin, including better handling of malformed Set-Cookie headers and normalization of storage keys for SecureStore compatibility (PR #6990, PR #6933). If you haven't already, make sure you're on the latest versions of both better-auth and @better-auth/expo.

A reliable workaround is to call await authClient.getSession() immediately after await authClient.signIn.social(...) or magic link sign-in. This forces a session refresh and ensures the session data is available in SecureStore, even if the cookie was overwritten (issue reference, issue reference).

It's also worth logging the deep link URL and SecureStore contents after sign-in to confirm the cookie handoff, and double-checking your trustedOrigins and callbackURL config on the backend (troubleshooting guide).

If the problem persists after upgrading and applying these steps, let me know if it happens with other SSO providers or only Google/Magic Link—there may be provider-specific quirks we can dig into.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other

Chat with Dosu Open in Cursor Join Discord Share on X

<!-- gh-comment-id:3748338956 --> @dosubot[bot] commented on GitHub (Jan 14, 2026): <!-- Answer --> You're right—this is a known issue with the better-auth Expo plugin. The root cause is usually a race condition or overwrite: after social sign-in, the backend sometimes sends two Set-Cookie responses in quick succession. The first contains the correct session_token, but the second is malformed or missing the token (like `null=`), which overwrites the valid cookie in SecureStore and causes the session to disappear in useSession. This is especially common with Google SSO and Magic Link flows in Expo Go/iOS. Recent fixes have improved cookie parsing and storage in the Expo plugin, including better handling of malformed Set-Cookie headers and normalization of storage keys for SecureStore compatibility ([PR #6990](https://github.com/better-auth/better-auth/pull/6990), [PR #6933](https://github.com/better-auth/better-auth/pull/6933)). If you haven't already, make sure you're on the latest versions of both better-auth and @better-auth/expo. A reliable workaround is to call `await authClient.getSession()` immediately after `await authClient.signIn.social(...)` or magic link sign-in. This forces a session refresh and ensures the session data is available in SecureStore, even if the cookie was overwritten ([issue reference](https://github.com/better-auth/better-auth/issues/1443), [issue reference](https://github.com/better-auth/better-auth/issues/3711)). It's also worth logging the deep link URL and SecureStore contents after sign-in to confirm the cookie handoff, and double-checking your trustedOrigins and callbackURL config on the backend ([troubleshooting guide](https://github.com/better-auth/better-auth/issues/6936)). If the problem persists after upgrading and applying these steps, let me know if it happens with other SSO providers or only Google/Magic Link—there may be provider-specific quirks we can dig into. <!-- 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/9205cb4c-bf31-4668-a5ee-fb1c719764b2?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/9205cb4c-bf31-4668-a5ee-fb1c719764b2?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/9205cb4c-bf31-4668-a5ee-fb1c719764b2?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/9205cb4c-bf31-4668-a5ee-fb1c719764b2?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/9205cb4c-bf31-4668-a5ee-fb1c719764b2?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/9205cb4c-bf31-4668-a5ee-fb1c719764b2?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/9205cb4c-bf31-4668-a5ee-fb1c719764b2?feedback_type=other)</sup> [![Chat with Dosu](https://dosu.dev/dosu-chat-badge.svg)](https://app.dosu.dev/cdda13d9-dd27-4d31-b09a-5d8bec92de21/ask?utm_source=github)&nbsp;[![Open in Cursor](https://dosu.dev/dosu-in-cursor.svg)](https://cursor.com/link/prompt?text=You%27re%20right%E2%80%94this%20is%20a%20known%20issue%20with%20the%20better-auth%20Expo%20plugin.%20The%20root%20cause%20is%20usually%20a%20race%20condition%20or%20overwrite%3A%20after%20social%20sign-in%2C%20the%20backend%20sometimes%20sends%20two%20Set-Cookie%20responses%20in%20quick%20succession.%20The%20first%20contains%20the%20correct%20session_token%2C%20but%20the%20second%20is%20malformed%20or%20missing%20the%20token%20%28like%20%60null%3D%60%29%2C%20which%20overwrites%20the%20valid%20cookie%20in%20SecureStore%20and%20causes%20the%20session%20to%20disappear%20in%20useSession.%20This%20is%20especially%20common%20with%20Google%20SSO%20and%20Magic%20Link%20flows%20in%20Expo%20Go/iOS.%0A%0ARecent%20fixes%20have%20improved%20cookie%20parsing%20and%20storage%20in%20the%20Expo%20plugin%2C%20including%20better%20handling%20of%20malformed%20Set-Cookie%20headers%20and%20normalization%20of%20storage%20keys%20for%20SecureStore%20compatibility%20%28%5BPR%20%236990%5D%28https%3A//github.com/better-auth/better-auth/pull/6990%29%2C%20%5BPR%20%236933%5D%28https%3A//github.com/better-auth/better-auth/pull/6933%29%29.%20If%20you%20haven%27t%20already%2C%20make%20sure%20you%27re%20on%20the%20latest%20versions%20of%20both%20better-auth%20and%20%40better-auth/expo.%0A%0AA%20reliable%20workaround%20is%20to%20call%20%60await%20authClient.getSession%28%29%60%20immediately%20after%20%60await%20authClient.signIn.social%28...%29%60%20or%20magic%20link%20sign-in.%20This%20forces%20a%20session%20refresh%20and%20ensures%20the%20session%20data%20is%20available%20in%20SecureStore%2C%20even%20if%20the%20cookie%20was%20overwritten%20%28%5Bissue%20reference%5D%28https%3A//github.com/better-auth/better-auth/issues/1443%29%2C%20%5Bissue%20reference%5D%28https%3A//github.com/better-auth/better-auth/issues/3711%29%29.%0A%0AIt%27s%20also%20worth%20logging%20the%20deep%20link%20URL%20and%20SecureStore%20contents%20after%20sign-in%20to%20confirm%20the%20cookie%20handoff%2C%20and%20double-checking%20your%20trustedOrigins%20and%20callbackURL%20config%20on%20the%20backend%20%28%5Btroubleshooting%20guide%5D%28https%3A//github.com/better-auth/better-auth/issues/6936%29%29.%0A%0AIf%20the%20problem%20persists%20after%20upgrading%20and%20applying%20these%20steps%2C%20let%20me%20know%20if%20it%20happens%20with%20other%20SSO%20providers%20or%20only%20Google/Magic%20Link%E2%80%94there%20may%20be%20provider-specific%20quirks%20we%20can%20dig%20into.)&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/6810)
Author
Owner

@MarkLee425 commented on GitHub (Jan 20, 2026):

I have upgraded to the latest version and have tested many plugins. Mobile Number plugin is the one which can be used correctly. But other plugins including magic link, email otp, google sso, facebook sso etc are not working correctly.

<!-- gh-comment-id:3773735245 --> @MarkLee425 commented on GitHub (Jan 20, 2026): I have upgraded to the latest version and have tested many plugins. Mobile Number plugin is the one which can be used correctly. But other plugins including magic link, email otp, google sso, facebook sso etc are not working correctly.
Author
Owner

@MarkLee425 commented on GitHub (Jan 21, 2026):

Hi all, I think I have found the reason triggering this issue:
For all 200 response, it works seamlessly for the set-cookie header. (i.e. phone-number plugin)
For all 302 redirect response, the set-cookie header is not exposed on the redirect response. (i.e. magic link, all sso etc.)

This issue is confirmed that it is related to server side expo plugin, and irrelevant to client side expoClient.

I haven't checked very detailedly, but the reason is probably because of misconfiguration when using fetch api without { redirect: 'manual' }. We can refer to this nodejs issue: https://github.com/nodejs/node/issues/50742.

@ping-maxwell This is no longer a expo issue, but api fetching issue. May someone please help follow this issue? Thanks.

P.S.: I was using fastify to test, but the issue should be irrelevant to what framework it is using.

<!-- gh-comment-id:3778066958 --> @MarkLee425 commented on GitHub (Jan 21, 2026): Hi all, I think I have found the reason triggering this issue: For all 200 response, it works seamlessly for the `set-cookie` header. (i.e. phone-number plugin) For all 302 redirect response, the `set-cookie` header is not exposed on the redirect response. (i.e. magic link, all sso etc.) This issue is confirmed that it is related to server side `expo` plugin, and irrelevant to client side `expoClient`. I haven't checked very detailedly, but the reason is probably because of misconfiguration when using fetch api without `{ redirect: 'manual' }`. We can refer to this nodejs issue: https://github.com/nodejs/node/issues/50742. @ping-maxwell This is no longer a expo issue, but api fetching issue. May someone please help follow this issue? Thanks. P.S.: I was using fastify to test, but the issue should be irrelevant to what framework it is using.
Author
Owner

@laghamn commented on GitHub (Jan 21, 2026):

I'm having the same (or similar) problem with the Google provider. Expo app and Tanstack Start (which should be irrelevant).

Console log from better-auth onError handler on Tanstack server:
[InternalAPIError: ] { status: 'FOUND', body: undefined, headers: Headers { 'set-cookie': 'better-auth.state=; Max-Age=0, better-auth.session_token=ttfzlzD2O2WVh3pCNGgBq8ll7paaFYOw.5ibKPYu4qnruok3sOox9djiQHRLYNdEip6F6CBrA%2Fk4%3D; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax', location: 'expo:///' }, statusCode: 302 }

It seems that better-auth detects an "InternalAPIError"?

Console log cookie header in better-auth after hook:
better-auth.state=nArwLq4o6nWvGTpocHWaf5vGrVGjdzBZ.RN9T2%2BsOz8PcW4EOlqZm%2FqmCKaW89zHbg7VU9Nx37mQ%3D; null=; __Secure-better-auth.state=ZDHUi1xQDJ4JzGpLIa_vsj_cau7CzPKY.%2B7VUZGUYJCM9G15jT%2B7nGf%2BudoFbBAXdfmoNG2hpWXk%3D

My web application works as expected...

<!-- gh-comment-id:3780183514 --> @laghamn commented on GitHub (Jan 21, 2026): I'm having the same (or similar) problem with the Google provider. Expo app and Tanstack Start (which should be irrelevant). Console log from better-auth [onError handler](https://www.better-auth.com/docs/reference/options#onapierror) on Tanstack server: `[InternalAPIError: ] { status: 'FOUND', body: undefined, headers: Headers { 'set-cookie': 'better-auth.state=; Max-Age=0, better-auth.session_token=ttfzlzD2O2WVh3pCNGgBq8ll7paaFYOw.5ibKPYu4qnruok3sOox9djiQHRLYNdEip6F6CBrA%2Fk4%3D; Max-Age=604800; Path=/; HttpOnly; SameSite=Lax', location: 'expo:///' }, statusCode: 302 } ` It seems that better-auth detects an "InternalAPIError"? Console log cookie header in better-auth [after hook](https://www.better-auth.com/docs/reference/options#hooks): `better-auth.state=nArwLq4o6nWvGTpocHWaf5vGrVGjdzBZ.RN9T2%2BsOz8PcW4EOlqZm%2FqmCKaW89zHbg7VU9Nx37mQ%3D; null=; __Secure-better-auth.state=ZDHUi1xQDJ4JzGpLIa_vsj_cau7CzPKY.%2B7VUZGUYJCM9G15jT%2B7nGf%2BudoFbBAXdfmoNG2hpWXk%3D` My web application works as expected...
Author
Owner

@bytaesu commented on GitHub (Jan 22, 2026):

@laghamn @Wizzel1 @MarkLee425,

Could you briefly describe the main issue you're running into? I'd like to take a look 🧐

<!-- gh-comment-id:3785319852 --> @bytaesu commented on GitHub (Jan 22, 2026): @laghamn @Wizzel1 @MarkLee425, Could you briefly describe the main issue you're running into? I'd like to take a look 🧐
Author
Owner

@MarkLee425 commented on GitHub (Feb 14, 2026):

Hi. Same issue still occurs in the latest version of better-auth. The cookie will not have null=, but the session_token is still null while the session_state is valid.

<!-- gh-comment-id:3901117576 --> @MarkLee425 commented on GitHub (Feb 14, 2026): Hi. Same issue still occurs in the latest version of better-auth. The cookie will not have `null=`, but the session_token is still null while the session_state is valid.
Author
Owner

@MarkLee425 commented on GitHub (Feb 16, 2026):

@ping-maxwell @bytaesu Please follow this issue. This issue is not solved and should not be closed.

Regarding to the main issue, the cookie will still get invalid session_token while session_state is valid. There are two logs showing the first one can successfully get both, while the second one will get the null session_token. This issue only happens for the endpoints that will do 302 redirect in the api response, while so far it only affects expo integration.

<!-- gh-comment-id:3910276678 --> @MarkLee425 commented on GitHub (Feb 16, 2026): @ping-maxwell @bytaesu Please follow this issue. This issue is not solved and should not be closed. Regarding to the main issue, the cookie will still get invalid session_token while session_state is valid. There are two logs showing the first one can successfully get both, while the second one will get the null session_token. This issue only happens for the endpoints that will do 302 redirect in the api response, while so far it only affects expo integration.
Author
Owner

@bytaesu commented on GitHub (Feb 16, 2026):

Alright @MarkLee425,

I’ve been testing with the actual app myself, but I’ll check again. Could you share a minimal reproducible repo with me?

<!-- gh-comment-id:3910878354 --> @bytaesu commented on GitHub (Feb 16, 2026): Alright @MarkLee425, I’ve been testing with the actual app myself, but I’ll check again. Could you share a minimal reproducible repo with me?
Author
Owner

@bytaesu commented on GitHub (Feb 17, 2026):

Hi @MarkLee425,

Based on your setup, I think I've found an issue.
#8013 might solve your problem 🤔

<!-- gh-comment-id:3911877238 --> @bytaesu commented on GitHub (Feb 17, 2026): Hi @MarkLee425, Based on your setup, I think I've found an issue. #8013 might solve your problem 🤔
Author
Owner

@mehdyouras commented on GitHub (Feb 17, 2026):

In the end I ended up using @react-native-google-signin/google-signin and expo-apple-authentication. These return an idToken that you can use to sign in or sign up via better auth. Bonus on android and ios you have a native ui. It might not fit your use case if you use expo web.

<!-- gh-comment-id:3914277315 --> @mehdyouras commented on GitHub (Feb 17, 2026): In the end I ended up using `@react-native-google-signin/google-signin` and `expo-apple-authentication`. These return an `idToken` that you can use to sign in or sign up via better auth. Bonus on android and ios you have a native ui. It might not fit your use case if you use expo web.
Author
Owner

@bytaesu commented on GitHub (Feb 17, 2026):

In the end I ended up using @react-native-google-signin/google-signin and expo-apple-authentication. These return an idToken that you can use to sign in or sign up via better auth. Bonus on android and ios you have a native ui. It might not fit your use case if you use expo web.

https://canary.better-auth.com/docs/integrations/expo#idtoken-sign-in

I’ve added documentation for this. To use idToken, most of the required setup is on the Expo side rather than in Better Auth 🧐

<!-- gh-comment-id:3914300582 --> @bytaesu commented on GitHub (Feb 17, 2026): > In the end I ended up using `@react-native-google-signin/google-signin` and `expo-apple-authentication`. These return an `idToken` that you can use to sign in or sign up via better auth. Bonus on android and ios you have a native ui. It might not fit your use case if you use expo web. https://canary.better-auth.com/docs/integrations/expo#idtoken-sign-in I’ve added documentation for this. To use idToken, most of the required setup is on the Expo side rather than in Better Auth 🧐
Author
Owner

@mehdyouras commented on GitHub (Feb 17, 2026):

In the end I ended up using @react-native-google-signin/google-signin and expo-apple-authentication. These return an idToken that you can use to sign in or sign up via better auth. Bonus on android and ios you have a native ui. It might not fit your use case if you use expo web.

https://canary.better-auth.com/docs/integrations/expo#idtoken-sign-in

I’ve added documentation for this. To use idToken, most of the required setup is on the Expo side rather than in Better Auth 🧐

Exactly how I use it! It bypasses the issue of the redirecting after the expo browser closes 😄

<!-- gh-comment-id:3915131991 --> @mehdyouras commented on GitHub (Feb 17, 2026): > > In the end I ended up using `@react-native-google-signin/google-signin` and `expo-apple-authentication`. These return an `idToken` that you can use to sign in or sign up via better auth. Bonus on android and ios you have a native ui. It might not fit your use case if you use expo web. > > https://canary.better-auth.com/docs/integrations/expo#idtoken-sign-in > > I’ve added documentation for this. To use idToken, most of the required setup is on the Expo side rather than in Better Auth 🧐 Exactly how I use it! It bypasses the issue of the redirecting after the expo browser closes 😄
Author
Owner

@github-actions[bot] commented on GitHub (Apr 1, 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:4166557930 --> @github-actions[bot] commented on GitHub (Apr 1, 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#10637