Hardening round for a public AI app that spends your own API key

Before launch, audit and patch a public AI app that spends your API key: fail-closed quotas, keyed IP hashes, a bot gate, CSP, provider terms, spend-cap arithmetic and bring-your-own-key.

Skill advanced Tested Opus 5.5 · Nov 2026May be dated

The prompt

This is the skill's SKILL.md. Install the skill from this page into .claude/skills/public-ai-app-hardening/; the download adds references/patterns.md, code patterns for network keys, counters, charging, the bot gate and headers. Then, in a Claude Code session at the root of the app's repository, ask Claude to harden the app and say what it does, where it runs, which provider it calls and the monthly cap. You can also paste the text into a session with that request, but without the patterns file Claude writes those parts from the rules alone.

Example

Input: the skill above, installed with references/patterns.md, given this request in a Claude Code session:

Harden my public app before launch: it is a Cloudflare Worker that calls an LLM API with my key for each request; anonymous users; no rate limits yet. It's an "explain this recipe" app for anonymous visitors worldwide, no sign-in, and it must stay under US$50 a month on the Claude API. Visitors can paste their own Anthropic key in Settings: it's kept in localStorage and sent as x-user-key. index.html loads Inter from Google Fonts, there's no _headers file, wrangler.jsonc has observability on with default logs, and the footer says "We don't keep your data. No tracking." Here's the whole Worker:

// src/worker.ts
export interface Env { ASSETS: Fetcher; ANTHROPIC_API_KEY: string }

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url)
    if (url.pathname !== '/api/explain') return env.ASSETS.fetch(request)
    const { recipe, recipeUrl } = await request.json<{ recipe?: string; recipeUrl?: string }>()
    const text = recipeUrl ? await (await fetch(recipeUrl)).text() : recipe ?? ''
    console.log('explain', request.headers.get('cf-connecting-ip'), recipeUrl ?? text.slice(0, 80))
    const upstream = await fetch('https://api.anthropic.com/v1/messages', {
      method: 'POST',
      headers: {
        'x-api-key': request.headers.get('x-user-key') || env.ANTHROPIC_API_KEY,
        'anthropic-version': '2023-06-01',
        'content-type': 'application/json',
      },
      body: JSON.stringify({
        model: 'claude-haiku-4-5', max_tokens: 8000, stream: true,
        system: 'Explain this recipe step by step for a beginner cook.',
        messages: [{ role: 'user', content: text }],
      }),
    })
    return new Response(upstream.body, {
      status: upstream.status,
      headers: { 'content-type': 'text/event-stream', 'access-control-allow-origin': '*' },
    })
  },
} satisfies ExportedHandler<Env>

Output (trimmed: the opening, three of the twenty-two findings, the spend arithmetic and the verification):

Your cap is US$50 a month on the Claude API, and I sized everything to that. Right now anyone with a script can spend your key without limit, and one stuffed request can cost about $0.23. I found 22 issues, 7 of them P0. The patches are below with their tests, and the tests, type check and build all pass. …

# Area Finding (file:line) Risk (who exploits it, what it costs) Sev Fix Test
1 A src/worker.ts:14: every request without x-user-key runs on your key, and nothing counts calls on the server Anyone with curl can loop the route. At today's worst case (row 3, $0.232 a call), about 215 calls use up $50. P0 Atomic Durable Object counters per 24 h: network (IPv4 or IPv6 /64) 10, allocation (/24 or /48) 30, site 88. Charged narrowest first and given back on refusal. A store error refuses the call (503). worker.test "stops at the network cap", "fails closed with 503 when the counter store is down"; quota.test
6 H wrangler.jsonc observability with default logs: invocation logs record each request, and Cloudflare's docs don't say whether headers are redacted A visitor's x-user-key may sit in Workers Logs for 3–7 days, readable from your dashboard P0 "logs": { "invocation_logs": false }. The Worker's own log lines carry no headers. Own log lines: "logs neither the key, the address nor the recipe". Invocation logs: check on the deployed site.
8 F src/worker.ts:11: no region check Visitors in unsupported regions (CN, RU, IR…) reach Claude through you, which breaches Commercial Terms D.2 P1 regionAllowed(cf.country, cf.regionCode): 185 listed countries plus territories. Refuses XX, T1, unknown locations and the excluded Ukrainian regions. Applies to own-key calls too. region.test (6); "refuses an unsupported or unknown region with 451, own key included"

…

Haiku 4.5 costs $1 per million input tokens and $5 per million output tokens (https://platform.claude.com/docs/en/about-claude/pricing, read today).

  • Before: worst cost per call = 192,000 × $1/M + 8,000 × $5/M = $0.232.
  • After: worst cost per call = (8,000 + 1,200) × $1/M + 1,500 × $5/M = $0.0167. There is no pre-check, retry, fallback or follow-on call.
  • Site cap per day = floor(50 × 0.9 ÷ 30.4 ÷ 0.0167) = 88. At the cap with every call at its worst, that is 88 × 30.4 × $0.0167 = $44.68 a month.
  • Expected bill = calls per day × 30.4 × typical cost per call. My estimate for a typical recipe is 1,200 tokens in and 700 out, which is $0.0047. At 40 free answers a day that's 40 × 30.4 × $0.0047 = $5.72 a month; at the 88-a-day cap with typical answers it's $12.57. Replace the 1,200/700 estimate with real figures from the Console's Usage page.

…

Commands run in the patched tree …:

  • npx vitest run: 8 files, 78 tests passed (source 28, worker 18, quota 7, net 6, region 6, sse 5, turnstile 5, config 3).
  • npx tsc --noEmit with @cloudflare/workers-types@5.20261019.1: exit 0.
  • npx wrangler deploy --dry-run --outdir dist with wrangler 4.151.3: exit 0. Total upload 30.14 KiB. Bindings: env.QUOTA (Quota), env.RATE_LIMITER (6 requests/60s), env.ASSETS.
  • To confirm the tests have teeth, I put three flaws back one at a time. Each made exactly one test fail, and the code was then restored:
    • logging cf-connecting-ip fails "logs neither the key, the address nor the recipe"
    • failing open on a counter-store error fails "fails closed with 503…"
    • restoring access-control-allow-origin: * fails "…sends no CORS header"
  • git apply --check of hardening-p0.patch against the original src/worker.ts and wrangler.jsonc: applies, and the result matches the patched src/.
  • While writing these, the prompt-overhead check failed (1,023 bytes against an assumed 1,000). I raised the overhead to 1,200 and redid the sums ($0.0165 → $0.0167, 89 → 88 a day) rather than loosen the check.

Not run:

  • The browser code (explain-client.js, the Turnstile widget, the refusal display) was not run.
  • The Durable Object, rate limiter and Cache API were tested only against in-memory fakes, not on Cloudflare's runtime.
  • No real Turnstile or Anthropic calls were made.

The model priced the original Worker's worst call at $0.232 (192,000 input and 8,000 output tokens), capped one call at $0.0167 and took the 88-a-day site cap from the skill's formula. When its own overhead test failed, it raised the assumed overhead and redid the sums instead of loosening the check, and it listed what it had tested only against in-memory fakes.

Why it works

  • The server is the guard, and the site counter is the spend cap. A browser counter resets when someone clears storage, so it can only nudge. Three nested counters in an atomic store are charged narrowest first: the network counter stops one visitor, the allocation counter stops one cheap IPv6 block rotating through its /64s, and the site counter caps the bill. The bot gate keeps a script spread over many networks from using it up. Failing closed when the store errors means an outage can't turn into unmetered spend. Refunding only what the provider doesn't bill means its errors don't use up a visitor's allowance, and a billed decline can't be looped for free.
  • Keyed hashes, grouped by network. IPv4 has only 2^32 addresses, so a plain SHA-256 of one can be reversed by brute force; an HMAC under a server secret can't. Grouping IPv6 by /64 and /48 matters because one household or one rented server can rotate addresses within its block for free.
  • Terms become gates, and the cap is a sum you can check. The provider sees your server's location, not your visitor's, so region rules it can't apply become a gate in code you own, with the source and date beside the list; an age rule becomes a stated audience. Your server can't tell whether a visitor's own key is billed either, so where the provider has a free tier, the key gets free-tier rules unless the visitor says otherwise. Writing the cap as a sum over today's prices (never remembered ones) makes it auditable. Max input and output tokens bound the worst case, and cheaper-only fallbacks stop a fallback from doubling the bill.
  • "Never stored or logged" has to be true at the platform level. Platform request logs can capture headers and addresses unless you turn them off, and provider error text can echo a key. A key from a project without billing, or a workspace with its own spend limit, turns a leak into a nuisance instead of a bill. Diffing the privacy note against the code catches drift, such as an edge-injected analytics script behind a "no tracking" claim.

Variations

  • No Durable Objects. In Redis, SET k 0 EX <ttl> NX, then INCR (or one Lua script), and DECR on a refusal. In Postgres or SQLite, UPDATE … SET used = used + 1 WHERE id = ? AND used < ? RETURNING used. Don't use an eventually consistent key-value store for counters, because parallel requests overspend it.
  • One area at a time. When you change provider or model, rerun E (filters, refusals, what's billed), F (terms), G (spend) and H (key shape, key check). Rerun J (privacy copy) whenever you add a third party.
  • Generalised from a public AI app.

Install as a skill

This entry comes with a SKILL.md and 1 reference file. Claude loads it whenever a request matches its description.

  • Claude Code: unzip into .claude/skills/ in your project, or ~/.claude/skills/ for every project.
  • Claude apps: upload the .zip under Customize → Skills → + → Create skill → Upload a skill. Skills need code execution, which is under Settings → Capabilities.