Where the trust boundary is
Everything inside the Worker is server-side code, so it may hold secrets and talk to databases through bindings. Everything that reaches it is untrusted: the URL, headers, cookies and body of each request. A Worker has no login screen in front of it unless you add one, so each route is public until your code says otherwise.
Configuration is part of the boundary. Cloudflare's documentation says secrets are environment variables whose values are not visible in Wrangler or the dashboard after you define them, and it says not to use vars to store sensitive information in your Worker's Wrangler configuration file. That file is normally committed to git, so a key placed under vars is published with your code.
If the Worker serves a front end as well, the usual rule applies to that part: anything sent to the browser is public.
The checklist
| Area | What to verify | How to test on your own project | Pass condition |
|---|---|---|---|
| Secrets and vars | No credential sits under vars in the Wrangler configuration file or in source. | Read wrangler.toml or wrangler.jsonc and run the searches in Step 1. | Only non-sensitive values are under vars, and credentials are set as secrets. |
| Local secret files | .dev.vars and .env files are ignored by git and were never committed. | Check .gitignore and search history with the commands in Step 1. | Both patterns are ignored and history has no hits. |
| Addresses | You know every address that serves the Worker. | Read workers_dev and preview_urls in the config, then request the workers.dev address with curl. | Only intended addresses respond, and the production domain is a custom domain or route. |
| Authentication | Private routes verify the caller inside the fetch handler. | Call each route with curl and no credentials, on every address the Worker answers on. | Private routes refuse on all of them. |
| Cross-user access | Records are scoped to the verified user. | As a second test user, request the first user's record id. | The second user gets a refusal or an empty result. |
| D1 queries | SQL uses prepared statements with bound parameters. | Search for prepare( calls that build strings, using the command in Step 3. | No query joins request input into the SQL text. |
| Abuse limits | Routes that cost money or send messages are limited per user. | Call the route repeatedly from a script against your own Worker. | Requests beyond your limit get a 429 response. |
| Global state | No per-request data is kept in module-level variables. | Read the top of each module for let or mutable objects that hold user or request data. | Request data is passed through function arguments only. |
Step 1: Secrets, vars and local files
Cloudflare documents npx wrangler secret put for adding a secret to a deployed Worker, and .dev.vars or .env files for local development. It says those files should not be committed to git and tells you to add .dev.vars* and .env* to your .gitignore. Run the searches below on your own project, move anything sensitive out of vars, and rotate any credential that was ever committed.
grep -nA12 "vars" wrangler.toml wrangler.jsonc 2>/dev/null
grep -nE "\.dev\.vars|\.env" .gitignore
git ls-files | grep -E "\.dev\.vars|^\.env"
git log --all -p -S"sk_live_" | head -50
npx wrangler secret list
npx wrangler secret put STRIPE_API_KEYStep 2: Know every address
A Worker can answer on a workers.dev subdomain, on preview URLs and on your own domain. Cloudflare's documentation recommends running production Workers on a Workers route or custom domain, not on the workers.dev subdomain, and shows workers_dev = false in the Wrangler configuration file to disable that route. It says preview URLs are available publicly when enabled, that they default to matching your workers_dev setting, and that preview_urls = false turns them off. It also describes using Cloudflare Access to restrict previews.
This matters when protection sits in front of your custom domain, for example rules on your zone, while the same code is reachable directly on another address. Confirm in your own project which addresses respond. If a route is private, it should refuse on all of them because the check is in the code.
workers_dev = false
preview_urls = false
curl -i "https://your-worker.your-subdomain.workers.dev/api/private"
curl -i "https://your-domain.example/api/private"Step 3: The fetch handler, D1 and rate limits
Read the fetch handler as a list of routes and ask of each one: who may call this, and where does the code check? Generated Workers often verify nothing, or compare a header with a secret using ordinary equality. Cloudflare's best practices recommend crypto.subtle.timingSafeEqual() for comparing secrets, and the Web Crypto API instead of Math.random() for anything security-sensitive such as tokens.
For D1, the documentation says binding parameters with bind() to prepared statements prevents SQL injection attacks. For abuse, Cloudflare provides a rate limiting binding. Its documentation advises against using IP addresses as the key, because many users may share one, and suggests stable identifiers such as user ids. It also describes the limiter as permissive and eventually consistent, not an accurate accounting system, so treat it as protection against floods and keep hard spending caps with your paid providers too.
grep -rnE "prepare\(`.*\$\{|prepare\(.*\+" src
const row = await env.DB
.prepare("SELECT id, title FROM notes WHERE id = ? AND owner_id = ?")
.bind(noteId, userId)
.first()
const { success } = await env.MY_RATE_LIMITER.limit({ key: userId })
if (!success) {
return new Response("Too many requests", { status: 429 })
}Step 4: Global state and CORS
Cloudflare's best practices explain that Workers reuse isolates across requests, so a variable set during one request is still present during the next. Generated code that stores the current user or a request body in a module-level variable can hand one visitor's data to another. Pass state through function arguments instead.
Check the CORS headers the Worker sends. AI tools often add a wildcard origin to clear a browser error. A wildcard is fine for a truly public, read-only API. It is the wrong answer for routes that use cookies or return private data, where the allowed origin should be your own site.
Common mistakes
- Putting an API key under vars in the Wrangler configuration file and committing it.
- Committing .dev.vars because only .env was in .gitignore.
- Protecting the custom domain and forgetting the workers.dev address serves the same code.
- Leaving public preview URLs on for a Worker bound to production data.
- Building SQL with template strings from request input.
- Keeping the current user in a global variable.
- Leaving an AI or email route open with no limit per user.
Keep it working
Re-read the Wrangler configuration file in every change that touches it, and repeat the outside probes after each deploy on every address. Pass condition: no credential in config, source or history, private routes refuse on all addresses, queries use bound parameters, and costly routes return 429 when hammered.
Frequently asked questions
Are Cloudflare Workers secure?
The platform runs your code, and your code decides who gets what. A Worker has no authentication unless you add it, so every route is public until the fetch handler checks the caller. Secrets, addresses and query handling are the areas to review, and this checklist covers each.
What is the difference between vars and secrets in Cloudflare Workers?
Both reach your code as environment variables. Cloudflare's documentation says the difference is that secret values are not visible in Wrangler or the dashboard after you define them, and it says not to use vars to store sensitive information in the Wrangler configuration file.
Is my workers.dev URL public?
Yes, if it is enabled. Anyone with the address can send requests to it. Cloudflare recommends running production Workers on a route or custom domain and documents workers_dev = false to disable the subdomain. Check your own configuration and test the address with curl.
Are Cloudflare preview URLs private?
No. Cloudflare's documentation says that when enabled, preview URLs are available publicly. You can turn them off with preview_urls = false or restrict them with Cloudflare Access.
How do I prevent SQL injection in Cloudflare D1?
Use prepared statements with bound parameters. Cloudflare's D1 documentation says binding parameters with bind() prevents SQL injection attacks. Never join request input into the SQL text.