Is this 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:
- Configure
betterAuth with a secondaryStorage and an adapter whose user read misses once.
- Create a user, then create a session for that user.
- Look at what landed in
secondaryStorage under the session token.
- 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:
createSession skips the secondaryStorage.set when the user lookup came back empty, so the next findSession falls through to the database and repairs itself.
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.
Is this 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, thenbun add better-authandbun repro.ts:Steps:
betterAuthwith asecondaryStorageand an adapter whose user read misses once.secondaryStorageunder the session token.findSessionwith that token.Output:
and
findSessionaround line 630: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 withif (!user) return null;. So the cache branch is out of step with better-auth's own handling of the identical situation.findSessionsreads the same cached shape at line 707 and would hit the same null, but itstry/catchswallows 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:
createSessionskips thesecondaryStorage.setwhen the user lookup came back empty, so the nextfindSessionfalls through to the database and repairs itself.findSessiontreats a cached entry with a nulluseras 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)
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
createSessionoccasionally 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 breakingfindSessions, 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.