OpenRouter API Key Leak — Incident and Remediation (2026-11-03)
Status: Contained. Keys rotated, model allowlist and daily caps applied. Old leaked keys are disabled but not yet deleted — deletion waits on deployment of the new secrets.
Workspace: OpenRouter
Default Workspace(d0cf37df-13fd-5d36-8a74-92ed6fa210c7)
Summary
Three OpenRouter API keys were found being used by third-party LLM reseller gateways instead of HungryHub services. The abuse was visible from 2026-10-27 and peaked on 2026-11-02.
- $666.33 of reseller-driven spend in the 31 days to 2026-11-03, across 94,101 requests.
- $142.04 of that was
openai/gpt-6-astra-20260903(GPT-6 Astra). - Three of seven keys were compromised:
hh-server prod,litellm,pma.
The controlling weakness was not IP access. At the time of the incident there was no model allowlist, no guardrail, no key expiry, and spending limits reset monthly at 20–100× real usage — so $602.54 of spend on 2026-11-02 did not trip any limit.
Impact
| Measure | Value |
|---|---|
| Confirmed reseller spend (31d) | $666.33 |
| Confirmed Astra spend (all history) | $142.04 |
| Requests through reseller gateways | 94,101 |
| Keys compromised | 3 of 7 (hh-server prod, litellm, pma) |
| Lifetime usage on compromised keys | $3,362.15 |
| Reseller share of that lifetime usage | ≥ $666 (verified over a 31-day window) |
Direct monetary loss was bounded at roughly $0.67K. The material risk was unbounded going forward: the key material stayed in the attacker’s possession, so a reinstated key could have been resold immediately.
How the abuse was detected
OpenRouter’s analytics origin dimension (the HTTP Referer reaching OpenRouter) was the
decisive signal. Legitimate HungryHub traffic shows origin in
https://litellm.ai/, https://hungryhub.com/, or empty. The abuse showed:
| Origin | Key | Requests (31d) | Spend (31d) |
|---|---|---|---|
https://www.newapi.ai/ | hh-server prod | 63,833 | $456.98 |
https://www.newapi.ai/ | litellm | 30,268 | $202.61 |
https://git.break.wtf/xenside/oxcode | pma | 127 | $6.74 |
newapi.ai (“New API”) and git.break.wtf/xenside/oxcode are third-party inference gateways that
pool upstream provider keys and resell them to end users. Both attributed the traffic to
country US, while our own traffic resolves to Singapore, Germany and elsewhere.
Note that the user dimension attributes requests to the credential owner, not the caller — it
pointed at our own engineers and was a red herring. The origin dimension was the signal.
Timeline (UTC)
| Date | Key | Requests | Spend | Detail |
|---|---|---|---|---|
| 2026-10-27 | hh-server prod | 15,182 | $24.60 | Astra $24.60 |
| 2026-10-29 | hh-server prod | 632 | $23.48 | gpt-6.1-sol $23.41 |
| 2026-11-01 | pma | 127 | $6.74 | claude-opus-4.6 / sonnet-5.5 via oxcode |
| 2026-11-01 | hh-server prod | 49 | $0.02 | gpt-5.4-pro tail |
| 2026-11-02 | hh-server prod | 47,817 | $399.93 | gpt-6.1-sol $284.47, Astra $115.37 |
| 2026-11-02 | litellm | 30,268 | $202.61 | gpt-6.1-sol |
| 2026-11-03 | hh-server prod | 153 | $8.94 | gpt-5.5-pro $6.44, Astra $1.97 |
| 2026-11-02 13:37 | pma | — | — | key disabled |
| 2026-11-03 03:05 | litellm | — | — | key disabled |
| 2026-11-03 03:42 | Eagle Eye (prod + staging) | — | — | disabled precautionarily, not implicated |
| 2026-11-03 03:43 | hh-server prod | — | — | key disabled — last reseller request at 03:43:19Z |
The Astra exposure window is narrow: total Astra spend across the whole 2026-08-20 → 2026-11-03 history is $142.04 (27,143 requests), all of it inside the four days above.
The Eagle Eye keys were disabled alongside the genuinely compromised keys but were not part of
the abuse. Their traffic profile (Singapore origin, no Referer, model mix limited to a cheap
open-weight model plus a search model) is consistent with Eagle Eye service traffic.
Affected keys
Full hashes (needed for key rotation and guardrail assignment):
| Key | Hash (prefix) | Created | Limit before | Lifetime usage | State |
|---|---|---|---|---|---|
hh-server prod | 7669826c0b3e | 2026-02-24 | $500/monthly | $2,507.62 | disabled, pending deletion |
litellm | 57c19cab5d49 | 2026-08-10 | $300/monthly | $626.97 | disabled, pending deletion |
pma | f04409f10960 | 2026-08-10 | $200/monthly | $227.56 | disabled, pending deletion |
Root cause
- No model allowlist. Any key could call any model, including the expensive ones the reseller targeted.
- No guardrail of any kind.
GET /api/v1/guardrailsreturnedtotal_count: 0; the workspace default guardrail was not configured and there were no per-key assignments. - Limits reset monthly and were oversized.
$500/$300/$200against real usage of ~$5–25/day meant a single bad day could not trip a limit. - No key expiry.
expires_atwas absent on every key, so a leaked key stayed valid forever. - Shared catch-all keys.
hh-server prodandlitellmeach served many model families; one leak exposed everything.
Remediation applied (2026-11-03)
Model allowlist
The workspace default guardrail af250252-64ec-5449-b575-bc6de8d9a4bc was materialized. It applies
to all workspace traffic, including keys not visible in the key list.
| Setting | Value |
|---|---|
allowed_models | 44 canonical slugs (see below) |
ignored_models | openai/gpt-6-astra-20260903, openai/gpt-6-astra-pro-20260903, ~openai/gpt-astra-latest, openai/gpt-6.1-sol-20260929 |
limit_usd | 600, reset_interval: monthly |
include_byok_in_budgets | true |
enable_paid_model_training / enable_free_model_training / enable_free_model_publication | true |
The four ignored models are 100% reseller traffic — excluding them removes the reseller’s entire model menu. The allowed list covers only models HungryHub services actually call.
anthropic/claude-4.5-haiku-20251001 anthropic/claude-4.5-opus-20251124
anthropic/claude-4.5-sonnet-20250929 anthropic/claude-4.6-opus-20260205
anthropic/claude-4.6-sonnet-20260217 anthropic/claude-4.7-opus-20260416
anthropic/claude-4.8-opus-20260528 anthropic/claude-5-fable-20260609
anthropic/claude-fable-5.1-20260831 anthropic/claude-haiku-5.5-20261007
anthropic/claude-opus-5-20260723 anthropic/claude-opus-5.5-20260921
anthropic/claude-sonnet-5-20260630 anthropic/claude-sonnet-5.5-20260928
cohere/north-mini-code-20260617:free deepseek/deepseek-v4-flash-20260731
deepseek/deepseek-v4.1-flash-20261017 dots-studio/dots-3-note-preview-20260813:free
google/gemini-2.5-flash google/gemini-2.5-flash-image
google/gemini-3.1-flash-lite-20260507 google/gemini-3.8-flash-20260902
google/gemma-4-26b-a4b-it-20260403 google/gemma-4-26b-a4b-it-20260403:free
nex-agi/nex-n2.5-mini-20260908 nex-agi/nex-n2.5-pro-20260907
openai/gpt-4o openai/gpt-5.4-20260305
openai/gpt-5.4-mini-20260317 openai/gpt-5.4-pro-20260305
openai/gpt-5.5-20260423 openai/gpt-5.5-pro-20260423
openai/gpt-5.6-luna-20260709 openai/gpt-5.6-sol-20260709
openai/gpt-5.6-terra-20260709 openai/gpt-6-luna-20260922
openai/gpt-6-sol-20260922 openai/gpt-6.1-terra-20261026
openai/gpt-6.1-terra-pro-20261026 perplexity/sonar-pro-search
qwen/qwen3.5-122b-a10b-20260224 qwen/qwen3.5-397b-a17b-20260216
qwen/qwen3.8-27b-20260814 recursive/flint-v1.0-20261021:free
Key rotation and daily caps
All seven active keys now carry a daily limit sized against real usage (roughly 20–60×
headroom over observed legitimate spend):
| Key | Hash (prefix) | Daily cap | Expires | Consumer |
|---|---|---|---|---|
hh-server prod | b04b23bf3fd0 | $50 | 2027-02-01 | hh-server Rails app — env OPENROUTER_KEY |
hh-server dev | acdda149f7f9 | $10 | 2027-09-16 | hh-server dev / preview |
pma | 9dec31232fa3 | $30 | 2027-02-01 | pma.hungryhub.com service |
hh-menu | 8616a863bc53 | $30 | 2027-11-03 | hh-menu Rails app — env OPENROUTER_API_KEY |
PROD - Eagle Eye | fdf204786d48 | $60 | — | eagle-eye Go service |
STAGING - Eagle Eye | f3f34c6529c8 | $5 | — | eagle-eye staging |
LITELLM_PROBE_KEY | 2b62cccfdcbe | $5 | 2027-10-11 | LiteLLM probe key |
hh-server prod and pma were rotated (new key created, old one disabled). The Eagle Eye
keys were not compromised, so they were re-enabled with a daily cap rather than rotated.
expires_at is only settable at key-creation time — PATCH /api/v1/keys/{hash} has no such
field. Adding an expiry therefore requires rotating, which is why the two new keys were created
rather than patched.
Where the secrets live
New key secrets were written to a chmod 600 file outside the repository — never in chat, never
committed. Move each into the secret store, then delete the file.
- hh-server —
OPENROUTER_KEY, read athh-server/app/services/openrouter_client.rb. In Terraform this isvar.openrouter_key→hungryhub-terraform/hungryhub-apps/configmap.tf. Prod values are set out-of-band, not inprod.tfvars— update the secret, don’t edit tfvars. - hh-menu —
OPENROUTER_API_KEY, read athh-menu/app/services/openrouter_client.rb. In Terraform this isvar.openrouter_api_key_menu→configmap.tf.
Guardrail matching semantics (read before editing the allowlist)
GET /api/v1/models exposes two identifiers per model:
id— the short/floating slug. Services send these (anthropic/claude-sonnet-4.5,deepseek/deepseek-v4-flash-0731). Some services send a legacy alias instead (anthropic/claude-4.5-sonnet).canonical_slug— the dated permaslug. This is what the guardrail matches on.
The API stores and matches the resolved canonical slug plus the endpoint variant, not the string the client sent. This is undocumented and caused two rounds of false blocking of our own traffic:
| Client sends | Catalog id | canonical_slug to allowlist |
|---|---|---|
anthropic/claude-4.5-sonnet (legacy alias) | anthropic/claude-sonnet-4.5 | anthropic/claude-4.5-sonnet-20250929 |
anthropic/claude-4.5-sonnet:beta | same | same entry — :beta is a provider flag, not a variant |
perplexity/sonar-pro-search:online | perplexity/sonar-pro-search | perplexity/sonar-pro-search (this model has no dated permaslug) |
google/gemma-4-26b-a4b-it:free | google/gemma-4-26b-a4b-it:free | google/gemma-4-26b-a4b-it-20260403 and google/gemma-4-26b-a4b-it-20260403:free |
cohere/north-mini-code:free | cohere/north-mini-code:free | cohere/north-mini-code-20260617:free |
deepseek/deepseek-v4-flash-0731 | deepseek/deepseek-v4-flash-0731 | deepseek/deepseek-v4-flash-20260731 |
Four rules that follow:
- Provider-flag suffixes are ignored.
:beta,:online,:batchare flags on the same endpoint set, so one plain canonical entry covers them. :freeis a distinct endpoint set. A plain canonical slug does not match a:freerequest. Free-only models must be allowlisted with their:freeid; a model with both paid and free endpoints needs both entries.- Aliases float to dated permaslugs.
deepseek/deepseek-v4-flashanddeepseek/deepseek-v4-flash-0731resolved to two different permaslugs (…-20260423and…-20260731) — they are different models. The catalog today only carries the-0731alias. - The API rewrites a submitted canonical slug. Sending
anthropic/claude-haiku-5.5-20261007storedanthropic/claude-haiku-5.5:batch— and there is no:batchentry in the catalog. It appends a variant suffix on its own.
Always read the stored list back after a patch rather than trusting what was sent.
Verification
Enforcement was verified functionally, not by reading the config back:
- 17/17 first-party model strings return HTTP 200 through the guardrail.
- 7/7 blocked models return HTTP 404 with
Model blocked by guardrail(4 reseller targets + 3 deliberately dropped models). google/gemma-4-26b-a4b-it:freeandrecursive/flint-v1.0:freereturn provider 503 — that is upstream free-tier capacity, not a guardrail rejection. The guardrail passed them.hh-menu’s default modelanthropic/claude-4.5-sonnetwas probed live and returns HTTP 200 (OpenRouter resolves it toanthropic/claude-sonnet-4.5).
One gotcha: GET /api/v1/models/user still lists Astra and gpt-6.1-sol even with the guardrail
applied. That endpoint reflects the account’s model list, not the guardrail. The 404 responses are
the proof of enforcement.
Known consequences of the allowlist
z-ai/glm-5.3is intentionally excluded →pma’s glm-5.3 probe now 403s.mistralai/mistral-large-4-0andminimax/minimax-m3are intentionally excluded → the LiteLLM probe key’s calls to them 403. The deployed LiteLLM config still routesopenrouter/minimax-m3andopenrouter/moonshotai/kimi-k2.7-codethroughOPENROUTER_API_KEY;moonshotai/kimi-k2.7-codeis not in the allowlist and will 403 for those routes. LiteLLM should call minimax.io directly — see LiteLLM MiniMax Key Management.- The three
:freemodels plusenable_free_model_publication: truemean free endpoints may publish prompts to providers. - Guardrail budgets are per key / per member, not a workspace total. The $600/month is a per-member ceiling; the workspace ceiling is the sum of the daily caps.
google/gemini-2.0-flash-001(hh-server admin_setting default) is retired on OpenRouter — “no provider serves this model anymore”. This breakage predates the guardrail and is unrelated to it.
Remaining actions
| # | Action | Priority | Owner |
|---|---|---|---|
| 1 | Deploy the new hh-server prod secret into the prod secret store behind OPENROUTER_KEY | HIGH | — |
| 2 | Deploy the new pma secret into the pma service’s OpenRouter credential (service repo not in this workspace) | HIGH | — |
| 3 | Confirm the LiteLLM proxy’s OPENROUTER_API_KEY no longer holds the leaked litellm secret | HIGH | — |
| 4 | After the new keys show traffic in analytics, delete litellm (57c19cab…), old hh-server prod (7669826c…), old pma (f04409f1…) | HIGH | — |
| 5 | One key per service — assign a per-key guardrail so each service gets its own model allowlist | MEDIUM | — |
| 6 | Monitor for reseller traffic: query dimensions: [api_key_id, origin] daily and alert on origin ∉ {https://litellm.ai/, https://hungryhub.com/, empty} | MEDIUM | — |
| 7 | Consider allowed_data_regions guardrails (Business/Enterprise plan). Note this restricts the OpenRouter hostname requests must arrive through, not the client IP, so it would not have stopped newapi.ai. Defense in depth, not the fix | LOW | — |
| 8 | Find the leak source — repository secrets, CI logs, a container image, a leaked .env, or a compromised workstation. OpenRouter-side data bounds the damage but cannot say where the key material came from | HIGH | — |
Open questions
- Whether
pma’s oxcode traffic was the same actor as thenewapi.aitraffic or an unrelated colleague using that relay. - Whether other OpenRouter accounts or providers (LiteLLM, Anthropic, OpenAI direct, AWS) hold credentials from the same leak.
- Whether
PROD - Eagle Eye’s high-volumeqwen3.5-397bpattern (67,201 requests, 382M tokens) is entirely legitimate Eagle Eye load.
Rotating an OpenRouter key (runbook)
- Read the current inventory.
GET /api/v1/keys?include_disabled=true&include_expired=true. - Create the replacement.
POST /api/v1/keysacceptsname,limit,limit_resetandexpires_at. Setexpires_athere — it cannot be added later. - Capture the secret once into a
chmod 600file outside the repo. Never print it to chat, never commit it. - Deploy the secret to the consuming service and confirm it appears in analytics traffic.
- Disable the old key (
PATCH /api/v1/keys/{hash}withdisabled: true) while the new key is being verified. - Delete the old key (
DELETE /api/v1/keys/{hash}) once the new key shows traffic. - Re-check the guardrail — if the service calls a model that is not in
allowed_models, the call now fails with HTTP 404Model blocked by guardrail. Add the model’s canonical slug (see the matching-semantics section above) before deploying.
Related
- LiteLLM + Langfuse + Grafana — LLM Gateway Stack
- LiteLLM MiniMax Key Management
- Eagle Eye — Architecture and Operations
- Credential Access Monitoring, Logs & APM
- Incident — hungryhub + utility Namespace Deletion 2026-06-08 — same class of problem: secrets living only in Kubernetes
- hungryhub-team/hungryhub-terraform —
var.openrouter_key,var.openrouter_api_key_menu