[Bug] Duplicate cookies causing "Invalid Base64 character" error with @better-auth/expo #1337

Closed
opened 2026-03-13 08:33:47 -05:00 by GiteaMirror · 7 comments
Owner

Originally created by @quuentinho on GitHub (Jun 10, 2025).

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Environment

  • Better Auth version: 1.2.8-beta.3 (web) / 1.2.8 (expo)
  • Framework: Next.js 15.3.1 + Expo SDK 53
  • Platform: React Native iOS (testing on simulator)

Problem Description

I'm experiencing an issue where duplicate better-auth.session_data cookies are being sent from my Expo app to my Next.js API, causing a "Invalid Base64 character: ," error on the server side.

Setup

Next.js API (working fine with web frontend)

// src/app/api/mobile/me/route.js
export async function GET(request) {
    const session = await auth.api.getSession({
        headers: request.headers,
        query: {
            disableCookieCache: true
        }
    })
    
    if (!session) {
        return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
    }
    
    // ... rest of the code
}

Expo Auth Client

// lib/auth-client.js
import { createAuthClient } from "better-auth/react"
import { expoClient } from "@better-auth/expo/client"
import * as SecureStore from "expo-secure-store"
import Constants from 'expo-constants'

const baseURL = Constants.expoConfig.extra.API_URL

export const authClient = createAuthClient({
    baseURL: baseURL,
    plugins: [
        expoClient({
            scheme: "myapp",
            storagePrefix: "myapp",
            storage: SecureStore
        })
    ]
})

Axios Configuration

// lib/axios.js
import axios from 'axios'
import Constants from 'expo-constants'
import { authClient } from './auth-client'

const baseURL = Constants.expoConfig.extra.API_URL

const axiosInstance = axios.create({
  baseURL,
  headers: {
    "Cookie": authClient.getCookie()
  }
})

export default axiosInstance

The Issue

Mobile Side (authClient.getCookie() output):

{
  "better-auth.session_token": {
    "value": "TOKEN_VALUE_HERE",
    "expires": "2025-06-10T12:03:53.703Z"
  },
  "better-auth.session_data": {
    "value": "ENCODED_SESSION_DATA_1",
    "expires": "2025-06-10T12:07:27.251Z"
  },
  "better-auth.dont_remember": {
    "value": "",
    "expires": "2025-06-10T11:53:45.571Z"
  }
}

cookie: 'better-auth.session_data=ENCODED_SESSION_DATA_1; better-auth.session_token=TOKEN_VALUE; better-auth.session_data=ENCODED_SESSION_DATA_2; better-auth.dont_remember='

Notice the duplicate better-auth.session_data entries with different values.

Server Error:

ERROR [Better Auth]: INTERNAL_SERVER_ERROR Error: Invalid Base64 character: ,
at async GET (src/app/api/mobile/me/route.js:12:20)

Questions

  1. Why are there duplicate better-auth.session_data cookies? On the mobile side, authClient.getCookie() only shows one entry, but somehow two different values are being sent.

  2. How should I properly handle cookies in Expo with Better Auth? Is there a recommended way to ensure clean cookie handling?

  3. Is this a known issue with the expo client plugin?

Workaround Attempts

I tried manually parsing and cleaning the cookies before sending them, but this feels like a hack rather than a proper solution.

Package Versions

Next.js App

{
  "better-auth": "^1.2.8-beta.3",
  "next": "15.3.1"
}

Expo App

{
  "better-auth": "^1.2.8",
  "@better-auth/expo": "^1.2.8",
  "expo": "^53.0.0"
}

Any help or insights would be greatly appreciated! 🙏

Current vs. Expected behavior

When making API calls from an Expo app to a Next.js backend using Better Auth, duplicate better-auth.session_data cookies are sent in the request header, causing session validation to fail with Invalid Base64 character: , error.

Expected behavior: The Expo app should send clean, properly formatted cookies (just like the web frontend does) and successfully authenticate API requests.

What version of Better Auth are you using?

1.2.8-beta.3

Provide environment information

- OS : iOS

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

Backend

Auth config (if applicable)

import bcrypt from "bcryptjs"
import { verifyPassword } from "better-auth/crypto"

import dotenv from "dotenv"
dotenv.config({
    path: ".env.local",
})

import { betterAuth } from "better-auth"

import { expo } from "@better-auth/expo"
import { nextCookies } from "better-auth/next-js"

import { drizzleAdapter } from "better-auth/adapters/drizzle"
import { db } from "../drizzle/db"

import * as schema from "../../db/schema"

export const auth = betterAuth({
    database: drizzleAdapter(db, {
        provider: "pg",
        schema: {
            ...schema
        },
    }),
    trustedOrigins: ["XXXX", "XXXX", "myapp://"],
    emailAndPassword: {
        enabled: true,
        password: {
            verify: async ({ hash, password }) => {
                if (hash.startsWith("$2y$")) {
                    return bcrypt.compareSync(password, hash)
                } else {
                    return await verifyPassword({ hash, password })
                }
            },
        },
    },
    session: {
        cookieCache: {
            enabled: true,
            maxAge: 5 * 60 // Cache duration in seconds
        }
    },
    socialProviders: {
        google: {
            clientId: process.env.GOOGLE_CLIENT_ID,
            clientSecret: process.env.GOOGLE_CLIENT_SECRET,
        },
    },
    account: {
        accountLinking: {
            enabled: true,
            allowDifferentEmails: true,
        },
    },
    plugins: [
        expo(),
        nextCookies(),
    ],
    advanced: {
        database: {
            generateId: false,
        },
    },
})

Additional context

No response

Originally created by @quuentinho on GitHub (Jun 10, 2025). ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce ## Environment - **Better Auth version**: `1.2.8-beta.3` (web) / `1.2.8` (expo) - **Framework**: Next.js 15.3.1 + Expo SDK 53 - **Platform**: React Native iOS (testing on simulator) ## Problem Description I'm experiencing an issue where duplicate `better-auth.session_data` cookies are being sent from my Expo app to my Next.js API, causing a "Invalid Base64 character: ," error on the server side. ## Setup ### Next.js API (working fine with web frontend) ```javascript // src/app/api/mobile/me/route.js export async function GET(request) { const session = await auth.api.getSession({ headers: request.headers, query: { disableCookieCache: true } }) if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } // ... rest of the code } ``` ### Expo Auth Client ```javascript // lib/auth-client.js import { createAuthClient } from "better-auth/react" import { expoClient } from "@better-auth/expo/client" import * as SecureStore from "expo-secure-store" import Constants from 'expo-constants' const baseURL = Constants.expoConfig.extra.API_URL export const authClient = createAuthClient({ baseURL: baseURL, plugins: [ expoClient({ scheme: "myapp", storagePrefix: "myapp", storage: SecureStore }) ] }) ``` ### Axios Configuration ```javascript // lib/axios.js import axios from 'axios' import Constants from 'expo-constants' import { authClient } from './auth-client' const baseURL = Constants.expoConfig.extra.API_URL const axiosInstance = axios.create({ baseURL, headers: { "Cookie": authClient.getCookie() } }) export default axiosInstance ``` ## The Issue ### Mobile Side (authClient.getCookie() output): ```javascript { "better-auth.session_token": { "value": "TOKEN_VALUE_HERE", "expires": "2025-06-10T12:03:53.703Z" }, "better-auth.session_data": { "value": "ENCODED_SESSION_DATA_1", "expires": "2025-06-10T12:07:27.251Z" }, "better-auth.dont_remember": { "value": "", "expires": "2025-06-10T11:53:45.571Z" } } ``` ### Server Side (received cookie header): cookie: 'better-auth.session_data=ENCODED_SESSION_DATA_1; better-auth.session_token=TOKEN_VALUE; better-auth.session_data=ENCODED_SESSION_DATA_2; better-auth.dont_remember=' **Notice the duplicate `better-auth.session_data` entries with different values.** ### Server Error: ERROR [Better Auth]: INTERNAL_SERVER_ERROR Error: Invalid Base64 character: , at async GET (src/app/api/mobile/me/route.js:12:20) ## Questions 1. **Why are there duplicate `better-auth.session_data` cookies?** On the mobile side, `authClient.getCookie()` only shows one entry, but somehow two different values are being sent. 2. **How should I properly handle cookies in Expo with Better Auth?** Is there a recommended way to ensure clean cookie handling? 3. **Is this a known issue with the expo client plugin?** ## Workaround Attempts I tried manually parsing and cleaning the cookies before sending them, but this feels like a hack rather than a proper solution. ## Package Versions ### Next.js App ```json { "better-auth": "^1.2.8-beta.3", "next": "15.3.1" } ``` ### Expo App ```json { "better-auth": "^1.2.8", "@better-auth/expo": "^1.2.8", "expo": "^53.0.0" } ``` Any help or insights would be greatly appreciated! 🙏 ### Current vs. Expected behavior When making API calls from an Expo app to a Next.js backend using Better Auth, duplicate better-auth.session_data cookies are sent in the request header, causing session validation to fail with Invalid Base64 character: , error. Expected behavior: The Expo app should send clean, properly formatted cookies (just like the web frontend does) and successfully authenticate API requests. ### What version of Better Auth are you using? 1.2.8-beta.3 ### Provide environment information ```bash - OS : iOS ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import bcrypt from "bcryptjs" import { verifyPassword } from "better-auth/crypto" import dotenv from "dotenv" dotenv.config({ path: ".env.local", }) import { betterAuth } from "better-auth" import { expo } from "@better-auth/expo" import { nextCookies } from "better-auth/next-js" import { drizzleAdapter } from "better-auth/adapters/drizzle" import { db } from "../drizzle/db" import * as schema from "../../db/schema" export const auth = betterAuth({ database: drizzleAdapter(db, { provider: "pg", schema: { ...schema }, }), trustedOrigins: ["XXXX", "XXXX", "myapp://"], emailAndPassword: { enabled: true, password: { verify: async ({ hash, password }) => { if (hash.startsWith("$2y$")) { return bcrypt.compareSync(password, hash) } else { return await verifyPassword({ hash, password }) } }, }, }, session: { cookieCache: { enabled: true, maxAge: 5 * 60 // Cache duration in seconds } }, socialProviders: { google: { clientId: process.env.GOOGLE_CLIENT_ID, clientSecret: process.env.GOOGLE_CLIENT_SECRET, }, }, account: { accountLinking: { enabled: true, allowDifferentEmails: true, }, }, plugins: [ expo(), nextCookies(), ], advanced: { database: { generateId: false, }, }, }) ``` ### Additional context _No response_
Author
Owner

@florian-deroo commented on GitHub (Jul 17, 2025):

Same issue, did you found any clean solution? @QuentinBoulay

@florian-deroo commented on GitHub (Jul 17, 2025): Same issue, did you found any clean solution? @QuentinBoulay
Author
Owner

@florian-deroo commented on GitHub (Jul 17, 2025):

Found a solution, add credentials: 'omit'

const [trpcClient] = useState(() =>
        api.createClient({
            links: [
                httpBatchLink({
                    url: `${process.env.EXPO_PUBLIC_BASE_URL}/api/trpc`,
                    transformer: SuperJSON,
                    headers() {
                        const headers: Record<string, string> = {};
                        const cookie = authClient.getCookie();
                        if (cookie) {
                            headers["Cookie"] = cookie;
                        }

                        return headers;
                    },
                    async fetch(input, init) {
                        return fetch(input, {
                            ...init,
                            credentials: 'omit',
                        });
                    },
                }),
            ],
        })
    );
@florian-deroo commented on GitHub (Jul 17, 2025): Found a solution, add credentials: 'omit' ``` const [trpcClient] = useState(() => api.createClient({ links: [ httpBatchLink({ url: `${process.env.EXPO_PUBLIC_BASE_URL}/api/trpc`, transformer: SuperJSON, headers() { const headers: Record<string, string> = {}; const cookie = authClient.getCookie(); if (cookie) { headers["Cookie"] = cookie; } return headers; }, async fetch(input, init) { return fetch(input, { ...init, credentials: 'omit', }); }, }), ], }) ); ```
Author
Owner

@Kinfe123 commented on GitHub (Aug 14, 2025):

it should be fixed on the latest patch release.

@Kinfe123 commented on GitHub (Aug 14, 2025): it should be fixed on the latest patch release.
Author
Owner

@kavinaravind commented on GitHub (Aug 15, 2025):

I believe this issue isn't fully fixed, I have tried a similar flow with v1.3.6-beta.2:

EXPO:

headers() {
    const headers = new Map<string, string>();
    headers.set("x-trpc-source", "expo-react");

    const cookies = authClient.getCookie();
    if (cookies) {
      headers.set("Cookie", cookies);
    }
    console.log("HEADERS: ", Object.fromEntries(headers));
    return Object.fromEntries(headers);
},

HEADERS: {"Cookie": "; better-auth.session_token=LTiP8MlR7VaxL0flAGcKaWiBajppyLk7.MqvvmdRQfodgPrU3la6uafL8Aii8AE10EjwU7jn%2FjM8%3D", "x-trpc-source": "expo-react"}

NextJS:

const authApi = opts.auth.api;
const session = await authApi.getSession({
  headers: opts.headers,
});
console.log("SESSION: ", session, opts.headers);

SESSION: null Headers {
  ...
  'x-trpc-source': 'expo-react',
  cookie: 'better-auth.session_token=GsQWCyiI66EMbDj8EHJKlWFLxlfUmjqC.91k%2FwVjp%2Biq0SYtB%2F%2FNCJCzSgxL5TYBm%2FyodwvHQkDQ%3D,; better-auth.session_token=LTiP8MlR7VaxL0flAGcKaWiBajppyLk7.MqvvmdRQfodgPrU3la6uafL8Aii8AE10EjwU7jn%2FjM8%3D',
  ...
}

If I parse the headers beforehand and remove the duplicate, authApi.getSession works in expo

@kavinaravind commented on GitHub (Aug 15, 2025): I believe this issue isn't fully fixed, I have tried a similar flow with `v1.3.6-beta.2`: EXPO: ```js headers() { const headers = new Map<string, string>(); headers.set("x-trpc-source", "expo-react"); const cookies = authClient.getCookie(); if (cookies) { headers.set("Cookie", cookies); } console.log("HEADERS: ", Object.fromEntries(headers)); return Object.fromEntries(headers); }, HEADERS: {"Cookie": "; better-auth.session_token=LTiP8MlR7VaxL0flAGcKaWiBajppyLk7.MqvvmdRQfodgPrU3la6uafL8Aii8AE10EjwU7jn%2FjM8%3D", "x-trpc-source": "expo-react"} ``` NextJS: ```js const authApi = opts.auth.api; const session = await authApi.getSession({ headers: opts.headers, }); console.log("SESSION: ", session, opts.headers); SESSION: null Headers { ... 'x-trpc-source': 'expo-react', cookie: 'better-auth.session_token=GsQWCyiI66EMbDj8EHJKlWFLxlfUmjqC.91k%2FwVjp%2Biq0SYtB%2F%2FNCJCzSgxL5TYBm%2FyodwvHQkDQ%3D,; better-auth.session_token=LTiP8MlR7VaxL0flAGcKaWiBajppyLk7.MqvvmdRQfodgPrU3la6uafL8Aii8AE10EjwU7jn%2FjM8%3D', ... } ``` If I parse the headers beforehand and remove the duplicate, `authApi.getSession` works in expo
Author
Owner

@luozhouyang commented on GitHub (Sep 10, 2025):

Same issue

@luozhouyang commented on GitHub (Sep 10, 2025): Same issue
Author
Owner

@quuentinho commented on GitHub (Sep 11, 2025):

I believe this issue isn't fully fixed, I have tried a similar flow with v1.3.6-beta.2:

EXPO:

headers() {
const headers = new Map<string, string>();
headers.set("x-trpc-source", "expo-react");

const cookies = authClient.getCookie();
if (cookies) {
  headers.set("Cookie", cookies);
}
console.log("HEADERS: ", Object.fromEntries(headers));
return Object.fromEntries(headers);

},

HEADERS: {"Cookie": "; better-auth.session_token=LTiP8MlR7VaxL0flAGcKaWiBajppyLk7.MqvvmdRQfodgPrU3la6uafL8Aii8AE10EjwU7jn%2FjM8%3D", "x-trpc-source": "expo-react"}
NextJS:

const authApi = opts.auth.api;
const session = await authApi.getSession({
headers: opts.headers,
});
console.log("SESSION: ", session, opts.headers);

SESSION: null Headers {
...
'x-trpc-source': 'expo-react',
cookie: 'better-auth.session_token=GsQWCyiI66EMbDj8EHJKlWFLxlfUmjqC.91k%2FwVjp%2Biq0SYtB%2F%2FNCJCzSgxL5TYBm%2FyodwvHQkDQ%3D,; better-auth.session_token=LTiP8MlR7VaxL0flAGcKaWiBajppyLk7.MqvvmdRQfodgPrU3la6uafL8Aii8AE10EjwU7jn%2FjM8%3D',
...
}
If I parse the headers beforehand and remove the duplicate, authApi.getSession works in expo

do you have any updates on this issue ?

@quuentinho commented on GitHub (Sep 11, 2025): > I believe this issue isn't fully fixed, I have tried a similar flow with `v1.3.6-beta.2`: > > EXPO: > > headers() { > const headers = new Map<string, string>(); > headers.set("x-trpc-source", "expo-react"); > > const cookies = authClient.getCookie(); > if (cookies) { > headers.set("Cookie", cookies); > } > console.log("HEADERS: ", Object.fromEntries(headers)); > return Object.fromEntries(headers); > }, > > HEADERS: {"Cookie": "; better-auth.session_token=LTiP8MlR7VaxL0flAGcKaWiBajppyLk7.MqvvmdRQfodgPrU3la6uafL8Aii8AE10EjwU7jn%2FjM8%3D", "x-trpc-source": "expo-react"} > NextJS: > > const authApi = opts.auth.api; > const session = await authApi.getSession({ > headers: opts.headers, > }); > console.log("SESSION: ", session, opts.headers); > > SESSION: null Headers { > ... > 'x-trpc-source': 'expo-react', > cookie: 'better-auth.session_token=GsQWCyiI66EMbDj8EHJKlWFLxlfUmjqC.91k%2FwVjp%2Biq0SYtB%2F%2FNCJCzSgxL5TYBm%2FyodwvHQkDQ%3D,; better-auth.session_token=LTiP8MlR7VaxL0flAGcKaWiBajppyLk7.MqvvmdRQfodgPrU3la6uafL8Aii8AE10EjwU7jn%2FjM8%3D', > ... > } > If I parse the headers beforehand and remove the duplicate, `authApi.getSession` works in expo do you have any updates on this issue ?
Author
Owner

@kavinaravind commented on GitHub (Sep 30, 2025):

do you have any updates on this issue ?

Apologies for the late reply @quuentinho, I just saw this now. If I recall correctly, I ended up using similar logic in this thread:

httpBatchLink({
  transformer: superjson,
  url: `${getBaseUrl()}/api/trpc`,
  headers() {
    const headers = new Map<string, string>();
    headers.set("x-trpc-source", "expo-react");

    const cookies = authClient.getCookie();
    if (cookies) {
      headers.set("Cookie", cookies);
    }
    return Object.fromEntries(headers);
  },
  async fetch(input, init) {
    return fetch(input, {
      ...init,
      credentials: "omit",
    });
  },
}),

credentials: "omit" seemed to work with removing any duplicates.

@kavinaravind commented on GitHub (Sep 30, 2025): > do you have any updates on this issue ? Apologies for the late reply @quuentinho, I just saw this now. If I recall correctly, I ended up using similar logic in this [thread](https://github.com/better-auth/better-auth/issues/2970#issuecomment-3085301827): ```ts httpBatchLink({ transformer: superjson, url: `${getBaseUrl()}/api/trpc`, headers() { const headers = new Map<string, string>(); headers.set("x-trpc-source", "expo-react"); const cookies = authClient.getCookie(); if (cookies) { headers.set("Cookie", cookies); } return Object.fromEntries(headers); }, async fetch(input, init) { return fetch(input, { ...init, credentials: "omit", }); }, }), ``` `credentials: "omit"` seemed to work with removing any duplicates.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#1337