Cloudflare Security Best Practices for AI Apps
A public app that calls a model carries a risk an ordinary website does not: every request can cost money. This post sets out the security practices we follow when we put AI on Cloudflare Workers, in the order we apply them.
The examples come from our four public AI demos, which we use as the reference build. The post is about Cloudflare Workers and Workers AI, but most of it applies to any public app that calls a model.
Why an AI App Needs Its Own Review
A normal public website costs almost nothing to serve, so abuse is mostly a nuisance. An AI app is different in two ways:
- Every request can cost money. A model call is priced by the token. A script that calls your endpoint in a loop spends your money, not just your bandwidth.
- Visitors supply the input. Whatever a visitor types or uploads goes into a prompt. Anything the model is allowed to do, a visitor can try to talk it into doing.
So a security review of an AI app asks two questions. Can someone run up a bill? Can someone make the app do something you did not intend?
Start With the Plan You Are On
The first thing to establish is which Workers plan the account is on, because the two plans behave differently at a limit. The plan belongs to the account, not to the Worker: an account that moved to Workers Paid for one project is on it for every Worker it runs, including the ones designed to stay inside the free allowances.
The difference matters more than any line of code:
| Workers Free | Workers Paid | |
|---|---|---|
| At a usage limit | Requests fail with an error | Usage past the included amount is charged |
| Workers AI past 10,000 Neurons a day | Calls fail | $0.011 USD per 1,000 Neurons |
| Worker requests | 100,000 a day, then errors | 10 million a month, then $0.30 USD per million |
| Spending cap | Not needed | None |
On the free plan a flood takes your app offline for the day. On the paid plan it sends you an invoice. Cloudflare has budget alerts, but they are emails. They do not stop anything.
Practice: confirm the plan before you reason about cost. If the account is on a paid plan, the limits in your own code are the only thing between a script and your card. Our demos run on a paid account, so everything below is written for that case.
Do Not Let the Page Write Its Own Permissions
Each visitor's session lives in a Durable Object built with Cloudflare's Agents SDK. The SDK keeps a state object in sync between the server and the page, which is convenient: the page always shows what the server knows.
The sync runs both ways. Unless the agent refuses, a connected page can send a new state and the server will accept it. In our demos the state holds verified (the visitor passed the bot check) and the per-session limits. Left open, one WebSocket message could set verified: true and reset the limits.
So each agent refuses, in a few lines:
/** The page reads this state; only the agent writes it. */
validateStateChange(_next: SessionState, source: Connection | "server") {
if (source !== "server") throw new Error("Session state is read-only for clients");
}
To test it, send a state message from a script and check that the server rejects it.
Practice: anything that decides what a visitor may do belongs on the server, in state the client cannot write. If a framework syncs state for you, find out which direction it syncs.
Expose Only What You Mean To
The SDK's router maps a URL to a Durable Object by name: /agents/<class>/<instance>. It does this for every Durable Object bound to the Worker. Our demos have a second one, a shared object that tracks the daily AI budget. Without a check, a request to /agents/ledger/global would be routed to it.
That object has no HTTP handler, so the request would only get an error. But the object that guards the budget should not be reachable from the internet at all. So the Worker checks the path before routing:
if (url.pathname.startsWith("/agents/")) {
// Only a session agent under a UUID is reachable. Nothing else bound to this Worker is.
if (!isSessionRoute(url.pathname, "shop-agent")) return new Response("Not found", { status: 404 });
if (foreignOrigin(request)) return new Response("Forbidden", { status: 403 });
// ...
}
Two related settings are worth closing as well:
- Extra addresses. A Worker can answer on a
workers.devaddress and on preview addresses as well as its custom domain. Turn both off, so there is one way in. - Other sites' browsers. A WebSocket can be opened from any website. Refuse a handshake whose
Originis not your own.
Practice: list every address and every path that reaches your code, then close the ones you did not choose.
Put a Budget in Front of Every Model Call
Each demo has a daily budget in Neurons, Cloudflare's unit of AI usage. Before a model call the code checks the budget; after it, the code records what the call cost. The four budgets add up to the 10,000 free Neurons a day, so normal use costs nothing.
Two details decide whether a budget like this holds:
- No call may be counted as free. The cost comes from a usage figure in the model's reply. If a reply omitted it and the code recorded zero, the budget would never run out. So when the figure is missing, the cost is worked out from the token counts.
- The check and the record are separate. Calls running at the same moment can each pass the check before any of them is recorded. The overshoot is small, but it is real, which is a reason to leave headroom below the free allowance.
A budget kept by your own code also has an obvious flaw: if the code has a bug, nothing else is counting. AI Gateway can hold a second limit that Cloudflare enforces, a number of requests in a time window. We have not yet confirmed it applies to calls made through the Workers AI binding, so test it with a low limit before relying on it.
Practice: count every model call yourself, assume the count can be wrong, and add a second limit you did not write.
Save Only a Fixed Set of Results
The demos save the results of their built-in samples, so repeat visitors cost nothing. It is tempting to save more, for example the first message of every session, keyed by its text. Two things go wrong when you do:
- Storage a visitor controls. A script opening new sessions can add saved results without limit.
- A saved result built from visitor data. If a saved result is built from anything in the session, a visitor who can alter the session can have the model's reply to their text shown to everyone.
So saved results are a fixed set: the built-in samples only, built from the bundled sample data and never from session state.
Practice: a shared cache is shared content. Only put things in it that every visitor would get anyway, and build them from data visitors cannot touch.
Treat Uploads as Hostile, and Do Not Serve Them Back
One demo reads a photo of an invoice with a vision model. The obvious way to preview the upload is to store it and serve it back to the page. That gives every upload a public address on your domain, and turns the app into a small file host.
What we do instead:
- Uploads are never served back. The page previews the visitor's own copy from the browser, and the stored bytes are deleted as soon as the model has read them.
- The bytes are checked. A file that claims to be a PNG must start with a PNG's bytes.
- The size is counted as it arrives. The declared length can be missing or wrong, so reading stops at the limit.
Practice: store an upload for as short a time as the job needs, and do not give it a public address.
Keep the Model's Job Narrow
Prompt injection is the attack most people think of first. Every prompt in the demos tells the model that visitor text is data. That instruction helps, but we do not rely on it.
What we rely on is that the model cannot do much. It reads, classifies, and writes. Code picks prices, does the arithmetic, queries the database with fixed queries, and decides what is shown. Nothing is emailed, nothing is ordered, and nobody else sees the output. A visitor who talks the model into ignoring its instructions gets an odd paragraph, shown only to them.
Practice: ask what the worst output could cause, not whether the model can be tricked. Then shrink the answer until it is dull.
Rate Limit in Two Places
The demos limit each visitor address to 40 requests a minute using the Workers rate limiting binding. It has two properties worth knowing:
- It is approximate. It counts per Cloudflare location and is not an exact quota. It slows a script down.
- The Worker has already run. By the time the code answers 429, the request has been counted as a Worker request.
A rate limiting rule on the zone is different: it blocks at Cloudflare's edge, before any Worker runs. The free website plan includes one rule, counted per address over 10 seconds. The two do different jobs, so use both.
Addresses are also a weaker identity than they look. One IPv6 subscriber holds a whole /64 block, so count IPv6 by block.
Alerts Arrive the Next Day
Cloudflare's budget alerts are free and worth setting, at more than one amount. Read how they work first. Usage is processed once a day for the day before, and an alert fires once per billing cycle. An alert tells you about yesterday.
That makes alerts a way to catch a slow leak. They will not stop a flood on the day it happens. The limits have to do that.
For anything that should stop on its own, an alert can call a webhook. The design we recommend is a small Worker that turns on one firewall rule blocking the app's hostnames. Its API token can only edit firewall rules, so it cannot deploy code or change the plan.
The Seven Layers
Put together, this is what sits between the internet and a model call. No layer is trusted on its own: each assumes the one before it failed.
| # | Layer | What it limits |
|---|---|---|
| 1 | Zone rate limiting rule | Requests per address, before any Worker runs |
| 2 | Route guard and per-address limit | Which paths reach your code, and how often |
| 3 | Bot check and per-session caps | What one session can do |
| 4 | Daily budget in your own code | AI spend per day |
| 5 | AI Gateway request limit | Model calls per day, counted by Cloudflare |
| 6 | Usage alerts | Nothing. They tell you, a day later |
| 7 | Kill switch | Everything, once an alert fires |
The Basics Still Matter
A review focused on AI should not skip the ordinary items. None of them is specific to models, and each takes minutes to check:
- Two-factor login on the Cloudflare account. Whoever controls the account controls the site, the DNS, and the plan.
- API tokens. Remove the ones nothing uses.
- Email authentication. Check that SPF lists every service that sends mail for your domain, that each one signs with DKIM, and that DMARC is set to something stricter than
p=none. - A Content-Security-Policy. It limits what a page can load if something does go wrong.
- Forms that send email. Validate every field, cap the size, and rate limit it.
- Leftover DNS records. A record pointing at a service you no longer use can be claimed by someone else.
A Checklist
- You know which plan the account is on, and what happens at each limit.
- Session permissions live in state the client cannot write.
- Only the paths and addresses you chose reach your code.
- Every model call is counted, including when the reply reports no usage.
- A second limit on model calls exists that you did not write.
- Shared saved results are a fixed set, built from data visitors cannot touch.
- Uploads are checked, capped, short-lived, and not served back.
- The model cannot write to a database, send anything, or set a price.
- Requests are rate limited at the edge and in the Worker.
- Budget alerts exist at more than one amount, and something acts on them.
- Two-factor login is on.
What This Does Not Cover
- A determined flood. On a paid plan, enough requests from enough addresses still cost money. The layers make that expensive to attempt and quick to notice. They do not make it impossible.
- Abuse inside the limits. A visitor can still use a demo as a small free chatbot within the session and daily caps.
- An outside test. These practices come from reviewing our own code and configuration. A review by someone who did not build the system finds different things.
What This Means for a Real Project
Demos are low stakes: made-up businesses and made-up data. A client system has real records and a real invoice at the end of the month, and the same seven layers apply with one addition. Each client's system goes on the client's own Cloudflare account, so their usage is billed to them and a problem in one cannot spend another's money.
If you are putting a model in front of the public and want it reviewed first, see our fixed-price packages or book a free 30-minute call.