Skip to content

LiteLLM community: member API keys are stored in cleartext in SpendLogs (secret_fields.raw_headers) #43

Description

@sre-helmcode

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

  1. 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.
  2. 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.
  3. 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

  1. 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.
  2. 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).
  3. 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.
  4. 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)

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions