Cloudflare Workers Security Guide for Vibe Coders
Published on January 23, 2026 - 11 min read
TL;DR
Cloudflare Workers run at the edge globally, which means security must be baked in from the start. Use wrangler secret for API keys and sensitive data (never wrangler.toml). Validate all incoming requests including headers and body. Use bindings for KV, D1, and R2 access control. Remember Workers have no persistent state, so implement proper authentication for every request.
Why Workers Security Matters for Vibe Coding
Cloudflare Workers execute at over 300 edge locations worldwide. When AI tools generate Workers code, they often produce functional handlers but miss security fundamentals. The edge deployment model means every security vulnerability is replicated globally.
Common issues include secrets in wrangler.toml, missing request validation, and improper use of environment bindings.
Secrets Management
Workers have two ways to store configuration: variables (public) and secrets (encrypted).
Wrangler Secrets
# Set a secret (encrypted, not visible in dashboard)
wrangler secret put API_KEY
# Enter the value when prompted
# Set multiple secrets
wrangler secret put DATABASE_URL
wrangler secret put JWT_SECRET
# List secrets (values hidden)
wrangler secret list
# Delete a secret
wrangler secret delete OLD_KEY
Never Put Secrets in wrangler.toml
# wrangler.toml - WRONG!
[vars]
API_KEY = "sk-secret-key" # This is committed to git!
# wrangler.toml - CORRECT
[vars]
PUBLIC_API_URL = "https://api.example.com"
ENVIRONMENT = "production"
# Secrets are set via wrangler secret put
Accessing Secrets in Code
// Secrets are available on the env object
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Access secrets from env
const apiKey = env.API_KEY;
const dbUrl = env.DATABASE_URL;
if (!apiKey) {
console.error('API_KEY not configured');
return new Response('Server configuration error', { status: 500 });
}
// Use the secret
const response = await fetch('https://api.example.com', {
headers: { Authorization: `Bearer ${apiKey}` },
});
return response;
},
};
// Type definition
interface Env {
API_KEY: string;
DATABASE_URL: string;
JWT_SECRET: string;
MY_KV_NAMESPACE: KVNamespace;
}
Request Validation
Validate every incoming request before processing:
import { z } from 'zod';
const CreateUserSchema = z.object({
email: z.string().email().max(255),
name: z.string().min(1).max(100),
});
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Validate method
if (request.method !== 'POST') {
return new Response('Method not allowed', { status: 405 });
}
// Validate Content-Type
const contentType = request.headers.get('content-type');
if (!contentType?.includes('application/json')) {
return new Response('Invalid content type', { status: 415 });
}
// Parse and validate body
let body: unknown;
try {
body = await request.json();
} catch {
return new Response('Invalid JSON', { status: 400 });
}
const result = CreateUserSchema.safeParse(body);
if (!result.success) {
return Response.json(
{ error: 'Validation failed', details: result.error.flatten() },
{ status: 400 }
);
}
// Now safe to use
const { email, name } = result.data;
// Process the request...
return Response.json({ success: true });
},
};
Authentication Patterns
Workers are stateless, so authenticate every request:
JWT Validation
import { jwtVerify, createRemoteJWKSet } from 'jose';
async function validateJWT(request: Request, env: Env) {
const authHeader = request.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return null;
}
const token = authHeader.slice(7);
try {
// For Auth0/Clerk/etc - use JWKS
const JWKS = createRemoteJWKSet(
new URL(`${env.AUTH_ISSUER}/.well-known/jwks.json`)
);
const { payload } = await jwtVerify(token, JWKS, {
issuer: env.AUTH_ISSUER,
audience: env.AUTH_AUDIENCE,
});
return payload;
} catch (error) {
console.error('JWT validation failed:', error);
return null;
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const user = await validateJWT(request, env);
if (!user) {
return new Response('Unauthorized', { status: 401 });
}
// User is authenticated
return Response.json({ userId: user.sub });
},
};
API Key Authentication
async function validateApiKey(request: Request, env: Env) {
const apiKey = request.headers.get('x-api-key');
if (!apiKey) {
return null;
}
// Look up API key in KV
const keyData = await env.API_KEYS.get(apiKey, 'json');
if (!keyData) {
return null;
}
// Check if key is expired
if (keyData.expiresAt && keyData.expiresAt < Date.now()) {
return null;
}
return keyData;
}
Bindings Security
Workers access storage services through bindings. Secure them properly:
KV Namespace
// KV operations
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Never store raw secrets in KV
// KV is eventually consistent and accessible via API
// Store user-scoped data
const userId = await getUserId(request, env);
const key = `user:${userId}:preferences`;
// SAFE: User can only access their own data
const prefs = await env.USER_DATA.get(key, 'json');
// DANGEROUS: User-controlled key
const userKey = new URL(request.url).searchParams.get('key');
// const data = await env.USER_DATA.get(userKey); // Don't do this!
// SAFE: Validate and scope the key
if (userKey && /^[a-z0-9-]+$/.test(userKey)) {
const scopedKey = `user:${userId}:${userKey}`;
const data = await env.USER_DATA.get(scopedKey);
}
return Response.json(prefs);
},
};
D1 Database
// D1 with parameterized queries
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const userId = await getUserId(request, env);
// SAFE: Parameterized query
const { results } = await env.DB.prepare(
'SELECT * FROM posts WHERE author_id = ?'
).bind(userId).all();
// DANGEROUS: String interpolation
// const { results } = await env.DB.prepare(
// `SELECT * FROM posts WHERE author_id = '${userId}'`
// ).all();
return Response.json(results);
},
};
R2 Storage
// R2 with access control
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const userId = await getUserId(request, env);
const url = new URL(request.url);
const key = url.pathname.slice(1); // Remove leading /
// Validate key format
if (!key || key.includes('..') || key.startsWith('/')) {
return new Response('Invalid key', { status: 400 });
}
// Scope to user's directory
const scopedKey = `users/${userId}/${key}`;
if (request.method === 'GET') {
const object = await env.BUCKET.get(scopedKey);
if (!object) {
return new Response('Not found', { status: 404 });
}
return new Response(object.body);
}
if (request.method === 'PUT') {
// Validate content type and size
const contentType = request.headers.get('content-type');
const contentLength = parseInt(request.headers.get('content-length') || '0');
if (contentLength > 10 * 1024 * 1024) { // 10MB limit
return new Response('File too large', { status: 413 });
}
await env.BUCKET.put(scopedKey, request.body, {
httpMetadata: { contentType },
});
return new Response('Uploaded', { status: 201 });
}
return new Response('Method not allowed', { status: 405 });
},
};
Cloudflare Workers Security Checklist
- All secrets stored via
wrangler secret put - No secrets or API keys in wrangler.toml
- Request method and content-type validated
- Request body validated with Zod or similar
- Authentication checked on every request
- JWT tokens validated with proper issuer and audience
- KV keys scoped to authenticated user
- D1 queries use parameterized statements
- R2 paths validated and user-scoped
- Error responses don't leak sensitive information
- CORS configured appropriately
- Rate limiting implemented for sensitive endpoints
CORS Configuration
const CORS_HEADERS = {
'Access-Control-Allow-Origin': 'https://yourdomain.com',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
};
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, { headers: CORS_HEADERS });
}
// Check origin
const origin = request.headers.get('origin');
if (origin !== 'https://yourdomain.com') {
return new Response('Forbidden', { status: 403 });
}
const response = await handleRequest(request, env);
// Add CORS headers to response
Object.entries(CORS_HEADERS).forEach(([key, value]) => {
response.headers.set(key, value);
});
return response;
},
};
Are secrets encrypted in Cloudflare Workers?
Yes, secrets set via wrangler secret put are encrypted at rest and only decrypted when your Worker runs. They're not visible in the dashboard or wrangler output.
::
Can I use environment variables in wrangler.toml?
Yes, but only for non-sensitive configuration like feature flags or public URLs. Never put API keys, database credentials, or other secrets in wrangler.toml as it's committed to version control.
How do I handle different environments (dev/staging/prod)?
Use wrangler environments. Create separate Workers with different secrets: wrangler secret put API_KEY --env staging . Each environment has its own isolated secrets.
Is KV secure for storing sensitive data?
KV data is encrypted at rest, but it's accessible via the Cloudflare API with your account credentials. Don't store raw secrets in KV. For user data, always scope keys to prevent users from accessing each other's data.
::
What CheckYourVibe Detects
When scanning your Cloudflare Workers project, CheckYourVibe identifies:
- Secrets hardcoded in wrangler.toml or source code
- Missing request validation
- D1 queries vulnerable to SQL injection
- KV or R2 keys without user scoping
- Missing authentication on protected routes
- Overly permissive CORS configuration
Run npx checkyourvibe scan to catch these issues before they reach production.