Skip to content

eq null never matches absent optional fields β€” breaks oauth-provider refresh-token rotation (400 invalid_grant on every refresh)Β #399

Open

Description

@a-fatah

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-auth 0.12.4 (bug also present in 0.12.5 and on main @ c628916)
  • better-auth / @better-auth/oauth-provider 1.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:

https://github.com/get-convex/better-auth/blob/c628916b451a6b4cff0f5464f134475464b1a6da/src/client/adapter-utils.ts#L344-L347

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 generated WHERE revoked IS NULL matches.
  • 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:

https://github.com/better-auth/better-auth/blob/7f510cef533d432cc2ea8c23126a8db7617ede2a/packages/oauth-provider/src/token.ts#L385-L395

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

  1. Configure @better-auth/oauth-provider with this component.
  2. Run authorize + PKCE, exchange the code at /oauth2/token with offline_access β†’ get refresh_token. βœ…
  3. POST grant_type=refresh_token β†’ 400 invalid_grant "invalid refresh token" on the very first refresh. ❌ (expected: 200 with a rotated pair)

Suggested fixes

  1. Null/undefined coalescing for eq/ne null 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.

  2. A native incrementOne on the adapter would additionally make the oauth-provider's rotation CAS genuinely atomic and skip core's findMany+updateMany fallback (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

  1. a-fatah commented on Jul 3, 2026

    @a-fatah
    Author

    Cross-issue on the plugin side (write revoked: null explicitly at creation as defense-in-depth): better-auth/better-auth#10309

  2. added a commit that references this issue on Aug 12, 2026
    107db27
  3. Paalt commented on Aug 25, 2026

    @Paalt

    πŸ€– 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-auth 0.12.5 + better-auth/@better-auth/oauth-provider 1.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 orphaned oauthClient rows accumulate. Forensic signature, if it helps others confirm: zero revoked rows in oauthRefreshToken despite weeks of refresh attempts, because the rotation guard (incrementOne … where revoked eq null) never matches rows where revoked was never written.

    Workaround we shipped, in case it's useful to anyone landing here β€” two pieces, both app-side:

    1. Wrap the adapter factory passed to better-auth's database option so every oauthRefreshToken create carries an explicit revoked: null. An explicit null survives better-auth core's transformInput (only undefined is dropped), and because rotation-created rows go through the same create, it covers those too:
    function withExplicitRefreshTokenRevoked(
      factory: ReturnType<typeof authComponent.adapter>,
    ): ReturnType<typeof authComponent.adapter> {
      return (options) => {
        const adapter = factory(options);
        const create: typeof adapter.create = (args) => {
          const data = args.data as Record<string, unknown>;
          if (args.model === "oauthRefreshToken" && data.revoked === undefined) {
            return adapter.create({ ...args, data: { ...args.data, revoked: null } });
          }
          return adapter.create(args);
        };
        return { ...adapter, create };
      };
    }
    // usage: database: withExplicitRefreshTokenRevoked(authComponent.adapter(ctx))
    1. A one-time backfill mutation inside the component that stamps revoked: null onto pre-existing rows (note for local-install users: paginate() is unsupported inside components, so batch on _creationTime instead).

    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 revoked set to a timestamp, new row created with revoked: null).

    Agreed with the framing above that the durable fix belongs in the adapter's eq semantics β€” 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.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions