Skip to content

createSession caches user: null in secondaryStorage when the post-write user read misses, and every later findSession throws for the rest of the session TTL #10884

Description

@kuuuurt

Is this suited for github?

  • Yes, this is suited for github

Reproduction

Standalone repro, no database or replica needed. The only thing it fakes is a single user read coming back empty, which is what a lagging read replica (or a read race, or a flaky connection) gives you in production.

Save as repro.ts, then bun add better-auth and bun repro.ts:

import { betterAuth } from "better-auth";
import { memoryAdapter } from "better-auth/adapters/memory";

const store = { user: [], session: [], account: [], verification: [] };

// One transient miss on the user read.
let missNextUserRead = true;
const flakyAdapter = (options) => {
  const adapter = memoryAdapter(store)(options);
  return {
    ...adapter,
    findOne: async (data) => {
      if (data.model === "user" && missNextUserRead) {
        missNextUserRead = false;
        return null;
      }
      return adapter.findOne(data);
    },
  };
};

const cache = new Map<string, string>();

const auth = betterAuth({
  baseURL: "http://localhost:3000",
  secret: "repro-secret-repro-secret-repro-secret",
  emailAndPassword: { enabled: true },
  database: flakyAdapter,
  secondaryStorage: {
    get: async key => cache.get(key) ?? null,
    set: async (key, value) => void cache.set(key, value),
    delete: async key => void cache.delete(key),
  },
});

const ctx = await auth.$context;

const user = await ctx.internalAdapter.createUser({
  email: "repro@example.com",
  name: "Repro",
  emailVerified: false,
});

const session = await ctx.internalAdapter.createSession(user.id);

console.log("user row exists in the database:", (await ctx.internalAdapter.findUserById(user.id)) !== null);
console.log("cached entry:", cache.get(session.token));

try {
  await ctx.internalAdapter.findSession(session.token);
  console.log("findSession returned normally");
} catch (error) {
  console.log("findSession threw:", (error as Error).message);
}

Steps:

  1. Configure betterAuth with a secondaryStorage and an adapter whose user read misses once.
  2. Create a user, then create a session for that user.
  3. Look at what landed in secondaryStorage under the session token.
  4. Call findSession with that token.

Output:

user row exists in the database: true
cached entry: {"session":{...,"userId":"204EPUb...","token":"CXd7yES..."},"user":null}
findSession threw: null is not an object (evaluating 's.user.createdAt')

### Current vs. Expected behavior

**Current:** `createSession` writes the session, re-reads the user, and stores `{ session, user }` in `secondaryStorage` without checking whether the user read actually returned a row. If it did not, the cached entry holds `user: null`. From then on `findSession` finds the entry, takes the cache branch, and does `new Date(s.user.createdAt)` on `null`, which throws. It keeps throwing until the entry expires, which for us is a 30 day session TTL. The user row is in the database the whole time and the session is perfectly valid, so there is nothing for the user to do except clear cookies, and nothing on our side except flush the cache key by hand.

Still the case on `main` today. In `packages/better-auth/src/db/internal-adapter.ts`, `createSession` around line 551:

```ts
const user = await (await getCurrentAdapter(adapter)).findOne<User>({
model: "user",
where: [{ field: "id", value: userId }],
});
const sessionTTL = getTTLSeconds(data.expiresAt, now);
if (sessionTTL > 0) {
await secondaryStorage.set(
  data.token,
  JSON.stringify({ session: sessionData, user }), // user can be null here
  sessionTTL,
);
}

and findSession around line 630:

const parsedUser = parseUserOutput(ctx.options, {
  ...s.user,                             // null spreads fine
  createdAt: new Date(s.user.createdAt), // this throws
  updatedAt: new Date(s.user.updatedAt),
});

The thing that made me want to report it rather than just work around it: the database branch of the very same findSession, at line 662, already handles this case correctly with if (!user) return null;. So the cache branch is out of step with better-auth's own handling of the identical situation.

findSessions reads the same cached shape at line 707 and would hit the same null, but its try/catch swallows it and skips the entry, so there the session just quietly disappears from the list instead of throwing.

Expected: a transient read miss should cost you a cache entry, not the session. Either of these would do it, and doing both seems reasonable:

  1. createSession skips the secondaryStorage.set when the user lookup came back empty, so the next findSession falls through to the database and repairs itself.
  2. findSession treats a cached entry with a null user as a miss and reads the database, matching what the database branch already does.

Happy to open a PR for either if you tell me which shape you would prefer.

What version of Better Auth are you using?

1.7.1

System info

{
  "system": {
    "platform": "darwin",
    "arch": "arm64",
    "version": "Darwin Kernel Version 25.5.0: Mon Apr 27 20:39:29 PDT 2026; root:xnu-12377.121.6~2/RELEASE_ARM64_T8142",
    "release": "25.5.0",
    "cpuCount": 10,
    "cpuModel": "Apple M5",
    "totalMemory": "32.00 GB",
    "freeMemory": "0.72 GB"
  },
  "node": {
    "version": "v24.16.0",
    "env": "development"
  },
  "packageManager": {
    "name": "bun",
    "version": "1.3.14"
  },
  "frameworks": [
    {
      "name": "react",
      "version": "19.2.5"
    },
    {
      "name": "hono",
      "version": "catalog:"
    }
  ],
  "databases": [
    {
      "name": "pg",
      "version": "^8.21.0"
    },
    {
      "name": "postgres",
      "version": "^3.4.7"
    },
    {
      "name": "drizzle",
      "version": "catalog:"
    }
  ],
  "betterAuth": {
    "version": "Unknown",
    "config": null,
    "error": "Couldn't read your auth config. Make sure to default export your auth instance or to export as a variable named auth."
  }
}

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

Backend

Auth config (if applicable)

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";

export const auth = betterAuth({
  emailAndPassword: { enabled: true },
  database: drizzleAdapter(database, { provider: "pg", usePlural: true, schema }),
  secondaryStorage: {
    get: key => redis.get(key),
    set: (key, value, ttl) => (ttl ? redis.set(key, value, { EX: ttl }) : redis.set(key, value)),
    delete: key => redis.del(key),
  },
  session: { expiresIn: 60 * 60 * 24 * 30 },
});

Additional context

We hit this in production on Postgres with a read replica. Our drizzle client routes reads to the replica, so the user read inside createSession occasionally landed on the replica before it had caught up with the insert, and those users were locked out for the full 30 days. We fixed it on our side by pointing the auth client at a replica free connection, and that is the right fix for our setup, but the same shape happens without replicas anywhere the read can come back empty for a moment.

Tested against 1.7.1, and the repro above is on a plain memory adapter, so nothing here is drizzle specific.

I looked for an existing issue first and could not find this one. The closest neighbours are all something else: #10580 is a "null" entry breaking findSessions, which is the same family but a different path, #10835 is a session row genuinely vanishing from the database rather than a poisoned cache entry, #10569 is a TTL flooring bug in the same file, and #9963 is the broader date coercion gap. Happy to have this closed as a duplicate if I missed one.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    coreCore infra, API routes, session, cookies, client SDKdatabaseDatabase layer, all adapters, schema, migrations

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions