What
store_prompts_in_spend_logs: true on community stores the caller's full request body in LiteLLM_SpendLogs.proxy_server_request. That body includes secret_fields.raw_headers, which carries the caller's Authorization: Bearer sk-... in cleartext.
Measured 2026-09-09, community primary (litellm-community-pg-4, db litellm), 20-minute window:
rows 6,834
with secret_fields.raw_headers 6,825
with an Authorization header 6,825
with "bearer sk-" in cleartext 6,825
So essentially every community request stores the member's own API key, in plaintext, in a table any operator can SELECT from.
Scope
- Community only. Enterprise does not set
store_prompts_in_spend_logs, so its proxy_server_request is the empty object {} in 81,964 of 81,964 rows.
- The exposure window is 30 DAYS, not 3. The CNPG backups (
objectstore litellm-community-r2, retentionPolicy: 30d, s3://cnpg-backups-helmcode/litellm-community) keep the rows in Cloudflare R2, outside the cluster and outside Kubernetes RBAC. Purging the table closes nothing.
- The table's own retention is
maximum_spend_logs_retention_period: "2d", but the pruner is not effective: the oldest row has not moved since 2026-09-05 19:33:22.833 across >24h of observation, and the span reached 3d 13:14 at 2026-09-09 08:47Z and is growing. So even the in-table window is longer than configured. Tracked separately.
- The table currently holds 1,428,722 rows.
Why it matters
- Anyone with read access to the community LiteLLM database has every active member key, not just their hashes.
LiteLLM_VerificationToken stores hashes; this column undoes that.
- It travels: a row pasted into a ticket, a PR, a chat, or a support thread carries a live credential. Backups carry it to R2.
- It is invisible at the point of use —
SELECT * FROM "LiteLLM_SpendLogs" WHERE ... is the natural query and it returns the key.
How it surfaced
While correcting a false claim about this column (nan-devops#287 → #289), the documentation started deliberately pointing readers at proxy_server_request as an observation surface. A reviewer flagged that the PR was inviting people to a column nobody had checked for secrets. The invitation now carries a warning next to it, in the hook docstring and in MONITORING.md, but that is mitigation of the reading, not of the storage.
Options
- Redact at write time. A
LiteLLM_SpendLogs write hook or a pre_call scrub that empties secret_fields before logging. Preferred: fixes new rows, no operational change.
- Turn off
store_prompts_in_spend_logs. Cheapest and it is what enterprise already does, but community loses the body observability that #289 just established is genuinely useful (it is the only tenant where the routing-key scrub can be evidenced at all).
- Redact in place on a schedule.
UPDATE ... SET proxy_server_request = proxy_server_request #- '{secret_fields}'. Bounded work given 2-day retention; pairs with 1.
- Restrict read access to the community DB. Does not address backups or pasted rows.
Severity, revised after verification. These are 97 of 98 sampled keys confirmed LIVE against LiteLLM_VerificationToken (joined inside SQL, counts only, no key extracted), present in 99.87% of rows (1,579,199 of 1,581,290, single-scan snapshot 2026-09-09 07:46:56Z), and retained 30 days in external object storage. There IS exposure outside the cluster: the backups. It does not self-expire in 3 days.
Not propagated to the DWH, which was the worst case and does not hold: classifier and abuse_detection carry only request_id and counters, no headers/body/api_key columns. The labelled dataset is clean.
Also worth a line here: the pgcrypto extension was created in this database (oid 46458899, the newest of four) to run the sha256 join. It lives in public, so digest()/crypt() are available to any role with USAGE on public — in a database now known to hold live credentials. It is also the tool needed to verify any rotation. Community has no CNPG Database resource declaring extensions (enterprise does, at cnpg-cluster-litellm-enterprise-1/database-helmcode-enterprise.yaml:64-75), so a rebuild or restore would drop it silently. Declare it in IaC or remove it; do not leave it undeclared.
Evidence trail
- nan-devops#289 and its QA/UX review threads
platform/devops/MONITORING.md, nan_client_routing_key_stripped_total entry
- community
values.yaml:336 (store_prompts_in_spend_logs: true)
What
store_prompts_in_spend_logs: trueon community stores the caller's full request body inLiteLLM_SpendLogs.proxy_server_request. That body includessecret_fields.raw_headers, which carries the caller'sAuthorization: Bearer sk-...in cleartext.Measured 2026-09-09, community primary (
litellm-community-pg-4, dblitellm), 20-minute window:So essentially every community request stores the member's own API key, in plaintext, in a table any operator can
SELECTfrom.Scope
store_prompts_in_spend_logs, so itsproxy_server_requestis the empty object{}in 81,964 of 81,964 rows.objectstore litellm-community-r2,retentionPolicy: 30d,s3://cnpg-backups-helmcode/litellm-community) keep the rows in Cloudflare R2, outside the cluster and outside Kubernetes RBAC. Purging the table closes nothing.maximum_spend_logs_retention_period: "2d", but the pruner is not effective: the oldest row has not moved since 2026-09-05 19:33:22.833 across >24h of observation, and the span reached 3d 13:14 at 2026-09-09 08:47Z and is growing. So even the in-table window is longer than configured. Tracked separately.Why it matters
LiteLLM_VerificationTokenstores hashes; this column undoes that.SELECT * FROM "LiteLLM_SpendLogs" WHERE ...is the natural query and it returns the key.How it surfaced
While correcting a false claim about this column (nan-devops#287 → #289), the documentation started deliberately pointing readers at
proxy_server_requestas an observation surface. A reviewer flagged that the PR was inviting people to a column nobody had checked for secrets. The invitation now carries a warning next to it, in the hook docstring and inMONITORING.md, but that is mitigation of the reading, not of the storage.Options
LiteLLM_SpendLogswrite hook or apre_callscrub that emptiessecret_fieldsbefore logging. Preferred: fixes new rows, no operational change.store_prompts_in_spend_logs. Cheapest and it is what enterprise already does, but community loses the body observability that #289 just established is genuinely useful (it is the only tenant where the routing-key scrub can be evidenced at all).UPDATE ... SET proxy_server_request = proxy_server_request #- '{secret_fields}'. Bounded work given 2-day retention; pairs with 1.Severity, revised after verification. These are 97 of 98 sampled keys confirmed LIVE against
LiteLLM_VerificationToken(joined inside SQL, counts only, no key extracted), present in 99.87% of rows (1,579,199 of 1,581,290, single-scan snapshot 2026-09-09 07:46:56Z), and retained 30 days in external object storage. There IS exposure outside the cluster: the backups. It does not self-expire in 3 days.Not propagated to the DWH, which was the worst case and does not hold:
classifierandabuse_detectioncarry onlyrequest_idand counters, no headers/body/api_key columns. The labelled dataset is clean.Also worth a line here: the
pgcryptoextension was created in this database (oid 46458899, the newest of four) to run the sha256 join. It lives inpublic, sodigest()/crypt()are available to any role withUSAGEonpublic— in a database now known to hold live credentials. It is also the tool needed to verify any rotation. Community has no CNPGDatabaseresource declaring extensions (enterprise does, atcnpg-cluster-litellm-enterprise-1/database-helmcode-enterprise.yaml:64-75), so a rebuild or restore would drop it silently. Declare it in IaC or remove it; do not leave it undeclared.Evidence trail
platform/devops/MONITORING.md,nan_client_routing_key_stripped_totalentryvalues.yaml:336(store_prompts_in_spend_logs: true)