Part 3: Adding Auth to a Public API
Realizing the Problem
I was proudly admiring the blog API I'd just built, when a thought hit me. Anyone who knows this API URL could just call it with curl, right?
curl -X POST https://minsnote-api.xxx.workers.dev/api/comments \
-H "Content-Type: application/json" \
-d '{"slug":"tech-stack","nickname":"spam","content":"this is an ad"}'
CORS blocks it from browsers, but tools like curl and Postman ignore CORS entirely. Meaning, if someone knows the API URL, they could flood it with comment spam or manipulate view counts however they want.
It's a personal blog so it's not an urgent problem, but an open door should be closed.
Choosing an Auth Method
There are several API authentication methods:
| Method | Complexity | Best For |
|---|---|---|
| API Key (header) | Low | Server-to-server, trusted clients |
| OAuth 2.0 | High | When per-user auth is needed |
| JWT | Medium | Login session management |
| HMAC Signature | Medium | Request tampering prevention |
For a simple blog frontend -> Workers API call, OAuth or JWT is overkill. Sending an API Key via a custom header is the simplest and sufficient approach.
Workers Implementation
1. Add API_KEY to Env
export interface Env {
VIEWS: KVNamespace;
DB: D1Database;
ALLOWED_ORIGIN: string;
API_KEY: string; // added
}
2. Allow X-API-Key in CORS Headers
To send an API Key via a custom header, you need to allow that header in the CORS preflight. Otherwise, the browser blocks it at the preflight stage.
function corsHeaders(origin: string, allowed: string) {
return {
"Access-Control-Allow-Origin": isAllowedOrigin(origin, allowed) ? origin : "",
"Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, X-API-Key", // added
};
}
3. Validate Before the Router
Let OPTIONS (preflight) pass through, and check the API Key for all other requests.
if (request.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders(origin, allowed) });
}
const apiKey = request.headers.get("X-API-Key") || "";
if (!env.API_KEY || apiKey !== env.API_KEY) {
return jsonResponse({ error: "Unauthorized" }, 401, origin, allowed);
}
That's it. Just three changes.
Generating and Registering the API Key
An API Key just needs to be a sufficiently long random string.
python3 -c "import secrets; print(secrets.token_hex(32))"
# → e2877cbd3e06fadc9b3c6c071c4f7b8cfa996c95...
Register this key as a secret in Workers. Don't write it directly in wrangler.toml -- that would expose the key in code. Always use secrets.
echo "generated-key" | wrangler secret put API_KEY
Secrets are stored encrypted on Cloudflare's servers and accessible via env.API_KEY in the Workers runtime. The key never appears in wrangler.toml or source code.
Verification after deployment:
# Without API Key → 401
curl -s https://minsnote-api.xxx.workers.dev/api/visitors
# {"error":"Unauthorized"}
# With API Key → 200
curl -s -H "X-API-Key: generated-key" https://minsnote-api.xxx.workers.dev/api/visitors
# {"today":1,"total":1}
Injecting the API Key into the Frontend
Here's a dilemma I had. This blog is a static site built with output: "export". Since client components call the API, the API Key is ultimately exposed to the browser. Doesn't that defeat the purpose?
You can't completely hide it, but it still helps:
- Can't call the API just by knowing the URL (need the Key too)
- When the Key is rotated, previously leaked Keys become invalid
- CORS + API Key combo provides a minimum line of defense
A server-side proxy would hide it completely, but that's not possible with a static site, so this is a realistic compromise.
Environment Variable Setup
In Next.js, environment variables accessible from the client need the NEXT_PUBLIC_ prefix.
Local development .env.local:
NEXT_PUBLIC_API_KEY=generated-key
Used in the API client:
const API_KEY = process.env.NEXT_PUBLIC_API_KEY || "";
function headers(): Record<string, string> {
return {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
};
}
Every fetch call sends the API Key through the headers() function.
Registering Secrets in GitHub Actions
.env.local is in .gitignore so it doesn't get pushed to GitHub. So how does the GitHub Actions build know the API Key?
Register NEXT_PUBLIC_API_KEY in the GitHub repo's Settings -> Secrets and variables -> Actions.
Then inject it as an environment variable during the build in the workflow:
- name: Build
run: npm run build
env:
NEXT_PUBLIC_API_KEY: ${{ secrets.NEXT_PUBLIC_API_KEY }}
This way:
- Local: reads from
.env.local - CI/CD: injected from GitHub Secrets
- Source code: the key is never hardcoded anywhere
.env.example
I added .env.example so anyone cloning the project knows which environment variables are needed.
NEXT_PUBLIC_API_KEY=your_api_key_here
Since .gitignore has .env*, you need to explicitly exclude .env.example:
.env*
!.env.example
A small detail, but without this you won't know what needs to be configured when setting up in a different environment later.
Summary
| Layer | Protection |
|---|---|
| Browser | CORS (only allowed Origins pass) |
| API Server | X-API-Key header validation |
| Comments | 4-digit password hash |
| Rate Limit | IP-based 60-second limit |
| Key Management | wrangler secret + GitHub Secrets |
It's not perfect security. You can see the API Key by opening browser dev tools. But going from "anyone can call the API just by knowing the URL" to "you need to know the Key to call the API" is a sufficient improvement for a personal blog.
Security is ultimately a cost-benefit problem. Chasing perfection is endless, and knowing when to stop is also a judgment call. I think this combination is good enough for a blog API.