Skip to content

auth: better-auth plugin-table credential fields (e.g. oidc-provider clientSecret, two-factor secret) aren't covered by the ADR-0036 read-deny #1014

Description

@borisno2

Surfaced during review of #1013 (issue #981 / ADR-0036); filed separately rather than widening that PR's scope.

Summary

#981 / ADR-0036 shipped a field-level read deny on the six credential-bearing fields declared by the four base better-auth models: Session.token, Verification.value, Account.password/accessToken/refreshToken/idToken. The deny is implemented in deriveAuthLists (packages/auth/src/config/derive-auth-lists.ts) via a CREDENTIAL_FIELDS map keyed by BaseModelKey ('user' | 'session' | 'account' | 'verification' | 'rateLimit').

The same deriveAuthLists function also derives better-auth plugin tables (e.g. the mcp plugin's oidc-provider schema — oauthApplication, oauthAccessToken, oauthConsent — and any other better-auth plugin's own schema, per ADR-0034) through the identical scalar-field derivation path. Because CREDENTIAL_FIELDS is typed and indexed only by BaseModelKey, withCredentialAccess never matches a plugin-table model key — plugin-table credential fields are derived as plain, fully-readable text() fields, exactly the gap #981 closed for the base models.

Confirmed against better-auth's own plugin schemas (node_modules/better-auth/dist/plugins/*):

  • oidc-provider (used by the mcp plugin): oauthApplication.clientSecret, oauthAccessToken.accessToken/refreshToken
  • twoFactor(): twoFactor.secret, twoFactor.backupCodes

Why this matters

Plugin tables ship closed by default (ADR-0013/ADR-0034, no access passthrough), but packages/auth/CLAUDE.md documents the supported way for an app to open one: declare the list itself under the same derived key, so the plugin's field-only extend path merges in the app's own access —

lists: {
  OauthAccessToken: list({ access: { operation: { query: () => true } } }),
}

A very plausible feature (an admin screen listing connected OAuth apps, or a "manage your 2FA" page) built this way returns the live accessToken/refreshToken/secret in plaintext through context.db and the admin UI table — the same class of exposure ADR-0036's threat model (session hijack / account takeover / OAuth token replay) describes for the base models.

Why this is a separate issue from #981

#981's issue body and ADR-0036 both explicitly enumerate exactly six fields across the four base models; neither mentions plugin tables. Closing this gap needs its own design pass, since better-auth doesn't expose anything like the returned: false flag issue #981 could lean on for the base account columns (see issue #981's first comment) — plugin schemas carry no such signal, so the fix likely needs either a per-plugin credential-field allowlist (hardcoded, and who maintains it as plugins update) or a different signal entirely.

Suggested directions (not a recommendation — needs a design decision)

  1. Extend CREDENTIAL_FIELDS (or an equivalent registry) to cover known plugin tables the stack already has first-class support for (the mcp plugin's oidc-provider tables today), keyed the same way — by better-auth model/field key, surviving modelName/column remaps.
  2. A convention-based heuristic — e.g. deny read on any plugin-table scalar field better-auth itself never returns from its own client-facing endpoints — if such a signal can be found or approximated.
  3. Give plugin-table authors (or the authPlugin config) a declarative way to mark a field as a credential at the point a plugin is registered, so the stack doesn't need to hardcode knowledge of every better-auth plugin's schema.

Verification performed

  • Read derive-auth-lists.ts's CREDENTIAL_FIELDS/withCredentialAccess/BaseModelKey and confirmed the type/lookup only ever matches the five base model keys.
  • Read the plugin-table derivation loop in the same file and confirmed it derives plugin-table scalar fields through the exact same buildScalarField/withCredentialAccess call as base models — so the fix, if pursued, plugs into an already-shared path rather than needing a new one.
  • Read better-auth's own oidc-provider and two-factor plugin schema source to confirm the named fields exist and are plain string columns with no returned: false (or equivalent) flag.

Related

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingready-for-agentFully specified, ready for an AFK agent

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions