eq null never matches absent optional fields β breaks oauth-provider refresh-token rotation (400 invalid_grant on every refresh)Β #399
Description
eq null never matches absent optional fields β breaks @better-auth/oauth-provider refresh-token rotation (every refresh grant returns 400 invalid_grant)
Versions
@convex-dev/better-auth0.12.4 (bug also present in 0.12.5 and onmain@c628916)better-auth/@better-auth/oauth-provider1.6.22 (same behavior in 1.6.23 /main)- Reproduced end-to-end against a dev Convex deployment on 2026-07-03 (local component install,
local: { schema })
Summary
The adapter's where-filter implements eq as strict equality:
case undefined:
case "eq": {
return value === w.value;
}For a Convex document that has never had the field written, value is undefined, so a { operator: "eq", value: null } where never matches (undefined === null β false).
Every first-party better-auth adapter treats "unset optional field" as matching eq null:
- SQL adapters (Kysely/Drizzle/Prisma): an optional column that was never written is
NULL, and the generatedWHERE revoked IS NULLmatches. - MongoDB:
{ revoked: null }matches documents where the field is missing as well as explicitly null.
So plugins are written against a de-facto contract of "never-set == null", and the Convex adapter is the only one that diverges. That divergence is now user-visible:
Concrete breakage: oauth-provider refresh rotation
@better-auth/oauth-provider creates oauthRefreshToken rows without a revoked key (newRow in createRefreshToken), then guards rotation with a compare-and-swap:
const won = await ctx.context.adapter.incrementOne<{ id: string }>({
model: "oauthRefreshToken",
where: [
{ field: "id", value: originalRefresh.id },
{ field: "revoked", operator: "eq", value: null },
],
increment: {},
set: { revoked: new Date(iat * 1000) },
});
if (!won) throw new APIError("BAD_REQUEST", { error: "invalid_grant", ... });This adapter has no native incrementOne, so @better-auth/core's fallback runs findMany({ where, limit: 1 }) with that where (dist/db/adapter/factory.mjs). The revoked eq null leg goes through the strict-equality filter above, the freshly created row (no revoked field) never matches, findMany returns [], the fallback returns null, and the plugin throws.
Net effect: with this adapter, every single refresh_token grant fails with 400 invalid_grant / "invalid refresh token" β offline_access is unusable.
Repro
Adapter-level (minimal)
Inside any Convex function with the component adapter:
const adapter = /* betterAuthComponent adapter */;
const row = await adapter.create({
model: "oauthRefreshToken",
data: { token: "t", clientId: "c", userId: "u", scopes: ["openid"],
createdAt: new Date(), expiresAt: new Date(Date.now() + 86400_000) },
// note: no `revoked` key β exactly what the oauth-provider plugin writes
});
const found = await adapter.findMany({
model: "oauthRefreshToken",
where: [
{ field: "id", value: row.id },
{ field: "revoked", operator: "eq", value: null },
],
limit: 1,
});
// expected (per SQL/Mongo adapter behavior): [row]
// actual: []End-to-end
- Configure
@better-auth/oauth-providerwith this component. - Run authorize + PKCE, exchange the code at
/oauth2/tokenwithoffline_accessβ getrefresh_token. β - POST
grant_type=refresh_tokenβ 400invalid_grant"invalid refresh token" on the very first refresh. β (expected: 200 with a rotated pair)
Suggested fixes
-
Null/undefined coalescing for
eq/nenull in the where filter β e.g.case "eq": return w.value === null ? value === null || value === undefined : value === w.value;
(and the mirrored change for
ne). This matches the semantics of every other first-party adapter. Note the index-prefilter path (indexEqFieldsβq.eq(field, value)) has the same missing-vs-null caveat if a null-valued eq where is ever pushed into an index range. -
A native
incrementOneon the adapter would additionally make the oauth-provider's rotation CAS genuinely atomic and skip core'sfindMany+updateManyfallback (two round-trips through the component). Convex mutations are transactional, so read-check-patch inside one mutation is already a correct CAS β it just needs the null-vs-undefined comparison handled there too.
Either of (1) alone fixes the refresh grant; (2) is the better long-term shape.
Workaround (for anyone hitting this today)
Wrap the component adapter so oauthRefreshToken rows are created with an explicit revoked: null:
const adapterWithExplicitRefreshRevocation = (ctx: GenericCtx<DataModel>) => {
const factory = authComponent.adapter(ctx);
return ((options) => {
const adapter = factory(options);
const create: typeof adapter.create = async (args) => {
if (args.model !== "oauthRefreshToken" || "revoked" in args.data) return adapter.create(args);
return adapter.create({ ...args, data: { ...args.data, revoked: null } });
};
return { ...adapter, create };
}) satisfies ReturnType<typeof authComponent.adapter>;
};
// betterAuth({ database: adapterWithExplicitRefreshRevocation(ctx), ... })I'm also filing a cross-issue on better-auth suggesting the plugin write revoked: null explicitly at creation as defense-in-depth for document-store adapters (link to follow in a comment), but the adapter-side semantics seem like the root divergence β any plugin using the same "create without field, later match eq null" pattern (device-authorization does this with userId) breaks the same way on this adapter only.
Activity
a-fatah commentedon Jul 3, 2026
Cross-issue on the plugin side (write
revoked: nullexplicitly at creation as defense-in-depth): better-auth/better-auth#10309fix: match unset optional fields against null where clauses
Paalt commentedon Aug 25, 2026
π€ Written on behalf of @Paalt by Claude Fable 5 via Claude Code
Confirming this from an independent production hit, on current versions:
@convex-dev/better-auth0.12.5 +better-auth/@better-auth/oauth-provider1.6.29 (and the guard is unchanged in 1.7.1's dist).Impact in our deployment (an MCP connector for claude.ai): every refresh grant since migrating MCP auth to the oauth-provider plugin failed with
invalid_grant, so Claude fell back to a full re-authorization roughly daily β each one registering a fresh DCR client, so unrevoked refresh-token rows and orphanedoauthClientrows accumulate. Forensic signature, if it helps others confirm: zero revoked rows inoauthRefreshTokendespite weeks of refresh attempts, because the rotation guard (incrementOneβ¦where revoked eq null) never matches rows whererevokedwas never written.Workaround we shipped, in case it's useful to anyone landing here β two pieces, both app-side:
databaseoption so everyoauthRefreshTokencreate carries an explicitrevoked: null. An explicit null survives better-auth core'stransformInput(onlyundefinedis dropped), and because rotation-created rows go through the samecreate, it covers those too:revoked: nullonto pre-existing rows (note for local-install users:paginate()is unsupported inside components, so batch on_creationTimeinstead).Verified end-to-end against a dev deployment: pre-fix, a freshly-seeded prod-shape row 400s on refresh; post-fix, consecutive refresh grants succeed with proper rotation (old row's
revokedset to a timestamp, new row created withrevoked: null).Agreed with the framing above that the durable fix belongs in the adapter's
eqsemantics β plugins across the ecosystem are written against the "never-set == null" contract, and per-plugin defense-in-depth (better-auth/better-auth#10309) won't catch the next occurrence.Fix refresh-token guards for unset optional fields