You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Filed by the domain:services PM seat from the #7902 credential-persistence survey (report: comment 5264546868 on #7902). Unassigned, no pm:queue, no domain:* — the landing lane is genuinely uncertain (see routing note). For triage to grade and route.
The finding
sys_account holds each user's live third-party OAuth credentials for linked providers (Google, GitHub, …) as plain Field.textarea() columns, on an object that declares apiEnabled: true, apiMethods: ['get','list'].
Column
Evidence
access_token
sys-account.object.ts:181-194
refresh_token
”
id_token
”
(object's API surface)
sys-account.object.ts:238-245
These are not hashes and not platform-internal credentials: they are bearer credentials for someone else's service, and a refresh token in particular is long-lived.
Why the existing collectors do not catch it
The survey's structural result — this is the interesting half:
maskSecretFields masks secret-typed fields andpassword-typed fields, but it exempts objects with managedBy: 'better-auth' (packages/objectql/src/secret-fields.ts:104-119). sys_account is one of those. So the one collector that might have masked these columns is exempt from them by construction — and the columns are plain textarea anyway, which no type-keyed collector would reach even without the exemption.
Its sibling sys_api_key.key took the other route: internal: true, which makes omitInternalFieldsomit the field rather than mask it (engine.ts:4763-4766, landed by #7728). sys_account's token columns carry no such flag.
Shape of a fix, if wanted (⛔ not decided here)
Field.secret() is probably the wrong tool. better-auth owns the writes to this object; routing them through the engine's encrypt-on-write path would sit between better-auth and its own adapter, which is where this gets hard rather than where it gets safe.
internal: true is the plausible route — it is exactly what #7728 did for sys_api_key.key, it needs no cooperation from better-auth, and it removes the columns from API responses entirely rather than masking them. The load-bearing question a card must answer first: does any login/refresh path read these values off a result row (as opposed to reading them from the store directly)? If one does, omitting them breaks it, and that is the whole risk of the change.
Explicitly NOT claimed
No leak is demonstrated. This is reachable-cleartext plus an exempt collector; whether any persona in a shipped deployment can actually GET these rows depends on permissions this survey did not evaluate.
No ADR-0100 conflict. No Field.password() field exists anywhere in the repo (0 occurrences, reverse-checked), so the ADR-0100 carve-out has zero hits here.
Routing note — why no domain:*
The columns are declared in packages/platform-objects, which is domain:metadata by the lane table; the masking/omission machinery is in packages/objectql (domain:engine-core); and the consumer that would break is better-auth's adapter under plugin-auth (domain:identity). The survey ran in the services lane, but this card does not obviously land there. Triage's call — flagged rather than guessed, per the anchoring rule (label by the package the fix lands in, never by where it was found).
Evidence
Measured 2026-08-12 by the dev on #7902 (read-only survey, pr: null). Full 26-surface verdict table, including the reverse-checks that validate the clean verdicts, in comment 5264546868 on #7902.
Filed by the
domain:servicesPM seat from the #7902 credential-persistence survey (report: comment 5264546868 on #7902). Unassigned, nopm:queue, nodomain:*— the landing lane is genuinely uncertain (see routing note). For triage to grade and route.The finding
sys_accountholds each user's live third-party OAuth credentials for linked providers (Google, GitHub, …) as plainField.textarea()columns, on an object that declaresapiEnabled: true, apiMethods: ['get','list'].access_tokensys-account.object.ts:181-194refresh_tokenid_tokensys-account.object.ts:238-245These are not hashes and not platform-internal credentials: they are bearer credentials for someone else's service, and a refresh token in particular is long-lived.
Why the existing collectors do not catch it
The survey's structural result — this is the interesting half:
maskSecretFieldsmaskssecret-typed fields andpassword-typed fields, but it exempts objects withmanagedBy: 'better-auth'(packages/objectql/src/secret-fields.ts:104-119).sys_accountis one of those. So the one collector that might have masked these columns is exempt from them by construction — and the columns are plaintextareaanyway, which no type-keyed collector would reach even without the exemption.Its sibling
sys_api_key.keytook the other route:internal: true, which makesomitInternalFieldsomit the field rather than mask it (engine.ts:4763-4766, landed by #7728).sys_account's token columns carry no such flag.Shape of a fix, if wanted (⛔ not decided here)
Field.secret()is probably the wrong tool. better-auth owns the writes to this object; routing them through the engine's encrypt-on-write path would sit between better-auth and its own adapter, which is where this gets hard rather than where it gets safe.internal: trueis the plausible route — it is exactly what #7728 did forsys_api_key.key, it needs no cooperation from better-auth, and it removes the columns from API responses entirely rather than masking them. The load-bearing question a card must answer first: does any login/refresh path read these values off a result row (as opposed to reading them from the store directly)? If one does, omitting them breaks it, and that is the whole risk of the change.Explicitly NOT claimed
sys_account.password/previous_password_hashesare NOT part of this finding. They hold better-auth one-way hashes — ADR-0100's third channel. A hash is not a reversible outbound credential. They are flagged separately in the survey as the same classapi-key-ui-lifecycle (secondary): thekeycolumn (SHA-256 hash) serializes over the data API, contradicting its own "never exposed to clients" description #7728 addressed, worth a look, but they are not this card.Field.password()field exists anywhere in the repo (0 occurrences, reverse-checked), so the ADR-0100 carve-out has zero hits here.Routing note — why no
domain:*The columns are declared in
packages/platform-objects, which isdomain:metadataby the lane table; the masking/omission machinery is inpackages/objectql(domain:engine-core); and the consumer that would break is better-auth's adapter underplugin-auth(domain:identity). The survey ran in the services lane, but this card does not obviously land there. Triage's call — flagged rather than guessed, per the anchoring rule (label by the package the fix lands in, never by where it was found).Evidence
Measured 2026-08-12 by the dev on #7902 (read-only survey,
pr: null). Full 26-surface verdict table, including the reverse-checks that validate the clean verdicts, in comment 5264546868 on #7902.Source
Split out of #7902.