[GH-ISSUE #7674] Expo client creates malformed Cookie starting with a ; which is not accepted by some load balancers such as the google loadbalancer #28190

Closed
opened 2026-04-17 19:37:21 -05:00 by GiteaMirror · 8 comments
Owner

Originally created by @Laurin-Notemann on GitHub (Jan 29, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7674

Originally assigned to: @bytaesu on GitHub.

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. start simulator or build app and use app on phone.
  2. connect to a deployed version of the auth server (do not use your localhost server as that does not exhibit the same issue as describe below)
  3. login with any method (I tried otp and social login)
  4. log the useSession object and find that session is null but no error is reported in the expo layout or anywhere else

Current vs. Expected behavior

I expected that useSession would return a valid session object, in an expo environment

What version of Better Auth are you using?

1.4.16

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:40 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6000",
    "release": "25.2.0",
    "cpuCount": 10,
    "cpuModel": "Apple M1 Max",
    "totalMemory": "64.00 GB",
    "freeMemory": "0.09 GB"
  },
  "node": {
    "version": "v22.20.0",
    "env": "development"
  },
  "packageManager": {
    "name": "pnpm",
    "version": "10.23.0"
  },
  "frameworks": null,
  "databases": null,
  "betterAuth": {
    "version": "Unknown",
    "config": null
  }
}

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

Client

Auth config (if applicable)

import { expo } from '@better-auth/expo';
import { passkey } from '@better-auth/passkey';
import { sso } from '@better-auth/sso';
import { type BetterAuthOptions, betterAuth } from 'better-auth';
import { drizzleAdapter } from 'better-auth/adapters/drizzle';
import {
	admin,
	apiKey,
	customSession,
	emailOTP,
	lastLoginMethod,
	magicLink,
	multiSession,
	phoneNumber,
	twoFactor,
} from 'better-auth/plugins';
import { and, eq } from 'drizzle-orm';

import { db } from '@acme/db';
import { member, memberProfile } from '@acme/db/schema/auth';
import * as schema from '@acme/db/schema/auth';
import { getServerLogger } from '@acme/logger';
import {
	prepareOrganizationInvitationEmail,
	prepareSignInOtpEmail,
	sendOrganizationInvitationEmail,
	sendSignInOtpEmail,
} from '@acme/resend-client';
import { env as resendEnv } from '@acme/resend-client/env';
import { phoneFromTempEmail, tempEmailFromPhone } from '@acme/shared-auth';
import { sendOTP, verifyOTP } from '@acme/twilio-client';

import { cdnCookieHook } from './cdn-cookie-hook';
import { deactivationHook } from './deactivation-hook';
import { env } from './env';
import { getLogger } from './logger-store';
import { generateOTP } from './otp.config';
import { ac, god, user } from './permissions';

//const cache = getCache();

const serverLogger = getServerLogger();

const options = {
	// Trusted origins for CORS validation
	trustedOrigins: [
		env.CORS_ORIGIN,
		'acme://',
		...(process.env.NODE_ENV === 'development'
			? [
					'exp://', // Trust all Expo URLs (prefix matching)
					'exp://**', // Trust all Expo URLs (wildcard matching)
					'exp://192.168.*.*:*/**', // Trust 192.168.x.x IP range with any port and path
				]
			: []),
	],
	logger: {
		level: serverLogger.level as 'info' | 'warn' | 'error' | 'debug',
		log: (level: 'info' | 'warn' | 'error' | 'debug', message: string, ...args: unknown[]) => {
			if (args.length > 0) {
				serverLogger[level]({ betterAuth: args }, message);
			} else {
				serverLogger[level](message);
			}
		},
	},
	database: drizzleAdapter(db, {
		provider: 'pg',
		schema: schema,
	}),
	socialProviders: {
		google: {
			clientId: env.GOOGLE_CLIENT_ID ?? '',
			clientSecret: env.GOOGLE_CLIENT_SECRET ?? '',
			accessType: 'offline',
		},
	},
	user: {
		additionalFields: {
			userProfileId: {
				type: 'string',
				required: false,
			},
			firstName: {
				type: 'string',
				required: false,
			},
			lastName: {
				type: 'string',
				required: false,
			},
		},
	},
	rateLimit: {
		enabled: false,
		window: 10, //time window in seconds
		max: 100, //maximum number of requests
		customRules: {
			'get-session': false,
		},
	},
	session: {
		additionalFields: {
			activeOrganizationId: {
				type: 'string',
				required: false,
			},
		},
		expiresIn: env.SESSION_EXPIRES_IN,
		updateAge: env.SESSION_UPDATE_AGE, // every X the session expiration is updated
		freshAge: env.SESSION_FRESH_AGE, // A session is considered fresh if its createdAt is within the freshAge limit
		cookieCache: { enabled: true, maxAge: 5 * 60 }, // 5 minutes - session data cached in signed cookie
	},

	account: {
		accountLinking: {
			enabled: true,
		},
	},
	emailAndPassword: {
		enabled: true,
	},
	advanced: {
		defaultCookieAttributes: {
			sameSite: 'lax',
			secure: env.NODE_ENV === 'production',
			httpOnly: true,
		},
		database: {
			generateId: 'uuid',
		},
	appName: 'acme',
	hooks: {
		before: deactivationHook,
		after: cdnCookieHook,
	},
	plugins: [
		expo(),
		admin({
			ac,
			roles: {
				god,
				user,
			},
		}),
		lastLoginMethod({
			customResolveMethod: (ctx) => {
				// Track phone number authentication
				if (ctx.path === '/phone-number/send-otp' || ctx.path === '/phone-number/verify') {
					return 'phone';
				}
				// Return null to use default resolution
				return null;
			},
		}),
		twoFactor(),
		passkey({
			rpID: env.CORS_ORIGIN ? new URL(env.CORS_ORIGIN).hostname : 'localhost',
			rpName: 'acme',
		}),
		apiKey({
			enableMetadata: true,
			enableSessionForAPIKeys: true,
			rateLimit: {
				enabled: false,
			},
		}),
		sso(),
		multiSession(),
		magicLink({
			// TODO: Implement magic link email sending
			sendMagicLink: ({ email, url }, ctx) => {
				const logger = getLogger(ctx as { request?: Request; headers?: Headers });
				if (process.env.NODE_ENV !== 'production' && logger) {
					logger.info({ email, url }, '📧 Magic link');
					return;
				}
			},
		}),
		emailOTP({
			otpLength: env.OTP_LENGTH,
			expiresIn: env.OTP_EXPIRES_IN,
			generateOTP({ email }) {
				return generateOTP({
					email,
					length: env.OTP_LENGTH,
					isProduction: env.NODE_ENV === 'production',
				});
			},
			async sendVerificationOTP({ email, otp, type }, ctx) {
				const logger = getLogger(ctx as { request?: Request; headers?: Headers });
				if (process.env.NODE_ENV !== 'production' || !logger) {
					logger?.info({ email, otp, type }, `📧 OTP Email (${type})`);
					return;
				}

				try {
					if (type === 'sign-in') {
						// Since better-auth doesn't provide transaction context, we use a standalone pattern
						// 1. Insert the outbound email record with status='queued'
						// Note: Sign-in OTP is organization-independent, so we don't pass organizationId/memberProfileId
						const outboundEmailId = await db.transaction(async (trx) => {
							return await prepareSignInOtpEmail(trx, {
								recipientEmail: email,
							});
						});

						// 2. Send the email and update the record
						await sendSignInOtpEmail(db, outboundEmailId, {
							recipientEmail: email,
							otp,
						});
					} else if (type === 'email-verification') {
						// TODO: Implement email verification email sending when needed
						return;
					}
				} catch (error) {
					logger?.error({ error }, 'Failed to send OTP email');
				}
			},
		}),
		phoneNumber({
			otpLength: env.OTP_LENGTH,
			expiresIn: env.OTP_EXPIRES_IN,
			sendOTP: async ({ phoneNumber: phone, code }, ctx) => {
				const logger = getLogger(ctx as { request?: Request; headers?: Headers });
				if (process.env.NODE_ENV !== 'production' || !logger) {
					logger?.info({ phone, code }, '📱 OTP SMS');
					return;
				}

				try {
					await sendOTP(phone);
				} catch (error) {
					logger?.error({ error }, 'Failed to send OTP via Twilio');
				}
			},
			// In dev, Better Auth uses internal verification against stored code
			...(env.NODE_ENV === 'production' && {
				verifyOTP: async ({ phoneNumber: phone, code }, ctx) => {
					const logger = getLogger(ctx as { request?: Request; headers?: Headers });
					try {
						return await verifyOTP(phone, code);
					} catch (error) {
						logger?.error({ error }, 'Failed to verify OTP via Twilio');
						return false;
					}
				},
			}),
			signUpOnVerification: {
				getTempEmail: tempEmailFromPhone,
			},
		}),
	],
} satisfies BetterAuthOptions;

export const auth = betterAuth({
	...options,
	plugins: [
		customSession(async ({ user, session }) => {
			if (!session.activeOrganizationId) {
				return {
					user,
					session,
					memberProfile: null,
				};
			}

			const [result] = await db
				.select({ memberProfile })
				.from(member)
				.leftJoin(memberProfile, eq(member.memberProfileId, memberProfile.id))
				.where(
					and(eq(member.userId, user.id), eq(member.organizationId, session.activeOrganizationId))
				)
				.limit(1);

			return {
				user,
				session,
				memberProfile: result?.memberProfile ?? null,
			};
		}, options),
		...(options.plugins ?? []),
	],
});

export type Session = typeof auth.$Infer.Session;

export interface InvitationEmailData {
	invitation: typeof schema.invitation.$inferSelect;
	organization?: { name: string } | null;
}

const buildInvitationUrl = (type: 'email' | 'phone', value: string) => {
	const baseUrl = resendEnv.CORS_ORIGIN ?? 'http://localhost:3000';
	return `${baseUrl}/sign-in?${new URLSearchParams({ type, value }).toString()}`;
};

export async function sendInvitationEmail(data: InvitationEmailData): Promise<void> {
	// TODO: Because Better auth is being weird here they do not have the context here to we cant get an actual http logger here
	const logger = getServerLogger();

	const invitation = data.invitation;
	const organizationName = data.organization?.name ?? 'an organization';

	// Phone invitation - just log for now (SMS integration TODO)
	if (invitation.invitationType === 'phone_number') {
		const phone = phoneFromTempEmail(invitation.email);
		if (!phone) {
			logger?.warn('Invalid phone invitation, skipping');
			return;
		}
		const invitationUrl = buildInvitationUrl('phone', phone);
		logger.info({ phone, invitationUrl }, '📱 Phone invitation URL');
		return;
	}

	// Email invitation
	const invitationUrl = buildInvitationUrl('email', invitation.email);
	if (process.env.NODE_ENV !== 'production' || !logger) {
		logger.info(
			{ email: invitation.email, organizationName, invitationUrl },
			'📧 Invitation Email'
		);
		return;
	}

	try {
		// Since better-auth doesn't provide transaction context, we use a standalone pattern
		// 1. Insert the outbound email record with status='queued'
		const outboundEmailId = await db.transaction(async (trx) => {
			return await prepareOrganizationInvitationEmail(trx, {
				organizationId: invitation.organizationId,
				memberProfileId: invitation.memberProfileId,
				invitationId: invitation.id,
				recipientEmail: invitation.email,
			});
		});

		// 2. Send the email and update the record
		await sendOrganizationInvitationEmail(db, outboundEmailId, {
			recipientEmail: invitation.email,
			senderName: organizationName,
			organizationName,
			invitationUrl,
			invitationId: invitation.id,
		});
	} catch (error) {
		logger.error({ error }, 'Failed to send invitation email');
	}
}

Additional context

So basically I found out that when I use my staging backend that is hosted on a google cloud run instance that when I try to use authClient.useSession the session object is null. After many hours of debugging I figured out that the issue is that the cookie is not sent from the device to the server (the server sends back 200 anyways despite no cookie being there).

Now even more fascinating is that this bug does NOT occur when you use it locally because in that case the cookie is being sent, which might be because the behaviour of localhost and credentials='omi' is different? I am not entirely sure tbh

After rebuilding the getSession function from the expo client I ran the same function only exchanging credentials=omit to credential=include, and leaving everything else as it was and it turned out that actually worked.

To me it seems like that is a potential bug, and well my App cannot work as long as the credentials = omit is hardcoded like that not even allowing me to pass in my own credentials key if I wanted to. I saw @Bekacru implemented the expo client a year ago, and I am wondering if I am either missing something here or if that is a genuine error.

said authClient:

import { expoClient } from '@better-auth/expo/client';
import {
	adminClient,
	apiKeyClient,
	customSessionClient,
	emailOTPClient,
	magicLinkClient,
	multiSessionClient,
	phoneNumberClient,
	twoFactorClient,
	usernameClient,
} from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import * as SecureStore from 'expo-secure-store';

import type { auth } from '@acme/auth';
import { ac, god, user } from '@acme/auth/permissions';

import { env } from './env';
import { fetchLog } from './logger';

export const authClient = createAuthClient({
	baseURL: env.EXPO_PUBLIC_SERVER_URL,
	fetchOptions: {
		onSuccess(context) {
			console.log('[auth success]', context.data);
		},
		onRequest(context) {
			console.log('[auth request]', context);
		},
		onResponse({ request, response }) {
			console.log('[auth response]', request, response.status);
		},
		onRequestError(context: any) {
			console.error('[auth request error]', context);
		},
		onResponseError({ request, response }: { request: any; response: any }) {
			console.error('[auth response error]', request, response.status, response.body);
		},
	},
	plugins: [
		customSessionClient<typeof auth>(),
		expoClient({
			scheme: 'acme',
			storagePrefix: 'acme',
			storage: SecureStore,
		}),
		adminClient({
			ac,
			roles: {
				god,
				user,
			},
		}),
		twoFactorClient(),
		phoneNumberClient(),
		usernameClient(),
		magicLinkClient(),
		emailOTPClient(),
		apiKeyClient(),
		multiSessionClient(),
	],
});

/**
 * Force a fresh session fetch from the database, bypassing better-auth's cookie cache.
 * Use this after operations that change session data (e.g., switching organizations).
 * This also refreshes the server-side cookie cache with the new data.
 */
export async function getSessionFresh() {
	const result = await authClient.getSession({
		query: { disableCookieCache: true },
	});
	return result.error ? null : result.data;
}

my replication that worked:

	async function handleFetchSessionExpoStyle() {
		setIsFetchingSessionExpoStyle(true);
		setSessionResultExpoStyle(null);
		setError(null);

		try {
			const baseURL = env.EXPO_PUBLIC_SERVER_URL;
			const storagePrefix = 'acme';
			const cookieKey = `${storagePrefix}_cookie`;

			// Match expoClient plugin init hook logic exactly
			const storedCookie = SecureStore.getItem(cookieKey);
			const cookie = getCookie(storedCookie || '{}');
			const scheme =
				(Array.isArray(Constants.expoConfig?.scheme)
					? Constants.expoConfig?.scheme[0]
					: Constants.expoConfig?.scheme) ?? 'acme';
			const expoOrigin = `${scheme}://`;
			const url = `${baseURL}/api/auth/get-session`;

			sessionLog.debug('Expo-style fetch get-session - Request (matching expoClient plugin)', {
				url,
				cookieKey,
				hasStoredCookie: Boolean(storedCookie),
				storedCookieLength: storedCookie?.length ?? 0,
				storedCookiePreview: storedCookie?.slice(0, 120) ?? 'none',
				cookie: cookie || 'none',
				cookieLength: cookie?.length ?? 0,
				scheme,
				expoOrigin,
			});

			// Match expoClient plugin init hook exactly:
			// - headers: cookie (always included, even if empty), expo-origin, x-skip-oauth-proxy
			const res = await fetch(url, {
				method: 'GET',
				credentials: 'include',
				headers: {
					cookie, // Always include cookie header (matches plugin behavior)
					'expo-origin': expoOrigin,
					'x-skip-oauth-proxy': 'true',
				},
			});

			const body = await res.text();
			const setCookie = res.headers.get('set-cookie');
			const allHeaderKeys = [...res.headers.keys()];

			sessionLog.info('Expo-style fetch get-session - Response', {
				status: res.status,
				statusText: res.statusText,
				hasSetCookie: Boolean(setCookie),
				setCookieLength: setCookie?.length ?? 0,
				setCookiePreview: setCookie?.slice(0, 200) ?? 'none',
				allHeaders: allHeaderKeys.join(', '),
				bodyLength: body.length,
				bodyPreview: body.slice(0, 200),
			});

			let parsedBody;
			try {
				parsedBody = JSON.parse(body);
				sessionLog.debug('Expo-style fetch get-session - Parsed body', {
					userId: parsedBody?.user?.id,
					activeOrgId: parsedBody?.session?.activeOrganizationId,
					hasUser: Boolean(parsedBody?.user),
					hasSession: Boolean(parsedBody?.session),
				});
			} catch {
				parsedBody = null;
				sessionLog.warn('Expo-style fetch get-session - Failed to parse body as JSON', {
					bodyPreview: body.slice(0, 200),
				});
			}

			if (res.ok && parsedBody) {
				const userId = parsedBody.user?.id ?? t('Kein Benutzer');
				const activeOrgId = parsedBody.session?.activeOrganizationId ?? t('Keine Organisation');
				setSessionResultExpoStyle(
					t(
						'Session erfolgreich (Expo-Style): Status {{status}}, Benutzer {{userId}}, Organisation {{orgId}}',
						{
							status: res.status,
							userId,
							orgId: activeOrgId,
						}
					)
				);
			} else {
				setSessionResultExpoStyle(
					t('Fehler (Expo-Style): Status {{status}}, Body: {{body}}', {
						status: res.status,
						body: body.slice(0, 100),
					})
				);
			}
		} catch (err) {
			sessionLog.error('Expo-style fetch get-session - Error', {
				error: (err as Error).message,
				stack: (err as Error).stack,
			});
			setSessionResultExpoStyle(
				t('Fehler beim Expo-Style Abrufen der Session: {{message}}', {
					message: (err as Error).message,
				})
			);
		} finally {
			setIsFetchingSessionExpoStyle(false);
		}
	}
Originally created by @Laurin-Notemann on GitHub (Jan 29, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7674 Originally assigned to: @bytaesu on GitHub. ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. start simulator or build app and use app on phone. 2. connect to a deployed version of the auth server (do not use your localhost server as that does not exhibit the same issue as describe below) 3. login with any method (I tried otp and social login) 4. log the useSession object and find that session is null but no error is reported in the expo layout or anywhere else ### Current vs. Expected behavior I expected that useSession would return a valid session object, in an expo environment ### What version of Better Auth are you using? 1.4.16 ### System info ```bash { "system": { "platform": "darwin", "arch": "arm64", "version": "Darwin Kernel Version 25.2.0: Tue Nov 18 21:09:40 PST 2025; root:xnu-12377.61.12~1/RELEASE_ARM64_T6000", "release": "25.2.0", "cpuCount": 10, "cpuModel": "Apple M1 Max", "totalMemory": "64.00 GB", "freeMemory": "0.09 GB" }, "node": { "version": "v22.20.0", "env": "development" }, "packageManager": { "name": "pnpm", "version": "10.23.0" }, "frameworks": null, "databases": null, "betterAuth": { "version": "Unknown", "config": null } } ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript import { expo } from '@better-auth/expo'; import { passkey } from '@better-auth/passkey'; import { sso } from '@better-auth/sso'; import { type BetterAuthOptions, betterAuth } from 'better-auth'; import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { admin, apiKey, customSession, emailOTP, lastLoginMethod, magicLink, multiSession, phoneNumber, twoFactor, } from 'better-auth/plugins'; import { and, eq } from 'drizzle-orm'; import { db } from '@acme/db'; import { member, memberProfile } from '@acme/db/schema/auth'; import * as schema from '@acme/db/schema/auth'; import { getServerLogger } from '@acme/logger'; import { prepareOrganizationInvitationEmail, prepareSignInOtpEmail, sendOrganizationInvitationEmail, sendSignInOtpEmail, } from '@acme/resend-client'; import { env as resendEnv } from '@acme/resend-client/env'; import { phoneFromTempEmail, tempEmailFromPhone } from '@acme/shared-auth'; import { sendOTP, verifyOTP } from '@acme/twilio-client'; import { cdnCookieHook } from './cdn-cookie-hook'; import { deactivationHook } from './deactivation-hook'; import { env } from './env'; import { getLogger } from './logger-store'; import { generateOTP } from './otp.config'; import { ac, god, user } from './permissions'; //const cache = getCache(); const serverLogger = getServerLogger(); const options = { // Trusted origins for CORS validation trustedOrigins: [ env.CORS_ORIGIN, 'acme://', ...(process.env.NODE_ENV === 'development' ? [ 'exp://', // Trust all Expo URLs (prefix matching) 'exp://**', // Trust all Expo URLs (wildcard matching) 'exp://192.168.*.*:*/**', // Trust 192.168.x.x IP range with any port and path ] : []), ], logger: { level: serverLogger.level as 'info' | 'warn' | 'error' | 'debug', log: (level: 'info' | 'warn' | 'error' | 'debug', message: string, ...args: unknown[]) => { if (args.length > 0) { serverLogger[level]({ betterAuth: args }, message); } else { serverLogger[level](message); } }, }, database: drizzleAdapter(db, { provider: 'pg', schema: schema, }), socialProviders: { google: { clientId: env.GOOGLE_CLIENT_ID ?? '', clientSecret: env.GOOGLE_CLIENT_SECRET ?? '', accessType: 'offline', }, }, user: { additionalFields: { userProfileId: { type: 'string', required: false, }, firstName: { type: 'string', required: false, }, lastName: { type: 'string', required: false, }, }, }, rateLimit: { enabled: false, window: 10, //time window in seconds max: 100, //maximum number of requests customRules: { 'get-session': false, }, }, session: { additionalFields: { activeOrganizationId: { type: 'string', required: false, }, }, expiresIn: env.SESSION_EXPIRES_IN, updateAge: env.SESSION_UPDATE_AGE, // every X the session expiration is updated freshAge: env.SESSION_FRESH_AGE, // A session is considered fresh if its createdAt is within the freshAge limit cookieCache: { enabled: true, maxAge: 5 * 60 }, // 5 minutes - session data cached in signed cookie }, account: { accountLinking: { enabled: true, }, }, emailAndPassword: { enabled: true, }, advanced: { defaultCookieAttributes: { sameSite: 'lax', secure: env.NODE_ENV === 'production', httpOnly: true, }, database: { generateId: 'uuid', }, appName: 'acme', hooks: { before: deactivationHook, after: cdnCookieHook, }, plugins: [ expo(), admin({ ac, roles: { god, user, }, }), lastLoginMethod({ customResolveMethod: (ctx) => { // Track phone number authentication if (ctx.path === '/phone-number/send-otp' || ctx.path === '/phone-number/verify') { return 'phone'; } // Return null to use default resolution return null; }, }), twoFactor(), passkey({ rpID: env.CORS_ORIGIN ? new URL(env.CORS_ORIGIN).hostname : 'localhost', rpName: 'acme', }), apiKey({ enableMetadata: true, enableSessionForAPIKeys: true, rateLimit: { enabled: false, }, }), sso(), multiSession(), magicLink({ // TODO: Implement magic link email sending sendMagicLink: ({ email, url }, ctx) => { const logger = getLogger(ctx as { request?: Request; headers?: Headers }); if (process.env.NODE_ENV !== 'production' && logger) { logger.info({ email, url }, '📧 Magic link'); return; } }, }), emailOTP({ otpLength: env.OTP_LENGTH, expiresIn: env.OTP_EXPIRES_IN, generateOTP({ email }) { return generateOTP({ email, length: env.OTP_LENGTH, isProduction: env.NODE_ENV === 'production', }); }, async sendVerificationOTP({ email, otp, type }, ctx) { const logger = getLogger(ctx as { request?: Request; headers?: Headers }); if (process.env.NODE_ENV !== 'production' || !logger) { logger?.info({ email, otp, type }, `📧 OTP Email (${type})`); return; } try { if (type === 'sign-in') { // Since better-auth doesn't provide transaction context, we use a standalone pattern // 1. Insert the outbound email record with status='queued' // Note: Sign-in OTP is organization-independent, so we don't pass organizationId/memberProfileId const outboundEmailId = await db.transaction(async (trx) => { return await prepareSignInOtpEmail(trx, { recipientEmail: email, }); }); // 2. Send the email and update the record await sendSignInOtpEmail(db, outboundEmailId, { recipientEmail: email, otp, }); } else if (type === 'email-verification') { // TODO: Implement email verification email sending when needed return; } } catch (error) { logger?.error({ error }, 'Failed to send OTP email'); } }, }), phoneNumber({ otpLength: env.OTP_LENGTH, expiresIn: env.OTP_EXPIRES_IN, sendOTP: async ({ phoneNumber: phone, code }, ctx) => { const logger = getLogger(ctx as { request?: Request; headers?: Headers }); if (process.env.NODE_ENV !== 'production' || !logger) { logger?.info({ phone, code }, '📱 OTP SMS'); return; } try { await sendOTP(phone); } catch (error) { logger?.error({ error }, 'Failed to send OTP via Twilio'); } }, // In dev, Better Auth uses internal verification against stored code ...(env.NODE_ENV === 'production' && { verifyOTP: async ({ phoneNumber: phone, code }, ctx) => { const logger = getLogger(ctx as { request?: Request; headers?: Headers }); try { return await verifyOTP(phone, code); } catch (error) { logger?.error({ error }, 'Failed to verify OTP via Twilio'); return false; } }, }), signUpOnVerification: { getTempEmail: tempEmailFromPhone, }, }), ], } satisfies BetterAuthOptions; export const auth = betterAuth({ ...options, plugins: [ customSession(async ({ user, session }) => { if (!session.activeOrganizationId) { return { user, session, memberProfile: null, }; } const [result] = await db .select({ memberProfile }) .from(member) .leftJoin(memberProfile, eq(member.memberProfileId, memberProfile.id)) .where( and(eq(member.userId, user.id), eq(member.organizationId, session.activeOrganizationId)) ) .limit(1); return { user, session, memberProfile: result?.memberProfile ?? null, }; }, options), ...(options.plugins ?? []), ], }); export type Session = typeof auth.$Infer.Session; export interface InvitationEmailData { invitation: typeof schema.invitation.$inferSelect; organization?: { name: string } | null; } const buildInvitationUrl = (type: 'email' | 'phone', value: string) => { const baseUrl = resendEnv.CORS_ORIGIN ?? 'http://localhost:3000'; return `${baseUrl}/sign-in?${new URLSearchParams({ type, value }).toString()}`; }; export async function sendInvitationEmail(data: InvitationEmailData): Promise<void> { // TODO: Because Better auth is being weird here they do not have the context here to we cant get an actual http logger here const logger = getServerLogger(); const invitation = data.invitation; const organizationName = data.organization?.name ?? 'an organization'; // Phone invitation - just log for now (SMS integration TODO) if (invitation.invitationType === 'phone_number') { const phone = phoneFromTempEmail(invitation.email); if (!phone) { logger?.warn('Invalid phone invitation, skipping'); return; } const invitationUrl = buildInvitationUrl('phone', phone); logger.info({ phone, invitationUrl }, '📱 Phone invitation URL'); return; } // Email invitation const invitationUrl = buildInvitationUrl('email', invitation.email); if (process.env.NODE_ENV !== 'production' || !logger) { logger.info( { email: invitation.email, organizationName, invitationUrl }, '📧 Invitation Email' ); return; } try { // Since better-auth doesn't provide transaction context, we use a standalone pattern // 1. Insert the outbound email record with status='queued' const outboundEmailId = await db.transaction(async (trx) => { return await prepareOrganizationInvitationEmail(trx, { organizationId: invitation.organizationId, memberProfileId: invitation.memberProfileId, invitationId: invitation.id, recipientEmail: invitation.email, }); }); // 2. Send the email and update the record await sendOrganizationInvitationEmail(db, outboundEmailId, { recipientEmail: invitation.email, senderName: organizationName, organizationName, invitationUrl, invitationId: invitation.id, }); } catch (error) { logger.error({ error }, 'Failed to send invitation email'); } } ``` ### Additional context So basically I found out that when I use my staging backend that is hosted on a google cloud run instance that when I try to use authClient.useSession the session object is null. After many hours of debugging I figured out that the issue is that the cookie is not sent from the device to the server (the server sends back 200 anyways despite no cookie being there). Now even more fascinating is that this bug does NOT occur when you use it locally because in that case the cookie is being sent, which might be because the behaviour of localhost and credentials='omi' is different? I am not entirely sure tbh After rebuilding the getSession function from the expo client I ran the same function only exchanging credentials=omit to credential=include, and leaving everything else as it was and it turned out that actually worked. To me it seems like that is a potential bug, and well my App cannot work as long as the credentials = omit is hardcoded like that not even allowing me to pass in my own credentials key if I wanted to. I saw @Bekacru implemented the expo client a year ago, and I am wondering if I am either missing something here or if that is a genuine error. said authClient: ``` import { expoClient } from '@better-auth/expo/client'; import { adminClient, apiKeyClient, customSessionClient, emailOTPClient, magicLinkClient, multiSessionClient, phoneNumberClient, twoFactorClient, usernameClient, } from 'better-auth/client/plugins'; import { createAuthClient } from 'better-auth/react'; import * as SecureStore from 'expo-secure-store'; import type { auth } from '@acme/auth'; import { ac, god, user } from '@acme/auth/permissions'; import { env } from './env'; import { fetchLog } from './logger'; export const authClient = createAuthClient({ baseURL: env.EXPO_PUBLIC_SERVER_URL, fetchOptions: { onSuccess(context) { console.log('[auth success]', context.data); }, onRequest(context) { console.log('[auth request]', context); }, onResponse({ request, response }) { console.log('[auth response]', request, response.status); }, onRequestError(context: any) { console.error('[auth request error]', context); }, onResponseError({ request, response }: { request: any; response: any }) { console.error('[auth response error]', request, response.status, response.body); }, }, plugins: [ customSessionClient<typeof auth>(), expoClient({ scheme: 'acme', storagePrefix: 'acme', storage: SecureStore, }), adminClient({ ac, roles: { god, user, }, }), twoFactorClient(), phoneNumberClient(), usernameClient(), magicLinkClient(), emailOTPClient(), apiKeyClient(), multiSessionClient(), ], }); /** * Force a fresh session fetch from the database, bypassing better-auth's cookie cache. * Use this after operations that change session data (e.g., switching organizations). * This also refreshes the server-side cookie cache with the new data. */ export async function getSessionFresh() { const result = await authClient.getSession({ query: { disableCookieCache: true }, }); return result.error ? null : result.data; } ``` my replication that worked: ``` async function handleFetchSessionExpoStyle() { setIsFetchingSessionExpoStyle(true); setSessionResultExpoStyle(null); setError(null); try { const baseURL = env.EXPO_PUBLIC_SERVER_URL; const storagePrefix = 'acme'; const cookieKey = `${storagePrefix}_cookie`; // Match expoClient plugin init hook logic exactly const storedCookie = SecureStore.getItem(cookieKey); const cookie = getCookie(storedCookie || '{}'); const scheme = (Array.isArray(Constants.expoConfig?.scheme) ? Constants.expoConfig?.scheme[0] : Constants.expoConfig?.scheme) ?? 'acme'; const expoOrigin = `${scheme}://`; const url = `${baseURL}/api/auth/get-session`; sessionLog.debug('Expo-style fetch get-session - Request (matching expoClient plugin)', { url, cookieKey, hasStoredCookie: Boolean(storedCookie), storedCookieLength: storedCookie?.length ?? 0, storedCookiePreview: storedCookie?.slice(0, 120) ?? 'none', cookie: cookie || 'none', cookieLength: cookie?.length ?? 0, scheme, expoOrigin, }); // Match expoClient plugin init hook exactly: // - headers: cookie (always included, even if empty), expo-origin, x-skip-oauth-proxy const res = await fetch(url, { method: 'GET', credentials: 'include', headers: { cookie, // Always include cookie header (matches plugin behavior) 'expo-origin': expoOrigin, 'x-skip-oauth-proxy': 'true', }, }); const body = await res.text(); const setCookie = res.headers.get('set-cookie'); const allHeaderKeys = [...res.headers.keys()]; sessionLog.info('Expo-style fetch get-session - Response', { status: res.status, statusText: res.statusText, hasSetCookie: Boolean(setCookie), setCookieLength: setCookie?.length ?? 0, setCookiePreview: setCookie?.slice(0, 200) ?? 'none', allHeaders: allHeaderKeys.join(', '), bodyLength: body.length, bodyPreview: body.slice(0, 200), }); let parsedBody; try { parsedBody = JSON.parse(body); sessionLog.debug('Expo-style fetch get-session - Parsed body', { userId: parsedBody?.user?.id, activeOrgId: parsedBody?.session?.activeOrganizationId, hasUser: Boolean(parsedBody?.user), hasSession: Boolean(parsedBody?.session), }); } catch { parsedBody = null; sessionLog.warn('Expo-style fetch get-session - Failed to parse body as JSON', { bodyPreview: body.slice(0, 200), }); } if (res.ok && parsedBody) { const userId = parsedBody.user?.id ?? t('Kein Benutzer'); const activeOrgId = parsedBody.session?.activeOrganizationId ?? t('Keine Organisation'); setSessionResultExpoStyle( t( 'Session erfolgreich (Expo-Style): Status {{status}}, Benutzer {{userId}}, Organisation {{orgId}}', { status: res.status, userId, orgId: activeOrgId, } ) ); } else { setSessionResultExpoStyle( t('Fehler (Expo-Style): Status {{status}}, Body: {{body}}', { status: res.status, body: body.slice(0, 100), }) ); } } catch (err) { sessionLog.error('Expo-style fetch get-session - Error', { error: (err as Error).message, stack: (err as Error).stack, }); setSessionResultExpoStyle( t('Fehler beim Expo-Style Abrufen der Session: {{message}}', { message: (err as Error).message, }) ); } finally { setIsFetchingSessionExpoStyle(false); } } ```
GiteaMirror added the locked label 2026-04-17 19:37:21 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Jan 29, 2026):

It sounds like you're encountering issues with the credentials="omit" option in your Expo client specifically in a production environment. Based on the behavior you've described, including the discovery that credentials="include" solves the problem, it's likely that there is a misconfiguration with how cookies are being handled across environments.

  1. Verify Trusted Origins: Ensure that the trustedOrigins in your Better Auth configuration correctly reflect the production environment. If your cookies are not being sent, it may be due to your API's CORS configuration and trusted origins not being set properly.

  2. Cookie Settings: You mentioned that useSession returning null is linked to cookies not being sent. Double-check your cookie attributes in the auth configuration, particularly the sameSite, secure, and httpOnly settings. Ensure that in production, the cookies are configured to be secure.

  3. Expo On the Production: There might be differences in behavior regarding local and production environments. Since you noted that the credentials seem to work with localhost while failing in production, try implementing additional logging on both the client and server to capture any insights when requests fail or cookies are not sent.

  4. Search for Issues: There have been past issues with similar symptoms, such as setting headers incorrectly or the absence of cookies during requests. It might be useful for you to refer to the existing discussions or reported problems regarding the Expo integration to see if a similar case exists (document 5).

If issues persist, consider tagging @bekacru or @ping__ to get tailored support from the Better Auth team!

Have you already reviewed your cookie and origin configurations? Let me know if you need further assistance on this!

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

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3816228900 --> @better-auth-agent[bot] commented on GitHub (Jan 29, 2026): It sounds like you're encountering issues with the `credentials="omit"` option in your Expo client specifically in a production environment. Based on the behavior you've described, including the discovery that `credentials="include"` solves the problem, it's likely that there is a misconfiguration with how cookies are being handled across environments. 1. **Verify Trusted Origins**: Ensure that the `trustedOrigins` in your Better Auth configuration correctly reflect the production environment. If your cookies are not being sent, it may be due to your API's CORS configuration and trusted origins not being set properly. 2. **Cookie Settings**: You mentioned that `useSession` returning null is linked to cookies not being sent. Double-check your cookie attributes in the auth configuration, particularly the `sameSite`, `secure`, and `httpOnly` settings. Ensure that in production, the cookies are configured to be secure. 3. **Expo On the Production**: There might be differences in behavior regarding local and production environments. Since you noted that the credentials seem to work with localhost while failing in production, try implementing additional logging on both the client and server to capture any insights when requests fail or cookies are not sent. 4. **Search for Issues**: There have been past issues with similar symptoms, such as setting headers incorrectly or the absence of cookies during requests. It might be useful for you to refer to the existing discussions or reported problems regarding the Expo integration to see if a similar case exists ([document 5](5)). If issues persist, consider tagging @bekacru or @ping__ to get tailored support from the Better Auth team! Have you already reviewed your cookie and origin configurations? Let me know if you need further assistance on this! _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/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

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

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

This is a known limitation: the Expo client in Better Auth hardcodes credentials: 'omit' for fetch requests on native platforms and does not expose a way to override it. This was originally done to work around duplicate cookie issues, but it means cookies are not sent automatically in production environments, which leads to useSession returning null when deployed (especially on platforms like Google Cloud Run) [expoClient code].

The plugin tries to work around this by manually setting the cookie header from SecureStore, but this approach can break in production due to CORS, cookie, or proxy differences between local and remote environments. There is currently no supported way to override the credentials option from client code [issue 2970].

Workarounds that have helped others:

  • Manually replicate the fetch logic with credentials: 'include' (as you did) when you need to ensure cookies are sent.
  • Always use authClient.getCookie() to generate the cookie header—never extract tokens manually from storage, as this can result in invalid cookies [issue 3892].
  • Double-check your backend CORS and trustedOrigins config to ensure your app's scheme and origin are allowed.
  • After sign-in, call await authClient.getSession() to force a session refresh and ensure the session is available [issue 3711].

If these steps don't resolve the problem, let me know what you see in your request/response logs and I can help debug further.

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:3816252454 --> @dosubot[bot] commented on GitHub (Jan 29, 2026): <!-- Greeting --> Hi @Laurin-Notemann! I'm [Dosu](https://go.dosu.dev/dosubot) and I’m helping the better-auth team. <!-- Answer --> This is a known limitation: the Expo client in Better Auth hardcodes `credentials: 'omit'` for fetch requests on native platforms and does not expose a way to override it. This was originally done to work around duplicate cookie issues, but it means cookies are not sent automatically in production environments, which leads to `useSession` returning `null` when deployed (especially on platforms like Google Cloud Run) [[expoClient code](https://github.com/better-auth/better-auth/blob/d99d3ceae647ceb27aceb1b35aa7d5ca122d2a94/packages/expo/src/client.ts#L271-L467)]. The plugin tries to work around this by manually setting the `cookie` header from SecureStore, but this approach can break in production due to CORS, cookie, or proxy differences between local and remote environments. There is currently no supported way to override the credentials option from client code [[issue 2970](https://github.com/better-auth/better-auth/issues/2970)]. Workarounds that have helped others: - Manually replicate the fetch logic with `credentials: 'include'` (as you did) when you need to ensure cookies are sent. - Always use `authClient.getCookie()` to generate the cookie header—never extract tokens manually from storage, as this can result in invalid cookies [[issue 3892](https://github.com/better-auth/better-auth/issues/3892)]. - Double-check your backend CORS and `trustedOrigins` config to ensure your app's scheme and origin are allowed. - After sign-in, call `await authClient.getSession()` to force a session refresh and ensure the session is available [[issue 3711](https://github.com/better-auth/better-auth/issues/3711)]. If these steps don't resolve the problem, let me know what you see in your request/response logs and I can help debug further. <!-- 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/c103acfb-79fb-4c24-ab81-8324ee45068e?feedback_type=great_response) | [Irrelevant](https://app.dosu.dev/response-feedback/c103acfb-79fb-4c24-ab81-8324ee45068e?feedback_type=irrelevant_answer) | [Incorrect](https://app.dosu.dev/response-feedback/c103acfb-79fb-4c24-ab81-8324ee45068e?feedback_type=incorrect_sources) | [Verbose](https://app.dosu.dev/response-feedback/c103acfb-79fb-4c24-ab81-8324ee45068e?feedback_type=too_verbose) | [Hallucination](https://app.dosu.dev/response-feedback/c103acfb-79fb-4c24-ab81-8324ee45068e?feedback_type=hallucination) | [Report 🐛](https://app.dosu.dev/response-feedback/c103acfb-79fb-4c24-ab81-8324ee45068e?feedback_type=bug_report) | [Other](https://app.dosu.dev/response-feedback/c103acfb-79fb-4c24-ab81-8324ee45068e?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=This%20is%20a%20known%20limitation%3A%20the%20Expo%20client%20in%20Better%20Auth%20hardcodes%20%60credentials%3A%20%27omit%27%60%20for%20fetch%20requests%20on%20native%20platforms%20and%20does%20not%20expose%20a%20way%20to%20override%20it.%20This%20was%20originally%20done%20to%20work%20around%20duplicate%20cookie%20issues%2C%20but%20it%20means%20cookies%20are%20not%20sent%20automatically%20in%20production%20environments%2C%20which%20leads%20to%20%60useSession%60%20returning%20%60null%60%20when%20deployed%20%28especially%20on%20platforms%20like%20Google%20Cloud%20Run%29%20%5B%5BexpoClient%20code%5D%28https%3A//github.com/better-auth/better-auth/blob/d99d3ceae647ceb27aceb1b35aa7d5ca122d2a94/packages/expo/src/client.ts%23L271-L467%29%5D.%0A%0AThe%20plugin%20tries%20to%20work%20around%20this%20by%20manually%20setting%20the%20%60cookie%60%20header%20from%20SecureStore%2C%20but%20this%20approach%20can%20break%20in%20production%20due%20to%20CORS%2C%20cookie%2C%20or%20proxy%20differences%20between%20local%20and%20remote%20environments.%20There%20is%20currently%20no%20supported%20way%20to%20override%20the%20credentials%20option%20from%20client%20code%20%5B%5Bissue%202970%5D%28https%3A//github.com/better-auth/better-auth/issues/2970%29%5D.%0A%0AWorkarounds%20that%20have%20helped%20others%3A%0A-%20Manually%20replicate%20the%20fetch%20logic%20with%20%60credentials%3A%20%27include%27%60%20%28as%20you%20did%29%20when%20you%20need%20to%20ensure%20cookies%20are%20sent.%0A-%20Always%20use%20%60authClient.getCookie%28%29%60%20to%20generate%20the%20cookie%20header%E2%80%94never%20extract%20tokens%20manually%20from%20storage%2C%20as%20this%20can%20result%20in%20invalid%20cookies%20%5B%5Bissue%203892%5D%28https%3A//github.com/better-auth/better-auth/issues/3892%29%5D.%0A-%20Double-check%20your%20backend%20CORS%20and%20%60trustedOrigins%60%20config%20to%20ensure%20your%20app%27s%20scheme%20and%20origin%20are%20allowed.%0A-%20After%20sign-in%2C%20call%20%60await%20authClient.getSession%28%29%60%20to%20force%20a%20session%20refresh%20and%20ensure%20the%20session%20is%20available%20%5B%5Bissue%203711%5D%28https%3A//github.com/better-auth/better-auth/issues/3711%29%5D.%0A%0AIf%20these%20steps%20don%27t%20resolve%20the%20problem%2C%20let%20me%20know%20what%20you%20see%20in%20your%20request/response%20logs%20and%20I%20can%20help%20debug%20further.)&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/7674)
Author
Owner

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

Hi @Laurin-Notemann,

I haven’t used Expo in production before, so I’d like to clarify a few things:

  • Are there any official docs that explain or relate to this behavior?
  • Could switching to credentials: "include" introduce any unintended side effects?
  • If the app is fully closed and reopened, does the session persist correctly?

Since this config has been in place for a long time, I'd like to proceed carefully 🧐

<!-- gh-comment-id:3827877105 --> @bytaesu commented on GitHub (Jan 31, 2026): Hi @Laurin-Notemann, I haven’t used Expo in production before, so I’d like to clarify a few things: - Are there any official docs that explain or relate to this behavior? - Could switching to credentials: "include" introduce any unintended side effects? - If the app is fully closed and reopened, does the session persist correctly? Since this config has been in place for a long time, I'd like to proceed carefully 🧐
Author
Owner

@Laurin-Notemann commented on GitHub (Jan 31, 2026):

Hey! @bytaesu

As far as i can tell there is no additional docs that I can find related to that. I really feel like next to nobody has ever tested the expo plugin in production because according to my research it should not be possible to send cookies from a device with credentials omit, which i might have misunderstood.

Ideally it would be nice to be able to set it and overwrite it with fetchOptions in the better auth client, instead of just hardcoding it.

<!-- gh-comment-id:3828080154 --> @Laurin-Notemann commented on GitHub (Jan 31, 2026): Hey! @bytaesu As far as i can tell there is no additional docs that I can find related to that. I really feel like next to nobody has ever tested the expo plugin in production because according to my research it should not be possible to send cookies from a device with credentials omit, which i might have misunderstood. Ideally it would be nice to be able to set it and overwrite it with fetchOptions in the better auth client, instead of just hardcoding it.
Author
Owner

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

Hi @Laurin-Notemann,

I remember helping people on Discord who were running this in production, so it’s definitely being used in live 🤔

I’ll discuss this further

<!-- gh-comment-id:3828085929 --> @bytaesu commented on GitHub (Jan 31, 2026): Hi @Laurin-Notemann, I remember helping people on Discord who were running this in production, so it’s definitely being used in live 🤔 I’ll discuss this further
Author
Owner

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

# `better-auth`
npm i https://pkg.pr.new/better-auth/better-auth@7774

# `@better-auth/expo`
npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/expo@7774

Hi @Laurin-Notemann,

I deployed the Expo app to Google Play and deployed the server to test in a full production environment, but couldn't reproduce the session issue. Could you share a minimal reproducible repo showing the specific scenario where the problem occurs?

<!-- gh-comment-id:3843510759 --> @bytaesu commented on GitHub (Feb 3, 2026): ```sh # `better-auth` npm i https://pkg.pr.new/better-auth/better-auth@7774 # `@better-auth/expo` npm i https://pkg.pr.new/better-auth/better-auth/@better-auth/expo@7774 ``` Hi @Laurin-Notemann, I deployed the Expo app to Google Play and deployed the server to test in a full production environment, but couldn't reproduce the session issue. Could you share a minimal reproducible repo showing the specific scenario where the problem occurs?
Author
Owner

@Laurin-Notemann commented on GitHub (Feb 4, 2026):

Hey @bytaesu, I will try to get around that today or tomorrow! Do you mind sharing your example?

<!-- gh-comment-id:3845379514 --> @Laurin-Notemann commented on GitHub (Feb 4, 2026): Hey @bytaesu, I will try to get around that today or tomorrow! Do you mind sharing your example?
Author
Owner

@Laurin-Notemann commented on GitHub (Feb 5, 2026):

hey @bytaesu i think i found the issue, it seems like the Google load balancer doesn't handle it well when the cookie contains a leading ";". As I did not encounter this issue when I hosted my minimal expo example on my hetzner server with traefik where everything worked out fine. And when I manually stripped the ';' it worked.

I am pretty sure the error is in this line 6516ba100c/packages/expo/src/client.ts (L111)

because when acc is empty is just has an empty before the ';' which causes the problem.

I will open a PR tomorrow, as I don't have time anymore today, or feel free if you read this to open this.

most sensibly the fix should be something like this?

export function getCookie(cookie: string) {
	let parsed = {} as Record<string, StoredCookie>;
	try {
		parsed = JSON.parse(cookie) as Record<string, StoredCookie>;
	} catch {}
	const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
		if (value.expires && new Date(value.expires) < new Date()) {
			return acc;
		}
		return acc ? `${acc}; ${key}=${value.value}` : `${key}=${value.value}`;
	}, "");
	return toSend;
}
<!-- gh-comment-id:3855173598 --> @Laurin-Notemann commented on GitHub (Feb 5, 2026): hey @bytaesu i think i found the issue, it seems like the Google load balancer doesn't handle it well when the cookie contains a leading ";". As I did not encounter this issue when I hosted my minimal expo example on my hetzner server with traefik where everything worked out fine. And when I manually stripped the ';' it worked. I am pretty sure the error is in this line https://github.com/better-auth/better-auth/blob/6516ba100c476f90f857e0bdff97f3505a94032f/packages/expo/src/client.ts#L111 because when acc is empty is just has an empty before the ';' which causes the problem. I will open a PR tomorrow, as I don't have time anymore today, or feel free if you read this to open this. most sensibly the fix should be something like this? ``` export function getCookie(cookie: string) { let parsed = {} as Record<string, StoredCookie>; try { parsed = JSON.parse(cookie) as Record<string, StoredCookie>; } catch {} const toSend = Object.entries(parsed).reduce((acc, [key, value]) => { if (value.expires && new Date(value.expires) < new Date()) { return acc; } return acc ? `${acc}; ${key}=${value.value}` : `${key}=${value.value}`; }, ""); return toSend; } ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#28190