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).
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_hashesare 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:
passwordField.text— "Hashed password for email/password provider"previous_password_hashesField.textarea,hidden: true,readonly: true— ADR-0069 D1 reuse-prevention ringBoth on
sys_account, which declaresapiEnabled: true, apiMethods: ['get','list']. Reachable by an admin for every user's row, and by a member for their own (thesys_account_selfRLS policy grantsselectonuser_id == current_user.id).Measurement provenance, stated precisely
The key list above was captured on a build that differs from
origin/mainonly by the threeinternal: trueflags #7987 adds toaccess_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
maskSecretFieldsmaskssecret-typed andpassword-typed fields, but it exempts objects withmanagedBy: 'better-auth'(packages/objectql/src/secret-fields.ts), andsys_accountis one.sys_account.passwordis also atextcolumn, not apassword-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, andprevious_password_hashesmultiplies it by the history ring while its own declaration says "never exposed in UI".Shape of a fix, if wanted (⛔ not decided here)
internal: trueis the presumptive route — the #7728 flag, applied tosys_session.tokenby #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 readaccount.passwordback off a result row? Note that at least one path plainly does —internalAdapter.findCredentialAccount(userId)returns the row whosepasswordthe 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 inpackages/plugins/plugin-auth/src/internal-field-readback.ts, which recovers them throughEngine.resolveInternalField(#8118).resolveInternalFielddeliberately refusespassword. Its guard names ADR-0100 explicitly: dereferencing a masked-by-designpasswordwould be a mask bypass. That refusal is asserted in PR fix(security): sys_account OAuth access/refresh/id tokens stop serializing on the data API (#7987) #8675's dogfood fixture. A fix here has to decide whether flagging the columninternalmakes it legitimately dereferenceable, or whether the guard should keep refusing — that is a real decision, not an oversight to patch around.requiredcolumn;passwordisrequired: falseand is genuinely empty on OAuth-only accounts, so it needs the same per-column treatment (absenceProvesStrip: false) or it will break sign-in against fake engines.Not claimed
Field.password()field exists anywhere in the repo (0 occurrences, reverse-checked on finding: survey — which other config objects still keep credentials in a JSON blob column, now that resolveSecretField() makes the Field.secret() channel readable #7902), so the carve-out has zero hits here.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).