Skip to content

[security] sys_account.password and previous_password_hashes serialize on the generic data API — to admins cross-user, and to the user themselves #8676

Description

@qq9340100

Filed unassigned from the #7987 dev seat as an out-of-scope finding. #7987 explicitly carves these two columns out ("sys_account.password / previous_password_hashes are NOT part of this finding"), so this is the standalone card that carve-out implies, not a widening of it. For triage to grade.

The finding

While measuring #7987's three OAuth token columns on a real booted stack, the response key list showed two more credential-class columns coming back on the same read:

[ablation] admin get-by-id keys: id,created_at,updated_at,provider_id,issuer,account_id,
           user_id,access_token,refresh_token,id_token,access_token_expires_at,
           refresh_token_expires_at,scope,password,previous_password_hashes
Column Declaration
password Field.text — "Hashed password for email/password provider"
previous_password_hashes Field.textarea, hidden: true, readonly: true — ADR-0069 D1 reuse-prevention ring

Both on sys_account, which declares apiEnabled: true, apiMethods: ['get','list']. Reachable by an admin for every user's row, and by a member for their own (the sys_account_self RLS policy grants select on user_id == current_user.id).

Measurement provenance, stated precisely

The key list above was captured on a build that differs from origin/main only by the three internal: true flags #7987 adds to access_token / refresh_token / id_token. Those flags do not touch these two columns, so the reading transfers — but it was not re-measured against a clean tree, and triage should treat the exact personas as inherited from #7987's measurement rather than independently confirmed here.

Why the collectors miss them — the same structural reason as #7987

maskSecretFields masks secret-typed and password-typed fields, but it exempts objects with managedBy: 'better-auth' (packages/objectql/src/secret-fields.ts), and sys_account is one. sys_account.password is also a text column, not a password-typed one, so the type-keyed collection would miss it even without the exemption. Two independent barriers, exactly as #7902's survey described for the token columns.

Why this is lower severity than #7987, and why it is not zero

These are one-way hashes — ADR-0100's third channel. A hash is not a reversible outbound credential, which is why #7987 correctly refused to bundle them. But this is the same class #7728 already settled for sys_api_key.key, which was also a stored SHA-256 hash and was still ruled unfit to serialize through the API face. Serving a password hash to any caller hands out an offline-cracking target, and previous_password_hashes multiplies it by the history ring while its own declaration says "never exposed in UI".

Shape of a fix, if wanted (⛔ not decided here)

internal: true is the presumptive route — the #7728 flag, applied to sys_session.token by #7996 and to the three OAuth columns by #7987 / PR #8675. The load-bearing question a card must answer first is the same one #7987 had to measure: does better-auth read account.password back off a result row? Note that at least one path plainly does — internalAdapter.findCredentialAccount(userId) returns the row whose password the sign-in verifier compares against — so on current evidence this looks more exposed to the readback problem than #7987 was, not less. If so the mechanism already exists and is cheap: add the columns to the per-object table in packages/plugins/plugin-auth/src/internal-field-readback.ts, which recovers them through Engine.resolveInternalField (#8118).

⚠️ Two traps a card should carry:

Not claimed

Source

Measured 2026-08-14 by the dev seat on #7987 (PR #8675). Split out rather than absorbed, per that card's own scope guard. Related: #7902 (the credential-persistence survey), #7728 (sys_api_key.key), #7823 / PR #7996 (sys_session.token).

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions