[GH-ISSUE #7892] API Key verification changes userId from ObjectId to string in MongoDB, causing keys to disappear from apiKey.list() #19573

Closed
opened 2026-04-15 18:50:16 -05:00 by GiteaMirror · 1 comment
Owner

Originally created by @dmytro-balytskyi on GitHub (Feb 10, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7892

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

Title: API Key verification changes userId from ObjectId to string in MongoDB, causing keys to disappear from apiKey.list()

Type: Bug
Affected version: better-auth 1.x (tested on latest as of February 2026)
Plugin: apiKey
Database adapter: MongoDB (via better-auth/adapters/mongodb adapter)

Description

After creating an API key via createApiKey (server-side or mutation), the key appears correctly in authClient.apiKey.list() with userId as ObjectId.

However, after the first successful verification (e.g. using the key in a protected endpoint like /api/leads with verifyApiKey), the userId field in the apikey collection changes its type from ObjectId to string.

As a result, the key disappears from the list returned by apiKey.list() on the client-side (or server-side list calls), even though the document still exists in the database.

Steps to Reproduce

  1. Configure better-auth with apiKey plugin and MongoDB adapter (Prisma or native).
    apiKey({
       enableMetadata: true,
       defaultPrefix: 'token_',
    })
    
  2. Create a new API key (via admin mutation or server-side auth.api.createApiKey):
await auth.api.createApiKey({
   body: {
      name: "test-key",
      userId: session.user.id
   }
})
  • userId is stored as ObjectId in MongoDB.
  1. Check the key in MongoDB:
db.apikey.findOne({ name: "test-key" })

// userId: ObjectId("67a...123")
4. Use the key in a protected endpoint:

const check = await auth.api.verifyApiKey({ body: { key: "'token_xxx" } })
  1. Check the document again in MongoDB: userId is now string: "67a...123" (type changed!)

  2. Call authClient.apiKey.list() or server-side list → the key is missing from results.

Environment

better-auth: "^1.4.18"
Database: MongoDB 7.0.20 Community
Adapter: MongoDB adapter
Node.js: v22.18.0
Relevant code snippet (verification):

const check = await auth.api.verifyApiKey({ body: { key: apiKeyHeader } })

Current vs. Expected behavior

Current Behavior

During verification, the plugin performs an update (likely to refresh lastUsedAt, remaining, etc.).
The update spreads the entire apiKey object, where userId is deserialized as string in JS.
MongoDB adapter applies $set: { userId: "..." } → type changes to string.
Subsequent list queries (or internal filtering) ignore/fail on keys with string userId.

Expected Behavior

Verification should not modify the type of userId (should remain ObjectId).
All keys should remain visible in apiKey.list() regardless of usage.
No silent type coercion on update operations.

What version of Better Auth are you using?

1.4.18

System info

better-auth: "^1.4.18"
Database: MongoDB 7.0.20 Community
Adapter: MongoDB adapter
Node.js: v22.18.0
Relevant code snippet (verification):

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

Backend

Auth config (if applicable)

import { betterAuth } from 'better-auth'
import { mongodbAdapter } from 'better-auth/adapters/mongodb'
import { admin as adminPlugin, apiKey as apiKeyPlugin } from 'better-auth/plugins'
import { createAuthMiddleware, APIError } from 'better-auth/api'
import { ac, admin, user, pending } from './permissions.js'
import config from '../config.js'

export let auth

export const initAuth = (db, client) => {
  auth = betterAuth({
    appName: 'LeadGuard',
    baseURL: config.BETTER_AUTH_URL,
    trustedOrigins: config.CORS.origin,
    secret: config.BETTER_AUTH_SECRET,
    database: mongodbAdapter(db, {
      client
    }),
    plugins: [
      adminPlugin({
        adminRoles: ['admin'],
        defaultRole: 'pending',
        ac,
        roles: {
          admin,
          user,
          pending
        }
      }),
      apiKeyPlugin({
        enableMetadata: true,
        permissions: {
          defaultPermissions: {}
        },
        defaultPrefix: 'lead_',
        keyExpiration: {
          defaultExpiresIn: null
        }
      })
    ],
    user: {
      additionalFields: {
        role: {
          type: 'string',
          default: 'pending',
          required: true,
          input: false
        }
      }
    },
    socialProviders: {
      google: {
        clientId: config.GOOGLE_CLIENT_ID,
        clientSecret: config.GOOGLE_CLIENT_SECRET
      }
    },
    emailAndPassword: {
      enabled: true
    },
    account: {
      accountLinking: {
        enabled: true
      }
    },
    session: {
      cookieCache: {
        enabled: true,
        maxAge: 5 * 60 // Cache duration in seconds
      },
      expiresIn: 60 * 60 * 24 * 7, // 7 days
      freshAge: 60 * 60 * 24 * 30 // 30 days
    },
    advanced: {
      ipAddress: {
        ipAddressHeaders: ['x-forwarded-for']
      },
      useSecureCookies: config.NODE_ENV === 'production',
      defaultCookieAttributes: {
        httpOnly: true,
        secure: config.NODE_ENV === 'production',
        sameSite: 'Lax'
      },
      cookiePrefix: 'auth',
      crossDomain: {
        enabled: true
      }
    },
    experimental: {
      joins: true
    },
    telemetry: {
      enabled: false
    }
  })

  return auth
}

Additional context

No response

Originally created by @dmytro-balytskyi on GitHub (Feb 10, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7892 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce **Title:** API Key verification changes `userId` from ObjectId to string in MongoDB, causing keys to disappear from `apiKey.list()` **Type:** Bug **Affected version:** better-auth 1.x (tested on latest as of February 2026) **Plugin:** apiKey **Database adapter:** MongoDB (via better-auth/adapters/mongodb adapter) ### Description After creating an API key via `createApiKey` (server-side or mutation), the key appears correctly in `authClient.apiKey.list()` with `userId` as **ObjectId**. However, **after the first successful verification** (e.g. using the key in a protected endpoint like `/api/leads` with `verifyApiKey`), the `userId` field in the `apikey` collection **changes its type from ObjectId to string**. As a result, the key **disappears** from the list returned by `apiKey.list()` on the client-side (or server-side list calls), even though the document still exists in the database. ### Steps to Reproduce 1. Configure better-auth with apiKey plugin and MongoDB adapter (Prisma or native). ```ts apiKey({ enableMetadata: true, defaultPrefix: 'token_', }) ``` 2. Create a new API key (via admin mutation or server-side auth.api.createApiKey): ```js await auth.api.createApiKey({ body: { name: "test-key", userId: session.user.id } }) ``` * userId is stored as ObjectId in MongoDB. 3. Check the key in MongoDB: ``` db.apikey.findOne({ name: "test-key" }) ``` // userId: ObjectId("67a...123") 4. Use the key in a protected endpoint: ```ts const check = await auth.api.verifyApiKey({ body: { key: "'token_xxx" } }) ``` 6. Check the document again in MongoDB: **userId is now string: "67a...123" (type changed!)** 6. Call authClient.apiKey.list() or server-side list → the key is missing from results. ## Environment better-auth: "^1.4.18" Database: MongoDB 7.0.20 Community Adapter: MongoDB adapter Node.js: v22.18.0 Relevant code snippet (verification): ```ts const check = await auth.api.verifyApiKey({ body: { key: apiKeyHeader } }) ``` ### Current vs. Expected behavior ## Current Behavior During verification, the plugin performs an update (likely to refresh lastUsedAt, remaining, etc.). The update spreads the entire apiKey object, where userId is deserialized as string in JS. MongoDB adapter applies $set: { userId: "..." } → type changes to string. Subsequent list queries (or internal filtering) ignore/fail on keys with string userId. ## Expected Behavior Verification should not modify the type of userId (should remain ObjectId). All keys should remain visible in apiKey.list() regardless of usage. No silent type coercion on update operations. ### What version of Better Auth are you using? 1.4.18 ### System info ```bash better-auth: "^1.4.18" Database: MongoDB 7.0.20 Community Adapter: MongoDB adapter Node.js: v22.18.0 Relevant code snippet (verification): ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript import { betterAuth } from 'better-auth' import { mongodbAdapter } from 'better-auth/adapters/mongodb' import { admin as adminPlugin, apiKey as apiKeyPlugin } from 'better-auth/plugins' import { createAuthMiddleware, APIError } from 'better-auth/api' import { ac, admin, user, pending } from './permissions.js' import config from '../config.js' export let auth export const initAuth = (db, client) => { auth = betterAuth({ appName: 'LeadGuard', baseURL: config.BETTER_AUTH_URL, trustedOrigins: config.CORS.origin, secret: config.BETTER_AUTH_SECRET, database: mongodbAdapter(db, { client }), plugins: [ adminPlugin({ adminRoles: ['admin'], defaultRole: 'pending', ac, roles: { admin, user, pending } }), apiKeyPlugin({ enableMetadata: true, permissions: { defaultPermissions: {} }, defaultPrefix: 'lead_', keyExpiration: { defaultExpiresIn: null } }) ], user: { additionalFields: { role: { type: 'string', default: 'pending', required: true, input: false } } }, socialProviders: { google: { clientId: config.GOOGLE_CLIENT_ID, clientSecret: config.GOOGLE_CLIENT_SECRET } }, emailAndPassword: { enabled: true }, account: { accountLinking: { enabled: true } }, session: { cookieCache: { enabled: true, maxAge: 5 * 60 // Cache duration in seconds }, expiresIn: 60 * 60 * 24 * 7, // 7 days freshAge: 60 * 60 * 24 * 30 // 30 days }, advanced: { ipAddress: { ipAddressHeaders: ['x-forwarded-for'] }, useSecureCookies: config.NODE_ENV === 'production', defaultCookieAttributes: { httpOnly: true, secure: config.NODE_ENV === 'production', sameSite: 'Lax' }, cookiePrefix: 'auth', crossDomain: { enabled: true } }, experimental: { joins: true }, telemetry: { enabled: false } }) return auth } ``` ### Additional context _No response_
GiteaMirror added the lockedbug labels 2026-04-15 18:50:17 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Feb 10, 2026):

The issue you're encountering where the userId changes from ObjectId to string in MongoDB after the first verification of an API key is recognized as a bug. The cause is that during the verifyApiKey operation, the MongoDB adapter inadvertently converts the userId to a string when it is written back, due to how the update is performed. This issue can cause subsequent listing queries to fail because they expect userId to be an ObjectId [1].

To resolve this, there is an ongoing fix that aims to explicitly exclude userId from being updated during the verification process. This means that the update operation will no longer overwrite the existing userId type in the database, thus maintaining its expected ObjectId format after verification [1].

You might want to check back to ensure you're using a version that includes this fix once it is merged. In the meantime, if you have existing records that are affected, it may require separate handling to correct those type inconsistencies.

Have you tried updating to the latest version of Better Auth to see if the issue persists, or do you need further assistance with your current implementation?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3877370616 --> @better-auth-agent[bot] commented on GitHub (Feb 10, 2026): The issue you're encountering where the `userId` changes from `ObjectId` to `string` in MongoDB after the first verification of an API key is recognized as a bug. The cause is that during the `verifyApiKey` operation, the MongoDB adapter inadvertently converts the `userId` to a string when it is written back, due to how the update is performed. This issue can cause subsequent listing queries to fail because they expect `userId` to be an `ObjectId` [[1]](https://github.com/better-auth/better-auth/pull/7326). To resolve this, there is an ongoing fix that aims to explicitly exclude `userId` from being updated during the verification process. This means that the update operation will no longer overwrite the existing `userId` type in the database, thus maintaining its expected `ObjectId` format after verification [[1]](https://github.com/better-auth/better-auth/pull/7326). You might want to check back to ensure you're using a version that includes this fix once it is merged. In the meantime, if you have existing records that are affected, it may require separate handling to correct those type inconsistencies. Have you tried updating to the latest version of Better Auth to see if the issue persists, or do you need further assistance with your current implementation? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#19573