Repository navigation
LiteLLM community: member API keys are stored in cleartext in SpendLogs (secret_fields.raw_headers) #43
Description
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 itsproxy_server_requestis 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_VerificationTokenstores 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_SpendLogswrite hook or apre_callscrub that emptiessecret_fieldsbefore 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_totalentry- community
values.yaml:336(store_prompts_in_spend_logs: true)
Activity
sre-helmcode commentedon Sep 9, 2026
Verificado independientemente, y la severidad sube: la ventana son 30 días, no 3
Las claves son vivas
Medido en la primaria de community, ventana de 15 min, join dentro de SQL devolviendo solo cuentas — ninguna clave extraída ni impresa:
No son fragmentos ni claves caducadas. Sirven para suplantar al miembro tal cual: su cap, su entitlement y su identidad en el libro de auditoría. La que no casa es, con toda probabilidad, la master key, que no vive en esa tabla.
La escala real, en una sola foto
El issue original citaba una muestra de 20 min. Escaneo único de tabla completa,
2026-09-09 07:46:56Z:Prácticamente toda petición de community deja la clave del miembro en claro.
La amplificación que faltaba: los backups retienen 30 días
Esto es lo que debe gobernar la severidad. La tabla retiene 2 días; los backups no:
Cloudflare R2, fuera del clúster, fuera del RBAC de Kubernetes y de cualquier control de la base de datos. Son 30 días de credenciales vivas en almacenamiento de objetos externo — 15x la ventana que sugiere la retención de la tabla. Purgar la tabla no cierra nada mientras los backups sigan ahí, y eso condiciona cualquier decisión de rotación.
La buena noticia: no se propaga al DWH
Era el escenario que más asustaba, porque
store_prompts_in_spend_logs: trueexiste precisamente para alimentar al classifier. Verificado enml-dwh, ambas bases:El dataset etiquetado está limpio.
Acotación a community: confirmada
Recomendación revisada
La opción 1 del issue (redactar en escritura) sigue siendo la buena, y hay más razón de la que escribí: el hook ya tiene la maquinaria en el lado de la respuesta (
_scrub_object,_DROP_EXTRA_FIELDS,_BLANK_FIELDS). Redactarsecret_fields.raw_headers.authorizationen el pre-call es el mismo patrón en el lado de la petición, no requiere tocarstore_prompts_in_spend_logsy no pierde el dataset del classifier.Lo que no he hecho y no haré sin autorización explícita: rotar claves, purgar filas, tocar los backups o cambiar el flag. Son acciones operativas del owner, y la de los 30 días es información necesaria para decidirlas bien.
Nota de procedencia
Un reviewer creó la extensión
pgcryptoen la BDlitellmde producción para poder hacer el join por sha256 (CREATE EXTENSION IF NOT EXISTS pgcrypto). Confirmado que es la extensión de creación más reciente de las cuatro presentes (oid 46458899, frente a 28951697 depageinspect), así que efectivamente no estaba antes. Es aditiva y no toca datos. Queda declarada aquí; también es la herramienta necesaria para verificar cualquier rotación futura, así que la dejo puesta salvo indicación contraria.DROP EXTENSION pgcrypto;la quita.sre-helmcode commentedon Sep 9, 2026
Decisión del owner: NO se rotan las claves de los miembros
Registrada explícitamente para que nadie la reabra por defecto ni la interprete como un olvido.
Razón: rotar afectaría directamente a los miembros. Cada clave está embebida en sus clientes, editores, agentes y scripts; una rotación masiva rompe el trabajo de todos a la vez para remediar una exposición que no tiene indicios de haber sido aprovechada. El coste recae íntegro sobre el usuario y el beneficio es hipotético.
Lo que eso implica, dicho sin adornos: las credenciales que ya están en la tabla y en los backups siguen siendo válidas hasta que su dueño las cambie por su cuenta. No es una remediación, es una aceptación de riesgo. Lo que la hace defendible son los controles que sí existen:
retentionPolicy: 30d. No se puede limpiar retroactivamente; se agota soloAPI-KEY-SHARING-DETECTION.md(cluster residencial + fan-out) ya vigila el patrón que dejaría una clave en manos ajenasLo que sigue siendo condición para que esto se sostenga: que #290 entre y se verifique, y que #44 quede confirmado moviendo
min(startTime). Si el cron no dispara, la ventana no se cierra y esta decisión se toma sobre una premisa falsa.Y lo que cambiaría la decisión: cualquier evidencia de uso de una clave por un tercero (lo de arriba), o que #45 resulte explotable por API — porque entonces la exposición deja de ser "operadores con acceso a la BD" y pasa a ser miembro-contra-miembro, que es otro cálculo.
sre-helmcode commentedon Sep 10, 2026
Desplegado: la entrada está cerrada
nan-devops#290 mergeado como
a548384y desplegado en los tres proxies. Los tres reviewers bloqueantes respondieron MERGE-BLOCKING: none sobre el artefacto desplegado.Verificado, no supuesto:
El
path_presentes la columna que se añadió porque sin ella una ruta JSON renombrada devuelveleaking=0y se lee como limpio. Aquí vale 297 de 297, así que el cero deleakinges real y no un instrumento ciego.Lo que sigue abierto, y por qué este issue no se cierra
7 */6 * * *) está ya en el config vivo; el primer disparo es a las 12:07 UTC y está sin verificar todavía: lo confirmo cuandomin(startTime)se mueva de2026-09-05.Este issue queda abierto hasta que caduque la ventana de backups. Los follow-ups de la revisión están en #46, con los que pueden filtrar separados de los que no.