How to Build Your Own AI Image Generation API with Cloudflare Workers
A step-by-step guide to deploying your own text-to-image API on Cloudflare Workers AI with Stable Diffusion XL, protected by a secret key and callable from any app or n8n.

I wanted a plain HTTP endpoint I could send a prompt to and get an image back, without renting a GPU server or wiring up another paid image API. Cloudflare Workers AI runs image models on Cloudflare's side, and a Worker can call them through a binding, so the whole API fits in one small JavaScript file.
This post walks through that file, which lives in my open-source repo Free-AI-Image-Generation-API, and shows you how to deploy your own copy on your own Cloudflare account. There is no shared public endpoint: you run the Worker, you hold the key, and you see the usage on your own dashboard.
By the end you will have a Worker that accepts POST / with a JSON prompt, checks a secret bearer key, runs Stable Diffusion XL on Workers AI and returns the image bytes. You will also know the one configuration mistake that leaves the endpoint open, and how to close it.
What you are building
The API has one route and three rules:
- Every request must carry
Authorization: Bearer <your key>. Anything else gets401. - Only
POSTto/is allowed. Other methods or paths get405. - The body must be JSON with a
promptfield. A missing prompt gets400.
If all three pass, the Worker calls the Workers AI model @cf/stabilityai/stable-diffusion-xl-base-1.0 with the prompt and streams the result back as the response body. Errors from the model come back as 500.
Your app / script / n8n
|
| POST / {"prompt": "..."} Authorization: Bearer <key>
v
Cloudflare Worker (your account)
| 1. check key 2. check method + path 3. check prompt
v
Workers AI binding env.AI.run("@cf/stabilityai/stable-diffusion-xl-base-1.0")
|
v
Image bytes in the response body
There is no database, no storage bucket and no build step in the repo. That is the point: it is the smallest thing that turns a hosted model into an API you can call from anywhere.
What "free" means here
The repo is called "Free AI Image Generation API" because Cloudflare's Workers AI pricing page says Workers AI is included in both the Free and Paid Workers plans, with a daily free allocation measured in Neurons. I am deliberately not quoting a number of images per day. The allocation is measured in Neurons rather than requests, the SDXL model is listed as beta, and Cloudflare changes these terms over time. Check the Workers AI pricing page for the current allocation before you plan around it, and keep an eye on usage in your dashboard.
One more thing from the docs worth knowing early: running Workers AI always uses your Cloudflare account, even during local development with wrangler dev, so local testing counts against the same usage.
The Worker code
This is worker.js from the repo. I have trimmed the emoji from the comments and dropped the commented-out list of alternative model ids (more on those later). The logic is unchanged.
export default {
async fetch(request, env) {
const API_KEY = env.API_KEY;
const url = new URL(request.url);
const auth = request.headers.get("Authorization");
// Simple API key check
if (auth !== `Bearer ${API_KEY}`) {
return json({ error: "Unauthorized" }, 401);
}
// Only allow POST requests to /
if (request.method !== "POST" || url.pathname !== "/") {
return json({ error: "Not allowed" }, 405);
}
try {
const { prompt } = await request.json();
if (!prompt) return json({ error: "Prompt is required" }, 400);
// Generate image from prompt
const result = await env.AI.run(
"@cf/stabilityai/stable-diffusion-xl-base-1.0",
{ prompt }
);
return new Response(result, {
headers: { "Content-Type": "image/jpeg" },
});
} catch (err) {
return json({ error: "Failed to generate image", details: err.message }, 500);
}
},
};
// Function to return JSON responses
function json(data, status = 200) {
return new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});
}
A few things to notice:
env.API_KEYandenv.AIare not imported. Cloudflare injects them from the Worker's configuration:API_KEYis a secret you set, andAIis the Workers AI binding.- Only
promptis passed to the model. The model page lists more inputs (negative_prompt,width,height,num_steps,guidance,seed), but this Worker uses the defaults for all of them. - Cloudflare's model page says the binding returns a
ReadableStream, and the Worker hands that stream straight tonew Response(). The docs do not name the image format, so treat theimage/jpegheader as a label the repo chose, and check the bytes (for example with thefilecommand) if your client cares.
The Bearer undefined problem
Look at the first check again:
if (auth !== `Bearer ${API_KEY}`) {
If you forget to set API_KEY, env.API_KEY is undefined, and the template string becomes the literal text Bearer undefined. Anyone who sends Authorization: Bearer undefined would pass the check and use your Workers AI allocation. Nothing in the dashboard warns you about this, and the Worker deploys fine without the secret.
Two fixes, and I recommend both.
Set the key as a secret, not a plain variable. From your project folder:
npx wrangler secret put API_KEY
Wrangler prompts for the value and stores it encrypted. In the dashboard, the same thing lives under your Worker's Settings → Variables and Secrets → Add, with the type set to Secret. Use a long random value; one way to make one:
openssl rand -hex 32
Reject requests when the secret is missing. Add a guard at the top of fetch, before the key comparison, so a missing secret fails closed instead of open:
export default {
async fetch(request, env) {
const API_KEY = env.API_KEY;
// Fail closed: never compare against "Bearer undefined"
if (!API_KEY) {
return json({ error: "Server misconfigured" }, 500);
}
const url = new URL(request.url);
const auth = request.headers.get("Authorization");
if (auth !== `Bearer ${API_KEY}`) {
return json({ error: "Unauthorized" }, 401);
}
// ...rest of the handler unchanged
},
};
With the guard in place, a Worker deployed without its secret answers every request with 500 and never reaches the model.
Do not put the key in the code
Never paste the real key into worker.js or commit it to Git. For local development, Cloudflare reads secrets from a .dev.vars file, which should also stay out of Git.
Deploy option 1: the dashboard (what the repo README does)
The repo has no wrangler.toml or package.json; its README deploys by copy and paste. That is the quickest way to try it.
- In the Cloudflare dashboard, go to Workers & Pages and create a new Worker. Give it a name.
- Open the code editor, replace the example code with
worker.js(plus theAPI_KEYguard above), and deploy. - In the Worker's bindings settings, add a Workers AI binding and name the variable
AI(labels may shift as the dashboard changes). The code readsenv.AI, so the name must match exactly. (Note this is a Workers AI binding, not a service binding.) - Under Settings → Variables and Secrets, add
API_KEYwith the type Secret, then deploy again.
Your Worker is now live at https://<your-worker>.<your-subdomain>.workers.dev, where both parts are your own names.
Deploy option 2: Wrangler
If you prefer keeping the Worker in Git, create a project with the Cloudflare CLI, drop worker.js in as the entry file, and add the AI binding to the config.
npm create cloudflare@latest -- my-image-api
cd my-image-api
npx wrangler login
Put the code in src/index.js (or point main at wherever you keep it), then add the binding to wrangler.jsonc:
{
"name": "my-image-api",
"main": "src/index.js",
"ai": {
"binding": "AI"
}
}
The generated config will also contain a compatibility_date; keep it. If your project uses wrangler.toml instead, the binding is:
[ai]
binding = "AI"
Then set the secret and deploy:
npx wrangler secret put API_KEY
npx wrangler deploy
For local testing, put API_KEY="<a test key>" in .dev.vars and run npx wrangler dev. Remember that the model calls still run on your account.
Test it with cURL
curl -X POST "https://<your-worker>.<your-subdomain>.workers.dev/" \
-H "Authorization: Bearer <YOUR_API_KEY>" \
-H "Content-Type: application/json" \
-d '{"prompt": "A minimal blue and white illustration of an automation workflow"}' \
--output generated-image.jpg
If it worked, generated-image.jpg is an image file. If it is tiny and opens as text, it is one of the JSON errors; cat it to see which:
| Status | Body | Usual cause |
|---|---|---|
| 401 | {"error":"Unauthorized"} |
Missing or wrong Authorization header, or a typo in the secret |
| 405 | {"error":"Not allowed"} |
Used GET, or posted to a path other than / |
| 400 | {"error":"Prompt is required"} |
Body has no prompt field |
| 500 | {"error":"Failed to generate image", ...} |
Model call failed; often the AI binding is missing or misnamed |
| 500 | {"error":"Server misconfigured"} |
Your guard fired: API_KEY secret is not set |
Call it from JavaScript
On a server (a Node script, an API route, another Worker), fetch is all you need:
async function generateImage(prompt) {
const response = await fetch(process.env.IMAGE_API_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.IMAGE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ prompt }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || `Image API returned ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
}
Do not call the Worker straight from browser code. Anything in the browser is visible to the user, including the key, and with the key anyone can spend your allocation. Put your own backend in front of it, or give each user their own credential.
Call it from n8n with an HTTP Request node
Any HTTP client works, and n8n's HTTP Request node is enough. Configure it like this:
- Method:
POST. URL: your Worker URL. - Authentication: choose Generic Credential Type, then Header Auth. Create a credential with the name
Authorizationand the valueBearer <YOUR_API_KEY>, so the key lives in n8n's credential store rather than in the node. - Turn on Send Body, set Body Content Type to JSON, and use Specify Body: Using JSON:
{
"prompt": "{{ $json.imagePrompt }}"
}
Finally, under Options → Response, set Response Format to File and choose the field name in Put Output in Field (for example data).
The next node receives the image as binary data, so you can upload it to Google Drive, S3 or R2, or attach it to a draft. If you are building a content workflow around this, my post on building a content pipeline in n8n shows where an image step fits.
Hardening before you share the URL
The repo is intentionally minimal. Before the endpoint is used by anything other than your own scripts, I would add these, roughly in this order:
- The API_KEY guard from earlier. It is three lines and closes the worst hole.
- Rate limiting. There is none in the Worker, so one leaked key can burn your whole daily allocation. Cloudflare has a Rate Limiting binding for Workers (
env.MY_RATE_LIMITER.limit({ key })); key it on the caller's key or IP and choose limits for your own use. - Prompt validation. The Worker only checks that
promptis truthy. Check that it is a string and cap its length.
if (typeof prompt !== "string" || prompt.trim().length === 0 || prompt.length > 2000) {
return json({ error: "Prompt must be a non-empty string under 2000 characters" }, 400);
}
The 2000 here is my own cap, not a model limit; pick what suits you.
- Generic error bodies. The
500response includeserr.message. That is handy while you debug, but on a shared endpoint log the details withconsole.errorand return only{"error":"Failed to generate image"}. - Per-user keys and quotas if more than one person or app will call it. A single shared secret cannot be revoked for one caller without breaking everyone.
- Storage. Returning bytes keeps the Worker simple. If you want URLs instead, write the image to R2 and return the object key.
Swapping the model
The repo's comment lists a few other Workers AI image models. They are not drop-in replacements. Some, such as the img2img and inpainting models, need an input image as well as a prompt, and others return a different response shape (for example JSON with a base64 image rather than a raw stream), which means changing how the Worker builds its response. One id in that comment list is also misspelled. Before switching, open the model's page in the Workers AI catalogue, check its inputs and output, and adjust the code to match.
Frequently asked questions
Is it really free?
Workers AI is included in the Free and Paid Workers plans with a daily free allocation. How many images that covers depends on the model and Cloudflare's current terms, so check the pricing page and your usage dashboard instead of relying on a number from a blog post.
Can I use your deployed endpoint?
No. There is no public endpoint. The repo and this post are for deploying your own Worker on your own account.
Do I need a GPU or Python?
No. The model runs on Cloudflare's infrastructure and the Worker is plain JavaScript.
Why does every request return 500 after I deploy?
If the body says "Server misconfigured", the API_KEY secret is not set. If it says "Failed to generate image", check that the Workers AI binding exists and is named exactly AI.
Can I pass width, height or a seed?
The model accepts them, but this Worker only forwards prompt. Read the extra fields from the request body, validate them, and pass them into the env.AI.run options object.
Wrap-up
The useful part of this project is not the model, it is the shape: one input, a key check, a validation step, a model call and a predictable output. Fork the repo, add the API_KEY guard, set the secret with wrangler secret put API_KEY, and you have an image endpoint that any script or n8n workflow can call. Add rate limiting before anyone else gets the URL.
Sources
Verified against the sources below on October 3, 2026. Products and docs change often: check the linked sources if something looks different.
- stable-diffusion-xl-base-1.0 model page (Cloudflare Workers AI)
- Workers AI bindings (Cloudflare)
- Get started with Workers AI using Wrangler (Cloudflare)
- Secrets (Cloudflare Workers)
- Workers AI pricing (Cloudflare)
- Rate Limiting binding (Cloudflare Workers)
- HTTP Request node (n8n docs)
- Free-AI-Image-Generation-API (GitHub repository)



Comments
Loading comments...