[GH-ISSUE #3034] @better-auth/expo client plugin cookies bugs #9440

Closed
opened 2026-04-13 04:54:21 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @nkoynov on GitHub (Jun 15, 2025).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/3034

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

  1. Create a backend, add @better-auth/expo plugin
  2. Create a frontend and use client with @better-auth/expo client plugin
  3. Send a Set-Cookie header that includes both Expires and Max-Age.
  4. Allow the expo client plugin’s onSuccess hook to store it.
  5. Retrieve cookies via getCookie().
  6. Observe that:
  • Expired cookies may still be sent because the stored .expires value remains a string.
  • A small Max-Age (e.g. 60) should be lasting 60 000 ms (1 minute) as expected - but due to misinterpretation this actually behaves like 60 ms and is expired almost immediately.
  • When both Expires and Max-Age are present, the plugin honors the Expires timestamp even if Max-Age would already have expired the cookie.

Current vs. Expected behavior

Everything is in file packages/expo/src/client.ts

bug 1: JSON-parsed .expires is a string

Current Behavior

// getCookie()
// ⚠️ Don’t cast JSON.parse(cookie) directly to Record<string, StoredCookie>:
// the JSON “expires” field comes back as a string, not a Date.
// You must first parse and convert expires → Date, then you’ll have a valid StoredCookie.
parsed = JSON.parse(cookie) as Record<string, StoredCookie>;

//                          string < Date → coerces but is unreliable
if (value.expires && value.expires < new Date()) {
  return acc;
}
  • JSON.parse returns expires as a string, not a Date object.
  • Comparing that string to new Date() produces incorrect results, so expired cookies may still be sent.

Expected Behavior

  • After parsing, convert any non-null value.expires string into a real Date before comparison:
const expiresDate =
  typeof value.expires === "string"
    ? new Date(value.expires)
    : value.expires;
if (expiresDate && expiresDate < new Date()) {  }

bug 2: Max-Age misinterpreted as milliseconds

Current Behavior

// getSetCookie()
const maxAge = cookie["max-age"];
const expires = expiresAt
  ? new Date(String(expiresAt))
  : maxAge
    ? new Date(Date.now() + Number(maxAge))
    : null;
  • max-age is specified in seconds, but the code adds it directly to Date.now() (milliseconds).

Expected Behavior

  • Multiply maxAge by 1 000 to convert seconds → milliseconds:
new Date(Date.now() + Number(maxAge) * 1000)

bug 3: Precedence of Max-Age over Expires

Current Behavior

// getSetCookie()
const expires = expiresAt
  ? new Date(String(expiresAt))
  : maxAge
    ? /* … */
  • The code checks for the Expires attribute first
  • When both Expires and Max-Age are present, Expires is used even if Max-Age would have expired the cookie already.

Expected Behavior

  • Per the cookie specification, Max-Age must take precedence when both attributes are present.
  • Swap the order:
const expires = maxAge
  ? new Date(Date.now() + Number(maxAge) * 1000)
  : expiresAt
    ? new Date(String(expiresAt))
    : null;

What version of Better Auth are you using?

1.2.9

Provide environment information

- OS: MacOS 15.5
- Browser: Chrome

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

Client

Auth config (if applicable)

import { betterAuth } from 'better-auth';
import { expo } from '@better-auth/expo';

export const customPlugin = () => {
  return {
    id: 'customPlugin',
    endpoints: {
      customPluginEndpoint: createAuthEndpoint(
        '/custom-plugin/endpoint',
        { method: 'POST' },
        async (ctx) => {
            const someCookie = ctx.context.createAuthCookie('some-cookie-name', {
              maxAge: 0,
            });
            ctx.setCookie(someCookie.name, 'some-value', someCookie.attributes);
        },
      ),
    },
  };
};

export const auth = betterAuth({
  plugins: [expo(), customPlugin()],
});

Additional context

Three related issues affect how cookie expiration times are parsed, stored, and compared in the expoClient plugin:

  1. getCookie() casts the JSON-parsed expires field to Date | null, but the actual parsed value is a string, so the expiration check doesn’t work correctly.

  2. getSetCookie() treats the max-age attribute (specified in seconds) as milliseconds when constructing new Date(Date.now() + Number(maxAge)).

  3. When both Expires and Max-Age are present, the code gives precedence to Expires, but per the MDN spec, Max-Age must take precedence.

Originally created by @nkoynov on GitHub (Jun 15, 2025). Original GitHub issue: https://github.com/better-auth/better-auth/issues/3034 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce 1. Create a backend, add @better-auth/expo plugin 2. Create a frontend and use client with @better-auth/expo client plugin 3. Send a Set-Cookie header that includes both Expires and Max-Age. 4. Allow the expo client plugin’s onSuccess hook to store it. 5. Retrieve cookies via getCookie(). 6. Observe that: - Expired cookies may still be sent because the stored .expires value remains a string. - A small Max-Age (e.g. 60) should be lasting 60 000 ms (1 minute) as expected - but due to misinterpretation this actually behaves like 60 ms and is expired almost immediately. - When both Expires and Max-Age are present, the plugin honors the Expires timestamp even if Max-Age would already have expired the cookie. ### Current vs. Expected behavior Everything is in file `packages/expo/src/client.ts` # bug 1: JSON-parsed .expires is a string ## Current Behavior ```typescript // getCookie() // ⚠️ Don’t cast JSON.parse(cookie) directly to Record<string, StoredCookie>: // the JSON “expires” field comes back as a string, not a Date. // You must first parse and convert expires → Date, then you’ll have a valid StoredCookie. parsed = JSON.parse(cookie) as Record<string, StoredCookie>; … // string < Date → coerces but is unreliable if (value.expires && value.expires < new Date()) { return acc; } ``` - JSON.parse returns expires as a string, not a Date object. - Comparing that string to new Date() produces incorrect results, so expired cookies may still be sent. ### Expected Behavior - After parsing, convert any non-null value.expires string into a real Date before comparison: ```typescript const expiresDate = typeof value.expires === "string" ? new Date(value.expires) : value.expires; if (expiresDate && expiresDate < new Date()) { … } ``` # bug 2: Max-Age misinterpreted as milliseconds ## Current Behavior ```typescript // getSetCookie() const maxAge = cookie["max-age"]; const expires = expiresAt ? new Date(String(expiresAt)) : maxAge ? new Date(Date.now() + Number(maxAge)) : null; ``` - `max-age` is specified in seconds, but the code adds it directly to Date.now() (milliseconds). ### Expected Behavior - Multiply maxAge by 1 000 to convert seconds → milliseconds: ```typescript new Date(Date.now() + Number(maxAge) * 1000) ``` # bug 3: Precedence of Max-Age over Expires ## Current Behavior ```typescript // getSetCookie() const expires = expiresAt ? new Date(String(expiresAt)) : maxAge ? /* … */ ``` - The code checks for the Expires attribute first - When both Expires and Max-Age are present, Expires is used even if Max-Age would have expired the cookie already. ### Expected Behavior - Per the cookie specification, Max-Age must take precedence when both attributes are present. - Swap the order: ```typescript const expires = maxAge ? new Date(Date.now() + Number(maxAge) * 1000) : expiresAt ? new Date(String(expiresAt)) : null; ``` ### What version of Better Auth are you using? 1.2.9 ### Provide environment information ```bash - OS: MacOS 15.5 - Browser: Chrome ``` ### Which area(s) are affected? (Select all that apply) Client ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth'; import { expo } from '@better-auth/expo'; export const customPlugin = () => { return { id: 'customPlugin', endpoints: { customPluginEndpoint: createAuthEndpoint( '/custom-plugin/endpoint', { method: 'POST' }, async (ctx) => { const someCookie = ctx.context.createAuthCookie('some-cookie-name', { maxAge: 0, }); ctx.setCookie(someCookie.name, 'some-value', someCookie.attributes); }, ), }, }; }; export const auth = betterAuth({ plugins: [expo(), customPlugin()], }); ``` ### Additional context Three related issues affect how cookie expiration times are parsed, stored, and compared in the expoClient plugin: 1. `getCookie()` casts the JSON-parsed `expires` field to Date | null, but the actual parsed value is a string, so the expiration check doesn’t work correctly. 2. `getSetCookie()` treats the `max-age` attribute (specified in seconds) as milliseconds when constructing new `Date(Date.now() + Number(maxAge))`. 3. When both `Expires` and `Max-Age` are present, the code gives precedence to `Expires`, but per the [MDN spec](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Set-Cookie#:~:text=If%20both%20Expires%20and%20Max%2DAge%20are%20set%2C%20Max%2DAge%20has%20precedence), Max-Age must take precedence.
GiteaMirror added the lockedbug labels 2026-04-13 04:54:21 -05:00
Author
Owner

@nkoynov commented on GitHub (Jun 15, 2025):

This is the patch I'm using for now.

@better-auth%2Fexpo@1.2.9.patch

diff --git a/dist/client.mjs b/dist/client.mjs
index 82958fb70d61409b6ccd500aa057402c0c4f6397..8f68d29309f189185aba072cacd4281ef91d2552 100644
--- a/dist/client.mjs
+++ b/dist/client.mjs
@@ -24,7 +24,7 @@ function getSetCookie(header, prevCookie) {
   parsed.forEach((cookie, key) => {
     const expiresAt = cookie["expires"];
     const maxAge = cookie["max-age"];
-    const expires = expiresAt ? new Date(String(expiresAt)) : maxAge ? new Date(Date.now() + Number(maxAge)) : null;
+    const expires =  maxAge ? new Date(Date.now() + Number(maxAge) * 1000) : expiresAt ? new Date(String(expiresAt)) : null;
     toSetCookie[key] = {
       value: cookie["value"],
       expires
@@ -49,7 +49,8 @@ function getCookie(cookie) {
   } catch (e) {
   }
   const toSend = Object.entries(parsed).reduce((acc, [key, value]) => {
-    if (value.expires && value.expires < /* @__PURE__ */ new Date()) {
+    const expiresDate = typeof value.expires === "string" ? new Date(value.expires) : value.expires;
+    if (expiresDate && expiresDate < /* @__PURE__ */ new Date()) {
       return acc;
     }
     return `${acc}; ${key}=${value.value}`;
<!-- gh-comment-id:2973597424 --> @nkoynov commented on GitHub (Jun 15, 2025): This is the patch I'm using for now. **`@better-auth%2Fexpo@1.2.9.patch`** ```diff:title=@better-auth%2Fexpo@1.2.9.patch diff --git a/dist/client.mjs b/dist/client.mjs index 82958fb70d61409b6ccd500aa057402c0c4f6397..8f68d29309f189185aba072cacd4281ef91d2552 100644 --- a/dist/client.mjs +++ b/dist/client.mjs @@ -24,7 +24,7 @@ function getSetCookie(header, prevCookie) { parsed.forEach((cookie, key) => { const expiresAt = cookie["expires"]; const maxAge = cookie["max-age"]; - const expires = expiresAt ? new Date(String(expiresAt)) : maxAge ? new Date(Date.now() + Number(maxAge)) : null; + const expires = maxAge ? new Date(Date.now() + Number(maxAge) * 1000) : expiresAt ? new Date(String(expiresAt)) : null; toSetCookie[key] = { value: cookie["value"], expires @@ -49,7 +49,8 @@ function getCookie(cookie) { } catch (e) { } const toSend = Object.entries(parsed).reduce((acc, [key, value]) => { - if (value.expires && value.expires < /* @__PURE__ */ new Date()) { + const expiresDate = typeof value.expires === "string" ? new Date(value.expires) : value.expires; + if (expiresDate && expiresDate < /* @__PURE__ */ new Date()) { return acc; } return `${acc}; ${key}=${value.value}`; ```
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#9440