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)
- 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.
- 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.
- 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
Summary
#981 / ADR-0036 shipped a field-level
readdeny 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 inderiveAuthLists(packages/auth/src/config/derive-auth-lists.ts) via aCREDENTIAL_FIELDSmap keyed byBaseModelKey('user' | 'session' | 'account' | 'verification' | 'rateLimit').The same
deriveAuthListsfunction also derives better-auth plugin tables (e.g. themcpplugin's oidc-provider schema —oauthApplication,oauthAccessToken,oauthConsent— and any other better-auth plugin's ownschema, per ADR-0034) through the identical scalar-field derivation path. BecauseCREDENTIAL_FIELDSis typed and indexed only byBaseModelKey,withCredentialAccessnever matches a plugin-table model key — plugin-table credential fields are derived as plain, fully-readabletext()fields, exactly the gap #981 closed for the base models.Confirmed against better-auth's own plugin schemas (
node_modules/better-auth/dist/plugins/*):mcpplugin):oauthApplication.clientSecret,oauthAccessToken.accessToken/refreshTokentwoFactor():twoFactor.secret,twoFactor.backupCodesWhy this matters
Plugin tables ship closed by default (ADR-0013/ADR-0034, no
accesspassthrough), butpackages/auth/CLAUDE.mddocuments 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 —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/secretin plaintext throughcontext.dband 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: falseflag issue #981 could lean on for the baseaccountcolumns (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)
CREDENTIAL_FIELDS(or an equivalent registry) to cover known plugin tables the stack already has first-class support for (themcpplugin's oidc-provider tables today), keyed the same way — by better-auth model/field key, survivingmodelName/column remaps.readon 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.authPluginconfig) 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
derive-auth-lists.ts'sCREDENTIAL_FIELDS/withCredentialAccess/BaseModelKeyand confirmed the type/lookup only ever matches the five base model keys.buildScalarField/withCredentialAccesscall as base models — so the fix, if pursued, plugs into an already-shared path rather than needing a new one.oidc-providerandtwo-factorplugin schema source to confirm the named fields exist and are plain string columns with noreturned: false(or equivalent) flag.Related