
Cloudflare Workers Security
- 240 installs
- 202 repo stars
- Updated August 4, 2026
- secondsky/claude-skills
Use cloudflare-workers-security for development tasks
About
cloudflare-workers-security: A skill for development. This provides functionality for development workflows.
- cloudflare-workers-security
Cloudflare Workers Security by the numbers
- 240 all-time installs (skills.sh)
- +17 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,635 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/secondsky/claude-skills --skill cloudflare-workers-securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 240 |
|---|---|
| repo stars | ★ 202 |
| Last updated | August 4, 2026 |
| Repository | secondsky/claude-skills ↗ |
What it does
Use cloudflare-workers-security for development tasks
Files
Cloudflare Workers Security
Comprehensive security patterns for protecting Workers and APIs.
Quick Security Checklist
// 1. Validate all input
const validated = schema.parse(await request.json());
// 2. Authenticate requests
const user = await verifyToken(request.headers.get('Authorization'));
if (!user) return new Response('Unauthorized', { status: 401 });
// 3. Rate limit
const limited = await rateLimiter.check(clientIP);
if (!limited.allowed) return new Response('Too Many Requests', { status: 429 });
// 4. Add security headers
response.headers.set('X-Content-Type-Options', 'nosniff');
response.headers.set('X-Frame-Options', 'DENY');
// 5. Use HTTPS-only cookies
headers.set('Set-Cookie', 'session=xxx; Secure; HttpOnly; SameSite=Strict');Critical Rules
1. Never trust client input - Validate and sanitize everything 2. Use secure secrets - Store in Wrangler secrets, never in code 3. Implement rate limiting - Protect against abuse 4. Set security headers - Prevent common attacks 5. Use CORS properly - Don't use * in production
Top 10 Security Errors
| Vulnerability | Symptom | Prevention |
|---|---|---|
| Missing auth | Unauthorized access | Verify tokens on every request |
| SQL injection | Data breach | Use parameterized queries with D1 |
| XSS | Script injection | Sanitize output, set CSP |
| CORS misconfiguration | Blocked requests or open access | Configure specific origins |
| Secrets in code | Exposed credentials | Use wrangler secret |
| Missing rate limits | DoS vulnerability | Implement per-IP limits |
| Weak tokens | Session hijacking | Use crypto.subtle for signing |
| Missing HTTPS | Data interception | Enforce HTTPS redirects |
| Insecure headers | Clickjacking, MIME attacks | Set security headers |
| Excessive permissions | Blast radius | Principle of least privilege |
Authentication Patterns
JWT Verification
async function verifyJWT(token: string, secret: string): Promise<{ valid: boolean; payload?: unknown }> {
try {
const [headerB64, payloadB64, signatureB64] = token.split('.');
// Verify signature
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signature = Uint8Array.from(atob(signatureB64.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0));
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const valid = await crypto.subtle.verify('HMAC', key, signature, data);
if (!valid) return { valid: false };
// Decode payload
const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
// Check expiration
if (payload.exp && Date.now() / 1000 > payload.exp) {
return { valid: false };
}
return { valid: true, payload };
} catch {
return { valid: false };
}
}API Key Validation
async function validateApiKey(
request: Request,
env: Env
): Promise<{ valid: boolean; clientId?: string }> {
const apiKey = request.headers.get('X-API-Key');
if (!apiKey) return { valid: false };
// Hash the key for lookup (never store plain keys)
const keyHash = await sha256(apiKey);
// Lookup in KV or D1
const client = await env.KV.get(`apikey:${keyHash}`, 'json');
if (!client) return { valid: false };
return { valid: true, clientId: client.id };
}
async function sha256(str: string): Promise<string> {
const buffer = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return [...new Uint8Array(buffer)].map(b => b.toString(16).padStart(2, '0')).join('');
}Input Validation
With Zod
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
});
async function handleCreate(request: Request): Promise<Response> {
try {
const body = await request.json();
const user = UserSchema.parse(body);
// Safe to use validated data
return Response.json({ success: true, user });
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json({ error: 'Validation failed', details: error.errors }, { status: 400 });
}
throw error;
}
}Security Headers
function addSecurityHeaders(response: Response): Response {
const headers = new Headers(response.headers);
// Prevent MIME type sniffing
headers.set('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
headers.set('X-Frame-Options', 'DENY');
// XSS protection
headers.set('X-XSS-Protection', '1; mode=block');
// Content Security Policy
headers.set('Content-Security-Policy', "default-src 'self'; script-src 'self'");
// HSTS
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
// Referrer policy
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
return new Response(response.body, { status: response.status, headers });
}CORS Configuration
const ALLOWED_ORIGINS = ['https://app.example.com', 'https://admin.example.com'];
function handleCORS(request: Request, response: Response): Response {
const origin = request.headers.get('Origin');
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
return response; // No CORS headers
}
const headers = new Headers(response.headers);
headers.set('Access-Control-Allow-Origin', origin);
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
headers.set('Access-Control-Allow-Credentials', 'true');
headers.set('Access-Control-Max-Age', '86400');
return new Response(response.body, { status: response.status, headers });
}When to Load References
Load specific references based on the task:
- Implementing authentication? → Load
references/authentication.md - CORS issues? → Load
references/cors-security.md - Validating input? → Load
references/input-validation.md - Managing secrets? → Load
references/secrets-management.md - Rate limiting? → Load
references/rate-limiting.md - Security headers? → Load
references/security-headers.md
Templates
| Template | Purpose | Use When |
|---|---|---|
templates/auth-middleware.ts | JWT/API key auth | Adding authentication |
templates/cors-handler.ts | CORS middleware | Handling cross-origin |
templates/rate-limiter.ts | Rate limiting | Preventing abuse |
templates/secure-worker.ts | Full secure setup | Starting secure project |
Scripts
| Script | Purpose | Command |
|---|---|---|
scripts/security-audit.sh | Audit security | ./security-audit.sh <url> |
Resources
- Security: https://developers.cloudflare.com/workers/platform/security/
- WAF: https://developers.cloudflare.com/waf/
- Rate Limiting: https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/
Authentication for Cloudflare Workers
Comprehensive guide to implementing authentication in Workers.
Authentication Methods
| Method | Use Case | Pros | Cons |
|---|---|---|---|
| JWT | Stateless APIs | Scalable, no DB lookup | Token size, revocation |
| API Keys | Service-to-service | Simple, long-lived | Less secure for users |
| Session cookies | Web apps | Secure, revocable | Requires state |
| OAuth 2.0 | Third-party auth | Standard, delegated | Complex setup |
| mTLS | High security | Very secure | Complex certificates |
JWT Authentication
Create JWT
interface JWTPayload {
sub: string;
iat: number;
exp: number;
[key: string]: unknown;
}
async function createJWT(payload: JWTPayload, secret: string): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)
);
const encodedSignature = base64UrlEncode(String.fromCharCode(...new Uint8Array(signature)));
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
}
function base64UrlEncode(str: string): string {
return btoa(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}Verify JWT
interface VerifyResult {
valid: boolean;
payload?: JWTPayload;
error?: string;
}
async function verifyJWT(token: string, secret: string): Promise<VerifyResult> {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return { valid: false, error: 'Invalid token format' };
}
const [headerB64, payloadB64, signatureB64] = parts;
// Verify signature
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signature = base64UrlDecode(signatureB64);
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const valid = await crypto.subtle.verify('HMAC', key, signature, data);
if (!valid) {
return { valid: false, error: 'Invalid signature' };
}
// Decode and validate payload
const payload = JSON.parse(atob(base64UrlUnescape(payloadB64))) as JWTPayload;
// Check expiration
if (payload.exp && Date.now() / 1000 > payload.exp) {
return { valid: false, error: 'Token expired' };
}
// Check not-before
if (payload.nbf && Date.now() / 1000 < payload.nbf) {
return { valid: false, error: 'Token not yet valid' };
}
return { valid: true, payload };
} catch (error) {
return { valid: false, error: (error as Error).message };
}
}
function base64UrlDecode(str: string): Uint8Array {
const base64 = base64UrlUnescape(str);
const binary = atob(base64);
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}
function base64UrlUnescape(str: string): string {
return str.replace(/-/g, '+').replace(/_/g, '/');
}JWT with RS256 (Asymmetric)
async function verifyRS256JWT(token: string, publicKeyPEM: string): Promise<VerifyResult> {
const [headerB64, payloadB64, signatureB64] = token.split('.');
// Import public key
const keyData = pemToArrayBuffer(publicKeyPEM);
const key = await crypto.subtle.importKey(
'spki',
keyData,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify']
);
// Verify
const signature = base64UrlDecode(signatureB64);
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const valid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', key, signature, data);
if (!valid) {
return { valid: false, error: 'Invalid signature' };
}
const payload = JSON.parse(atob(base64UrlUnescape(payloadB64)));
return { valid: true, payload };
}
function pemToArrayBuffer(pem: string): ArrayBuffer {
const base64 = pem
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/\s/g, '');
const binary = atob(base64);
return Uint8Array.from(binary, (c) => c.charCodeAt(0)).buffer;
}API Key Authentication
Secure API Key Generation
async function generateApiKey(): Promise<{ key: string; hash: string }> {
// Generate random key
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
const key = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
// Hash for storage
const hash = await sha256(key);
return {
key: `sk_live_${key}`, // Prefix helps identify key type
hash,
};
}
async function sha256(str: string): Promise<string> {
const buffer = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(str)
);
return [...new Uint8Array(buffer)].map((b) => b.toString(16).padStart(2, '0')).join('');
}API Key Validation
interface ApiKeyData {
clientId: string;
name: string;
permissions: string[];
createdAt: number;
}
async function validateApiKey(
apiKey: string,
kv: KVNamespace
): Promise<{ valid: boolean; client?: ApiKeyData }> {
if (!apiKey || !apiKey.startsWith('sk_')) {
return { valid: false };
}
// Hash the key for lookup
const keyHash = await sha256(apiKey);
// Lookup
const client = await kv.get<ApiKeyData>(`apikey:${keyHash}`, 'json');
if (!client) {
return { valid: false };
}
return { valid: true, client };
}API Key Rate Limiting
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
async function checkApiKeyRateLimit(
clientId: string,
kv: KVNamespace,
limit = 1000,
windowSeconds = 3600
): Promise<RateLimitResult> {
const key = `ratelimit:${clientId}`;
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
// Get current count
const data = await kv.get<{ count: number; resetAt: number }>(key, 'json');
if (!data || data.resetAt < now) {
// New window
await kv.put(
key,
JSON.stringify({ count: 1, resetAt: now + windowSeconds * 1000 }),
{ expirationTtl: windowSeconds }
);
return { allowed: true, remaining: limit - 1, resetAt: now + windowSeconds * 1000 };
}
if (data.count >= limit) {
return { allowed: false, remaining: 0, resetAt: data.resetAt };
}
// Increment
await kv.put(
key,
JSON.stringify({ count: data.count + 1, resetAt: data.resetAt }),
{ expirationTtl: windowSeconds }
);
return { allowed: true, remaining: limit - data.count - 1, resetAt: data.resetAt };
}Session Authentication
Cookie-Based Sessions
interface Session {
userId: string;
createdAt: number;
expiresAt: number;
}
async function createSession(
userId: string,
kv: KVNamespace,
ttlSeconds = 86400
): Promise<string> {
const sessionId = crypto.randomUUID();
const session: Session = {
userId,
createdAt: Date.now(),
expiresAt: Date.now() + ttlSeconds * 1000,
};
await kv.put(`session:${sessionId}`, JSON.stringify(session), {
expirationTtl: ttlSeconds,
});
return sessionId;
}
function setSessionCookie(response: Response, sessionId: string): Response {
const cookie = [
`session=${sessionId}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
'Path=/',
'Max-Age=86400',
].join('; ');
const newResponse = new Response(response.body, response);
newResponse.headers.append('Set-Cookie', cookie);
return newResponse;
}
async function getSession(
request: Request,
kv: KVNamespace
): Promise<Session | null> {
const cookies = request.headers.get('Cookie') || '';
const match = cookies.match(/session=([^;]+)/);
if (!match) return null;
const sessionId = match[1];
return kv.get<Session>(`session:${sessionId}`, 'json');
}
async function destroySession(sessionId: string, kv: KVNamespace): Promise<void> {
await kv.delete(`session:${sessionId}`);
}CSRF Protection
async function generateCSRFToken(sessionId: string, secret: string): Promise<string> {
const data = `${sessionId}:${Date.now()}`;
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(data)
);
const sigHex = [...new Uint8Array(signature)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return `${btoa(data)}.${sigHex}`;
}
async function validateCSRFToken(
token: string,
sessionId: string,
secret: string
): Promise<boolean> {
try {
const [dataB64, sigHex] = token.split('.');
const data = atob(dataB64);
const [tokenSessionId, timestamp] = data.split(':');
// Verify session matches
if (tokenSessionId !== sessionId) return false;
// Check age (max 1 hour)
if (Date.now() - parseInt(timestamp) > 3600000) return false;
// Verify signature
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signature = Uint8Array.from(
sigHex.match(/.{2}/g)!.map((byte) => parseInt(byte, 16))
);
return crypto.subtle.verify(
'HMAC',
key,
signature,
new TextEncoder().encode(data)
);
} catch {
return false;
}
}OAuth 2.0 / OIDC
OAuth Authorization Flow
interface OAuthConfig {
clientId: string;
clientSecret: string;
authorizationUrl: string;
tokenUrl: string;
redirectUri: string;
scopes: string[];
}
function generateAuthUrl(config: OAuthConfig, state: string): string {
const params = new URLSearchParams({
client_id: config.clientId,
redirect_uri: config.redirectUri,
response_type: 'code',
scope: config.scopes.join(' '),
state,
});
return `${config.authorizationUrl}?${params}`;
}
async function exchangeCode(
code: string,
config: OAuthConfig
): Promise<{ accessToken: string; refreshToken?: string }> {
const response = await fetch(config.tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: config.redirectUri,
client_id: config.clientId,
client_secret: config.clientSecret,
}),
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
const data = await response.json();
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
};
}JWT ID Token Verification
async function verifyIdToken(
idToken: string,
jwksUrl: string,
expectedAudience: string
): Promise<{ valid: boolean; claims?: Record<string, unknown> }> {
const [headerB64, payloadB64] = idToken.split('.');
const header = JSON.parse(atob(base64UrlUnescape(headerB64)));
const payload = JSON.parse(atob(base64UrlUnescape(payloadB64)));
// Fetch JWKS
const jwksResponse = await fetch(jwksUrl);
const jwks = await jwksResponse.json();
// Find key
const key = jwks.keys.find((k: { kid: string }) => k.kid === header.kid);
if (!key) {
return { valid: false };
}
// Import key
const cryptoKey = await crypto.subtle.importKey(
'jwk',
key,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify']
);
// Verify signature
const signature = base64UrlDecode(idToken.split('.')[2]);
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const valid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
cryptoKey,
signature,
data
);
if (!valid) return { valid: false };
// Verify claims
if (payload.aud !== expectedAudience) return { valid: false };
if (payload.exp && Date.now() / 1000 > payload.exp) return { valid: false };
return { valid: true, claims: payload };
}Auth Middleware
type AuthenticatedHandler = (
request: Request,
env: Env,
ctx: ExecutionContext,
user: User
) => Promise<Response>;
function withAuth(handler: AuthenticatedHandler) {
return async (request: Request, env: Env, ctx: ExecutionContext): Promise<Response> => {
const authHeader = request.headers.get('Authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'Missing authorization' }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const token = authHeader.slice(7);
const result = await verifyJWT(token, env.JWT_SECRET);
if (!result.valid) {
return new Response(JSON.stringify({ error: result.error }), {
status: 401,
headers: { 'Content-Type': 'application/json' },
});
}
const user = result.payload as User;
return handler(request, env, ctx, user);
};
}CORS Security for Cloudflare Workers
Complete guide to properly configuring Cross-Origin Resource Sharing.
CORS Overview
CORS (Cross-Origin Resource Sharing) controls which websites can access your API from browsers.
Same-Origin Policy
Without CORS, browsers block requests from different origins:
https://app.com→https://api.com(blocked without CORS)https://app.com→https://app.com/api(allowed, same origin)
Basic CORS Configuration
Simple CORS Handler
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://admin.example.com',
];
function handleCORS(request: Request): Response | null {
const origin = request.headers.get('Origin');
// Preflight request
if (request.method === 'OPTIONS') {
return handlePreflight(request, origin);
}
return null; // Continue to main handler
}
function handlePreflight(request: Request, origin: string | null): Response {
const headers: Record<string, string> = {
'Access-Control-Max-Age': '86400', // 24 hours
};
if (origin && ALLOWED_ORIGINS.includes(origin)) {
headers['Access-Control-Allow-Origin'] = origin;
headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS';
headers['Access-Control-Allow-Headers'] = request.headers.get('Access-Control-Request-Headers') || '';
headers['Access-Control-Allow-Credentials'] = 'true';
}
return new Response(null, { status: 204, headers });
}
function addCORSHeaders(response: Response, origin: string | null): Response {
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
return response;
}
const newResponse = new Response(response.body, response);
newResponse.headers.set('Access-Control-Allow-Origin', origin);
newResponse.headers.set('Access-Control-Allow-Credentials', 'true');
newResponse.headers.set('Vary', 'Origin');
return newResponse;
}Full CORS Middleware
interface CORSOptions {
origins: string[] | '*';
methods?: string[];
headers?: string[];
credentials?: boolean;
maxAge?: number;
exposeHeaders?: string[];
}
function createCORSMiddleware(options: CORSOptions) {
const {
origins,
methods = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
headers = ['Content-Type', 'Authorization'],
credentials = false,
maxAge = 86400,
exposeHeaders = [],
} = options;
function isOriginAllowed(origin: string): boolean {
if (origins === '*') return true;
return origins.includes(origin);
}
return async function corsMiddleware(
request: Request,
handler: () => Promise<Response>
): Promise<Response> {
const origin = request.headers.get('Origin');
// No origin = not a CORS request
if (!origin) {
return handler();
}
// Check origin
if (!isOriginAllowed(origin)) {
return new Response('Origin not allowed', { status: 403 });
}
// Handle preflight
if (request.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origins === '*' ? '*' : origin,
'Access-Control-Allow-Methods': methods.join(', '),
'Access-Control-Allow-Headers': headers.join(', '),
'Access-Control-Max-Age': maxAge.toString(),
...(credentials && { 'Access-Control-Allow-Credentials': 'true' }),
},
});
}
// Handle actual request
const response = await handler();
const newResponse = new Response(response.body, response);
newResponse.headers.set(
'Access-Control-Allow-Origin',
origins === '*' ? '*' : origin
);
if (credentials) {
newResponse.headers.set('Access-Control-Allow-Credentials', 'true');
}
if (exposeHeaders.length > 0) {
newResponse.headers.set(
'Access-Control-Expose-Headers',
exposeHeaders.join(', ')
);
}
// Important: Vary header for caching
newResponse.headers.append('Vary', 'Origin');
return newResponse;
};
}Security Best Practices
Never Use * with Credentials
// ❌ WRONG: Will be blocked by browsers
headers.set('Access-Control-Allow-Origin', '*');
headers.set('Access-Control-Allow-Credentials', 'true');
// ✅ CORRECT: Specific origin with credentials
headers.set('Access-Control-Allow-Origin', 'https://app.example.com');
headers.set('Access-Control-Allow-Credentials', 'true');Validate Origin Against Allowlist
// ❌ WRONG: Reflects any origin
const origin = request.headers.get('Origin');
headers.set('Access-Control-Allow-Origin', origin);
// ✅ CORRECT: Validate against allowlist
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);
const origin = request.headers.get('Origin');
if (origin && ALLOWED.has(origin)) {
headers.set('Access-Control-Allow-Origin', origin);
}Don't Trust Origin for Authentication
// ❌ WRONG: Using origin for access control
if (request.headers.get('Origin') === 'https://admin.example.com') {
// Allow admin access - INSECURE!
}
// ✅ CORRECT: Use proper authentication
const token = request.headers.get('Authorization');
const user = await verifyToken(token);
if (user.role === 'admin') {
// Allow admin access
}Dynamic Origin Validation
Subdomain Matching
function isAllowedOrigin(origin: string): boolean {
try {
const url = new URL(origin);
// Allow exact matches
const exactMatches = ['https://app.example.com', 'https://api.example.com'];
if (exactMatches.includes(origin)) return true;
// Allow subdomains of example.com
if (url.hostname.endsWith('.example.com') && url.protocol === 'https:') {
return true;
}
return false;
} catch {
return false;
}
}Environment-Based Origins
interface Env {
ENVIRONMENT: string;
ALLOWED_ORIGINS: string; // Comma-separated
}
function getAllowedOrigins(env: Env): string[] {
// Development allows localhost
if (env.ENVIRONMENT === 'development') {
return [
'http://localhost:3000',
'http://localhost:8787',
'http://127.0.0.1:3000',
];
}
// Production uses configured origins
return env.ALLOWED_ORIGINS.split(',').map((o) => o.trim());
}Preflight Caching
Optimize Preflight Requests
function handlePreflight(request: Request, origin: string): Response {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Origin': origin,
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-Request-ID',
// Cache preflight for 24 hours
'Access-Control-Max-Age': '86400',
// Tell browsers this response varies by Origin
'Vary': 'Origin, Access-Control-Request-Headers',
},
});
}Common CORS Issues
Issue: Preflight Fails
Symptom: OPTIONS request returns error
Solution: Handle OPTIONS separately
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Always handle OPTIONS first
if (request.method === 'OPTIONS') {
return handlePreflight(request);
}
// Then handle actual request
const response = await handleRequest(request, env);
return addCORSHeaders(response, request.headers.get('Origin'));
}
};Issue: Credentials Not Sent
Symptom: Cookies/auth headers not included
Solution: Set credentials on both client and server
// Client
fetch('https://api.example.com/data', {
credentials: 'include', // Required for cookies
});
// Server
headers.set('Access-Control-Allow-Credentials', 'true');
headers.set('Access-Control-Allow-Origin', 'https://app.example.com'); // Not *Issue: Custom Headers Blocked
Symptom: Custom headers not accessible in response
Solution: Expose headers explicitly
headers.set('Access-Control-Expose-Headers', 'X-Request-ID, X-RateLimit-Remaining');Issue: Cache Varies by Origin
Symptom: Wrong CORS headers served from cache
Solution: Include Vary header
response.headers.set('Vary', 'Origin');CORS with Hono
import { Hono } from 'hono';
import { cors } from 'hono/cors';
const app = new Hono();
// Global CORS
app.use('*', cors({
origin: ['https://app.example.com', 'https://admin.example.com'],
allowMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400,
}));
// Or dynamic origin
app.use('*', cors({
origin: (origin) => {
if (!origin) return null;
if (origin.endsWith('.example.com')) return origin;
return null;
},
}));Testing CORS
Manual Testing
# Test preflight
curl -X OPTIONS \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type" \
-v https://api.example.com/endpoint
# Test actual request
curl -X POST \
-H "Origin: https://app.example.com" \
-H "Content-Type: application/json" \
-d '{"test": true}' \
-v https://api.example.com/endpointAutomated Testing
describe('CORS', () => {
it('allows configured origins', async () => {
const response = await fetch(url, {
method: 'OPTIONS',
headers: {
'Origin': 'https://app.example.com',
'Access-Control-Request-Method': 'POST',
},
});
expect(response.headers.get('Access-Control-Allow-Origin'))
.toBe('https://app.example.com');
});
it('rejects unknown origins', async () => {
const response = await fetch(url, {
headers: { 'Origin': 'https://evil.com' },
});
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull();
});
});Input Validation for Cloudflare Workers
Comprehensive guide to validating and sanitizing user input.
Why Input Validation Matters
Without validation:
- SQL Injection via malicious input
- XSS via unescaped output
- DoS via large payloads
- Logic bugs via unexpected types
With validation:
- Known data shapes
- Controlled input sizes
- Type safety
- Clear error messages
Validation with Zod
Basic Schema
import { z } from 'zod';
// User schema
const UserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
age: z.number().int().min(0).max(150).optional(),
role: z.enum(['user', 'admin', 'moderator']).default('user'),
});
type User = z.infer<typeof UserSchema>;
// Validate
async function createUser(request: Request): Promise<Response> {
try {
const body = await request.json();
const user = UserSchema.parse(body);
// user is now typed and validated
return Response.json({ success: true, user });
} catch (error) {
if (error instanceof z.ZodError) {
return Response.json(
{ error: 'Validation failed', details: error.errors },
{ status: 400 }
);
}
throw error;
}
}Advanced Schemas
// Nested objects
const OrderSchema = z.object({
items: z.array(z.object({
productId: z.string().uuid(),
quantity: z.number().int().min(1).max(100),
price: z.number().positive(),
})).min(1).max(50),
shipping: z.object({
address: z.string().min(10).max(500),
city: z.string().min(2).max(100),
postalCode: z.string().regex(/^\d{5}(-\d{4})?$/),
country: z.string().length(2), // ISO country code
}),
notes: z.string().max(1000).optional(),
});
// Refinements
const DateRangeSchema = z.object({
startDate: z.string().datetime(),
endDate: z.string().datetime(),
}).refine(data => new Date(data.startDate) < new Date(data.endDate), {
message: 'End date must be after start date',
});
// Transform
const SearchSchema = z.object({
query: z.string().trim().toLowerCase().min(1).max(100),
page: z.string().transform(Number).pipe(z.number().int().min(1)).default('1'),
limit: z.string().transform(Number).pipe(z.number().int().min(1).max(100)).default('10'),
});Query Parameter Validation
function validateQueryParams<T extends z.ZodType>(
request: Request,
schema: T
): z.infer<T> {
const url = new URL(request.url);
const params = Object.fromEntries(url.searchParams);
return schema.parse(params);
}
// Usage
const SearchParamsSchema = z.object({
q: z.string().min(1).max(200),
page: z.coerce.number().int().min(1).default(1),
sort: z.enum(['date', 'relevance', 'price']).default('relevance'),
});
app.get('/search', async (c) => {
const params = validateQueryParams(c.req.raw, SearchParamsSchema);
// params is typed: { q: string, page: number, sort: 'date' | 'relevance' | 'price' }
});Request Body Validation Middleware
function validateBody<T extends z.ZodType>(schema: T) {
return async (c: Context, next: Next) => {
try {
const body = await c.req.json();
c.set('validatedBody', schema.parse(body));
await next();
} catch (error) {
if (error instanceof z.ZodError) {
return c.json(
{ error: 'Validation failed', details: formatZodError(error) },
400
);
}
throw error;
}
};
}
function formatZodError(error: z.ZodError): Record<string, string[]> {
const formatted: Record<string, string[]> = {};
for (const issue of error.issues) {
const path = issue.path.join('.') || '_root';
if (!formatted[path]) {
formatted[path] = [];
}
formatted[path].push(issue.message);
}
return formatted;
}Sanitization
HTML Sanitization
function escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// For user-generated content that will be displayed
function sanitizeUserContent(content: string): string {
// Remove HTML tags
let sanitized = content.replace(/<[^>]*>/g, '');
// Escape remaining special characters
sanitized = escapeHtml(sanitized);
// Normalize whitespace
sanitized = sanitized.replace(/\s+/g, ' ').trim();
return sanitized;
}SQL Injection Prevention
// ❌ NEVER do this
const sql = `SELECT * FROM users WHERE id = '${userId}'`;
// ✅ Always use parameterized queries
async function getUser(db: D1Database, userId: string): Promise<User | null> {
const result = await db
.prepare('SELECT * FROM users WHERE id = ?')
.bind(userId)
.first<User>();
return result;
}
// ✅ For multiple parameters
async function searchUsers(
db: D1Database,
params: { name: string; role: string; limit: number }
): Promise<User[]> {
const result = await db
.prepare('SELECT * FROM users WHERE name LIKE ? AND role = ? LIMIT ?')
.bind(`%${params.name}%`, params.role, params.limit)
.all<User>();
return result.results;
}Path Traversal Prevention
function sanitizePath(userPath: string): string {
// Remove path traversal attempts
let safe = userPath
.replace(/\.\./g, '')
.replace(/\/\//g, '/')
.replace(/^\//, '');
// Only allow alphanumeric, dash, underscore, slash
safe = safe.replace(/[^a-zA-Z0-9\-_\/]/g, '');
return safe;
}
// Usage
async function getFile(bucket: R2Bucket, userPath: string): Promise<R2Object | null> {
const safePath = sanitizePath(userPath);
// Ensure within allowed directory
const fullPath = `uploads/${safePath}`;
return bucket.get(fullPath);
}Size Limits
Request Size Validation
const MAX_BODY_SIZE = 1024 * 1024; // 1MB
async function validateRequestSize(request: Request): Promise<void> {
const contentLength = request.headers.get('Content-Length');
if (contentLength && parseInt(contentLength) > MAX_BODY_SIZE) {
throw new Error('Request body too large');
}
}
// Streaming size check
async function readBodyWithLimit(
request: Request,
maxSize: number
): Promise<ArrayBuffer> {
const reader = request.body?.getReader();
if (!reader) throw new Error('No body');
const chunks: Uint8Array[] = [];
let totalSize = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
totalSize += value.length;
if (totalSize > maxSize) {
reader.cancel();
throw new Error('Request body too large');
}
chunks.push(value);
}
// Combine chunks
const result = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result.buffer;
}Array/String Limits
const ItemsSchema = z.object({
items: z.array(z.string().max(100)) // Each string max 100 chars
.min(1)
.max(1000), // Max 1000 items
});
const TextSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1).max(50000), // 50KB of text
});Type Coercion
Safe Type Conversion
// With Zod
const NumberSchema = z.coerce.number(); // Converts string to number
const BooleanSchema = z.coerce.boolean(); // Converts to boolean
// Manual safe conversion
function safeInt(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed.toString() === value.trim()) {
return parsed;
}
}
return null;
}
function safeBoolean(value: unknown): boolean | null {
if (typeof value === 'boolean') return value;
if (value === 'true' || value === '1') return true;
if (value === 'false' || value === '0') return false;
return null;
}Content Type Validation
function validateContentType(
request: Request,
expected: string | string[]
): boolean {
const contentType = request.headers.get('Content-Type');
if (!contentType) return false;
const types = Array.isArray(expected) ? expected : [expected];
return types.some(type => contentType.startsWith(type));
}
// Usage
app.post('/api/data', async (c) => {
if (!validateContentType(c.req.raw, 'application/json')) {
return c.json({ error: 'Content-Type must be application/json' }, 415);
}
// Process JSON
});
app.post('/api/upload', async (c) => {
if (!validateContentType(c.req.raw, ['image/png', 'image/jpeg'])) {
return c.json({ error: 'Only PNG and JPEG images allowed' }, 415);
}
// Process image
});Error Handling
Consistent Error Responses
interface ValidationError {
field: string;
message: string;
code: string;
}
function createValidationError(errors: ValidationError[]): Response {
return Response.json(
{
error: 'Validation failed',
code: 'VALIDATION_ERROR',
details: errors,
},
{ status: 400 }
);
}
// From Zod errors
function zodToValidationErrors(error: z.ZodError): ValidationError[] {
return error.issues.map(issue => ({
field: issue.path.join('.'),
message: issue.message,
code: issue.code,
}));
}Complete Validation Middleware
import { z } from 'zod';
import { Hono } from 'hono';
interface ValidatorOptions {
body?: z.ZodType;
query?: z.ZodType;
params?: z.ZodType;
}
function validate(options: ValidatorOptions) {
return async (c: Context, next: Next) => {
const errors: { location: string; errors: z.ZodIssue[] }[] = [];
// Validate body
if (options.body) {
try {
const body = await c.req.json();
c.set('body', options.body.parse(body));
} catch (error) {
if (error instanceof z.ZodError) {
errors.push({ location: 'body', errors: error.issues });
}
}
}
// Validate query
if (options.query) {
try {
const url = new URL(c.req.url);
const params = Object.fromEntries(url.searchParams);
c.set('query', options.query.parse(params));
} catch (error) {
if (error instanceof z.ZodError) {
errors.push({ location: 'query', errors: error.issues });
}
}
}
// Validate params
if (options.params) {
try {
c.set('params', options.params.parse(c.req.param()));
} catch (error) {
if (error instanceof z.ZodError) {
errors.push({ location: 'params', errors: error.issues });
}
}
}
if (errors.length > 0) {
return c.json(
{ error: 'Validation failed', details: errors },
400
);
}
await next();
};
}
// Usage
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});
app.post('/users', validate({ body: CreateUserSchema }), async (c) => {
const body = c.get('body') as z.infer<typeof CreateUserSchema>;
// body is typed and validated
});Rate Limiting for Cloudflare Workers
Comprehensive guide to implementing rate limiting for API protection.
Rate Limiting Strategies
| Strategy | Best For | Complexity |
|---|---|---|
| Fixed Window | Simple APIs | Low |
| Sliding Window | Precise control | Medium |
| Token Bucket | Burst handling | Medium |
| Leaky Bucket | Smooth rate | Medium |
| Cloudflare Rate Limiting | Production | Low (managed) |
Fixed Window Rate Limiting
Basic Implementation with KV
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
retryAfter?: number;
}
async function checkRateLimit(
kv: KVNamespace,
key: string,
limit: number,
windowSeconds: number
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = Math.floor(now / (windowSeconds * 1000)) * (windowSeconds * 1000);
const windowEnd = windowStart + windowSeconds * 1000;
const kvKey = `ratelimit:${key}:${windowStart}`;
// Get current count
const countStr = await kv.get(kvKey);
const count = countStr ? parseInt(countStr, 10) : 0;
if (count >= limit) {
return {
allowed: false,
remaining: 0,
resetAt: windowEnd,
retryAfter: Math.ceil((windowEnd - now) / 1000),
};
}
// Increment count
await kv.put(kvKey, (count + 1).toString(), {
expirationTtl: windowSeconds,
});
return {
allowed: true,
remaining: limit - count - 1,
resetAt: windowEnd,
};
}Response Headers
function addRateLimitHeaders(
response: Response,
result: RateLimitResult,
limit: number
): Response {
const newResponse = new Response(response.body, response);
newResponse.headers.set('X-RateLimit-Limit', limit.toString());
newResponse.headers.set('X-RateLimit-Remaining', result.remaining.toString());
newResponse.headers.set('X-RateLimit-Reset', result.resetAt.toString());
if (!result.allowed && result.retryAfter) {
newResponse.headers.set('Retry-After', result.retryAfter.toString());
}
return newResponse;
}Sliding Window Rate Limiting
interface SlidingWindowResult {
allowed: boolean;
remaining: number;
windowStart: number;
}
async function slidingWindowRateLimit(
kv: KVNamespace,
key: string,
limit: number,
windowSeconds: number
): Promise<SlidingWindowResult> {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const windowStart = now - windowMs;
const kvKey = `ratelimit:sliding:${key}`;
// Get timestamps of recent requests
const data = await kv.get<number[]>(kvKey, 'json') || [];
// Filter to only requests in current window
const recentRequests = data.filter(ts => ts > windowStart);
if (recentRequests.length >= limit) {
return {
allowed: false,
remaining: 0,
windowStart,
};
}
// Add current request
recentRequests.push(now);
// Store updated list
await kv.put(kvKey, JSON.stringify(recentRequests), {
expirationTtl: windowSeconds,
});
return {
allowed: true,
remaining: limit - recentRequests.length,
windowStart,
};
}Token Bucket Rate Limiting
interface TokenBucket {
tokens: number;
lastRefill: number;
}
interface TokenBucketConfig {
capacity: number; // Max tokens
refillRate: number; // Tokens per second
}
async function tokenBucketRateLimit(
kv: KVNamespace,
key: string,
config: TokenBucketConfig,
tokensRequested = 1
): Promise<RateLimitResult> {
const now = Date.now();
const kvKey = `bucket:${key}`;
// Get or initialize bucket
let bucket = await kv.get<TokenBucket>(kvKey, 'json');
if (!bucket) {
bucket = {
tokens: config.capacity,
lastRefill: now,
};
}
// Calculate tokens to add since last refill
const timePassed = (now - bucket.lastRefill) / 1000;
const tokensToAdd = timePassed * config.refillRate;
bucket.tokens = Math.min(config.capacity, bucket.tokens + tokensToAdd);
bucket.lastRefill = now;
// Check if we have enough tokens
if (bucket.tokens < tokensRequested) {
// Calculate when tokens will be available
const tokensNeeded = tokensRequested - bucket.tokens;
const waitTime = tokensNeeded / config.refillRate;
await kv.put(kvKey, JSON.stringify(bucket), { expirationTtl: 3600 });
return {
allowed: false,
remaining: Math.floor(bucket.tokens),
resetAt: now + waitTime * 1000,
retryAfter: Math.ceil(waitTime),
};
}
// Consume tokens
bucket.tokens -= tokensRequested;
await kv.put(kvKey, JSON.stringify(bucket), { expirationTtl: 3600 });
return {
allowed: true,
remaining: Math.floor(bucket.tokens),
resetAt: now + (config.capacity - bucket.tokens) / config.refillRate * 1000,
};
}Rate Limiting with Durable Objects
Rate Limiter Durable Object
interface RateLimiterState {
requests: number[];
}
export class RateLimiter {
private state: DurableObjectState;
private requests: number[] = [];
private limit: number = 100;
private windowMs: number = 60000; // 1 minute
constructor(state: DurableObjectState) {
this.state = state;
state.blockConcurrencyWhile(async () => {
const stored = await state.storage.get<number[]>('requests');
if (stored) {
this.requests = stored;
}
});
}
async fetch(request: Request): Promise<Response> {
const now = Date.now();
const windowStart = now - this.windowMs;
// Clean old requests
this.requests = this.requests.filter(ts => ts > windowStart);
if (this.requests.length >= this.limit) {
const oldestRequest = Math.min(...this.requests);
const resetAt = oldestRequest + this.windowMs;
return Response.json({
allowed: false,
remaining: 0,
resetAt,
retryAfter: Math.ceil((resetAt - now) / 1000),
}, { status: 429 });
}
// Add request
this.requests.push(now);
await this.state.storage.put('requests', this.requests);
return Response.json({
allowed: true,
remaining: this.limit - this.requests.length,
resetAt: now + this.windowMs,
});
}
}
// Worker using DO rate limiter
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const id = env.RATE_LIMITER.idFromName(clientIP);
const limiter = env.RATE_LIMITER.get(id);
const result = await limiter.fetch(new Request('http://internal/check'));
const data = await result.json();
if (!data.allowed) {
return new Response('Too Many Requests', {
status: 429,
headers: {
'Retry-After': data.retryAfter.toString(),
'X-RateLimit-Remaining': '0',
},
});
}
// Continue with request handling
return handleRequest(request, env);
}
};Multi-Tier Rate Limiting
interface RateLimitTier {
name: string;
windowSeconds: number;
limit: number;
}
const tiers: RateLimitTier[] = [
{ name: 'second', windowSeconds: 1, limit: 10 },
{ name: 'minute', windowSeconds: 60, limit: 100 },
{ name: 'hour', windowSeconds: 3600, limit: 1000 },
];
async function multiTierRateLimit(
kv: KVNamespace,
key: string
): Promise<RateLimitResult> {
for (const tier of tiers) {
const result = await checkRateLimit(kv, `${key}:${tier.name}`, tier.limit, tier.windowSeconds);
if (!result.allowed) {
return {
...result,
tier: tier.name,
} as RateLimitResult & { tier: string };
}
}
return {
allowed: true,
remaining: -1, // Multiple limits, don't show single value
resetAt: Date.now() + tiers[0].windowSeconds * 1000,
};
}Per-User Rate Limiting
interface UserRateLimits {
free: { requests: number; windowSeconds: number };
pro: { requests: number; windowSeconds: number };
enterprise: { requests: number; windowSeconds: number };
}
const userLimits: UserRateLimits = {
free: { requests: 100, windowSeconds: 3600 },
pro: { requests: 1000, windowSeconds: 3600 },
enterprise: { requests: 10000, windowSeconds: 3600 },
};
async function userRateLimit(
kv: KVNamespace,
userId: string,
userTier: keyof UserRateLimits
): Promise<RateLimitResult> {
const limits = userLimits[userTier];
return checkRateLimit(kv, `user:${userId}`, limits.requests, limits.windowSeconds);
}Rate Limit Middleware
interface RateLimitOptions {
limit: number;
windowSeconds: number;
keyGenerator?: (request: Request) => string;
skipIf?: (request: Request) => boolean;
}
function createRateLimiter(kv: KVNamespace, options: RateLimitOptions) {
const {
limit,
windowSeconds,
keyGenerator = (req) => req.headers.get('CF-Connecting-IP') || 'unknown',
skipIf = () => false,
} = options;
return async (request: Request, next: () => Promise<Response>): Promise<Response> => {
// Skip rate limiting for certain requests
if (skipIf(request)) {
return next();
}
const key = keyGenerator(request);
const result = await checkRateLimit(kv, key, limit, windowSeconds);
if (!result.allowed) {
return new Response(
JSON.stringify({
error: 'Too Many Requests',
retryAfter: result.retryAfter,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': result.retryAfter?.toString() || '60',
'X-RateLimit-Limit': limit.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': result.resetAt.toString(),
},
}
);
}
const response = await next();
return addRateLimitHeaders(response, result, limit);
};
}
// Usage with Hono
import { Hono } from 'hono';
const app = new Hono<{ Bindings: Env }>();
app.use('/api/*', async (c, next) => {
const rateLimiter = createRateLimiter(c.env.KV, {
limit: 100,
windowSeconds: 60,
keyGenerator: (req) => {
// Use API key if present, otherwise IP
return req.headers.get('X-API-Key') ||
req.headers.get('CF-Connecting-IP') ||
'anonymous';
},
skipIf: (req) => {
// Skip rate limiting for health checks
return new URL(req.url).pathname === '/api/health';
},
});
return rateLimiter(c.req.raw, () => next());
});Cloudflare Rate Limiting API
interface RateLimitBinding {
limit: (options: { key: string }) => Promise<{ success: boolean }>;
}
interface Env {
MY_RATE_LIMITER: RateLimitBinding;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const clientIP = request.headers.get('CF-Connecting-IP') || 'unknown';
const { success } = await env.MY_RATE_LIMITER.limit({
key: clientIP,
});
if (!success) {
return new Response('Rate limit exceeded', { status: 429 });
}
return handleRequest(request);
}
};// wrangler.jsonc
{
"rate_limits": [
{
"binding": "MY_RATE_LIMITER",
"namespace_id": "xxx",
"simple": {
"limit": 100,
"period": 60
}
}
]
}Secrets Management for Cloudflare Workers
Best practices for handling sensitive data in Workers.
Secrets Overview
| Type | Storage | Access | Rotation |
|---|---|---|---|
| Wrangler Secrets | Encrypted, per-worker | env.SECRET_NAME | Manual via CLI |
| Environment Variables | wrangler.jsonc | env.VAR_NAME | Redeploy |
| KV | Encrypted at rest | env.KV.get() | Application-managed |
| D1 | Encrypted at rest | SQL queries | Application-managed |
Wrangler Secrets
Setting Secrets
# Set secret (interactive - paste value when prompted)
bunx wrangler secret put API_KEY
# Set secret from file
cat api-key.txt | bunx wrangler secret put API_KEY
# Set for specific environment
bunx wrangler secret put API_KEY --env production
# List secrets (shows names only, not values)
bunx wrangler secret list
# Delete secret
bunx wrangler secret delete API_KEYBulk Secrets (CI/CD)
# Set multiple secrets from env file
while IFS='=' read -r key value; do
echo "$value" | bunx wrangler secret put "$key"
done < secrets.env
# Or using jq from JSON
jq -r 'to_entries[] | "\(.key)=\(.value)"' secrets.json | while IFS='=' read -r key value; do
echo "$value" | bunx wrangler secret put "$key"
doneAccessing Secrets
interface Env {
API_KEY: string;
DATABASE_URL: string;
JWT_SECRET: string;
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Secrets available on env object
const apiKey = env.API_KEY;
// Never log secrets!
// console.log(env.API_KEY); // ❌ NEVER
return new Response('OK');
}
};Local Development Secrets
.dev.vars File
# .dev.vars (MUST be gitignored!)
API_KEY=dev-api-key-for-local
DATABASE_URL=postgres://localhost:5432/dev
JWT_SECRET=local-dev-secret-not-for-production
STRIPE_KEY=sk_test_xxxxx.gitignore Setup
# Secrets
.dev.vars
*.env
secrets.json
*.pem
*.keySecret Rotation
Rotation Strategy
interface SecretVersion {
value: string;
createdAt: number;
expiresAt: number;
}
async function getActiveSecret(
kv: KVNamespace,
secretName: string
): Promise<string> {
const versions = await kv.get<SecretVersion[]>(`secrets:${secretName}`, 'json');
if (!versions || versions.length === 0) {
throw new Error(`Secret ${secretName} not found`);
}
const now = Date.now();
// Find first non-expired version
const active = versions.find(v => v.expiresAt > now);
if (!active) {
throw new Error(`Secret ${secretName} expired`);
}
return active.value;
}
// Verify with both old and new during rotation
async function verifyWithRotation(
kv: KVNamespace,
token: string
): Promise<boolean> {
const versions = await kv.get<SecretVersion[]>('secrets:jwt', 'json') || [];
const now = Date.now();
// Try each non-expired secret
for (const version of versions) {
if (version.expiresAt > now) {
const result = await verifyJWT(token, version.value);
if (result.valid) return true;
}
}
return false;
}Automated Rotation
async function rotateApiKey(
kv: KVNamespace,
clientId: string
): Promise<{ newKey: string; oldKeyValidUntil: number }> {
const now = Date.now();
const gracePeriod = 24 * 60 * 60 * 1000; // 24 hours
// Generate new key
const newKey = await generateApiKey();
const newHash = await sha256(newKey);
// Get current keys
const keys = await kv.get<Array<{ hash: string; expiresAt: number }>>(
`client:${clientId}:keys`,
'json'
) || [];
// Add new key, expire old ones
const updatedKeys = [
{ hash: newHash, expiresAt: now + 365 * 24 * 60 * 60 * 1000 }, // 1 year
...keys.map(k => ({
hash: k.hash,
expiresAt: Math.min(k.expiresAt, now + gracePeriod),
})),
];
// Filter expired
const activeKeys = updatedKeys.filter(k => k.expiresAt > now);
await kv.put(`client:${clientId}:keys`, JSON.stringify(activeKeys));
return {
newKey,
oldKeyValidUntil: now + gracePeriod,
};
}Encryption at Application Level
Encrypt Sensitive Data
async function encryptData(
data: string,
key: CryptoKey
): Promise<{ encrypted: string; iv: string }> {
const iv = crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(data);
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
);
return {
encrypted: btoa(String.fromCharCode(...new Uint8Array(encrypted))),
iv: btoa(String.fromCharCode(...iv)),
};
}
async function decryptData(
encrypted: string,
iv: string,
key: CryptoKey
): Promise<string> {
const encryptedData = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0));
const ivData = Uint8Array.from(atob(iv), c => c.charCodeAt(0));
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: ivData },
key,
encryptedData
);
return new TextDecoder().decode(decrypted);
}
// Derive key from secret
async function deriveKey(secret: string): Promise<CryptoKey> {
const keyMaterial = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: new TextEncoder().encode('static-salt-for-demo'), // Use unique salt in production
iterations: 100000,
hash: 'SHA-256',
},
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
}Store Encrypted in D1
async function storeSecretValue(
db: D1Database,
encryptionKey: CryptoKey,
name: string,
value: string
): Promise<void> {
const { encrypted, iv } = await encryptData(value, encryptionKey);
await db
.prepare('INSERT INTO secrets (name, encrypted_value, iv) VALUES (?, ?, ?)')
.bind(name, encrypted, iv)
.run();
}
async function getSecretValue(
db: D1Database,
encryptionKey: CryptoKey,
name: string
): Promise<string | null> {
const result = await db
.prepare('SELECT encrypted_value, iv FROM secrets WHERE name = ?')
.bind(name)
.first<{ encrypted_value: string; iv: string }>();
if (!result) return null;
return decryptData(result.encrypted_value, result.iv, encryptionKey);
}Secret Access Patterns
Never Expose in Responses
// ❌ NEVER expose secrets in responses
app.get('/debug', (c) => {
return c.json({
config: c.env, // Exposes all secrets!
});
});
// ✅ Only expose safe values
app.get('/debug', (c) => {
return c.json({
environment: c.env.ENVIRONMENT,
version: c.env.VERSION,
// No secrets
});
});Mask in Logs
function maskSecret(secret: string): string {
if (secret.length <= 8) return '****';
return secret.slice(0, 4) + '****' + secret.slice(-4);
}
function safeLog(message: string, data: Record<string, unknown>): void {
const secretKeys = ['apiKey', 'secret', 'password', 'token', 'key'];
const sanitized = { ...data };
for (const key of secretKeys) {
if (key in sanitized && typeof sanitized[key] === 'string') {
sanitized[key] = maskSecret(sanitized[key] as string);
}
}
console.log(message, sanitized);
}Secure Comparison
// ❌ Vulnerable to timing attacks
function insecureCompare(a: string, b: string): boolean {
return a === b;
}
// ✅ Constant-time comparison
async function secureCompare(a: string, b: string): Promise<boolean> {
const encoder = new TextEncoder();
const aBytes = encoder.encode(a);
const bBytes = encoder.encode(b);
if (aBytes.length !== bBytes.length) {
// Hash to make timing consistent
await crypto.subtle.digest('SHA-256', aBytes);
await crypto.subtle.digest('SHA-256', bBytes);
return false;
}
const aHash = await crypto.subtle.digest('SHA-256', aBytes);
const bHash = await crypto.subtle.digest('SHA-256', bBytes);
const aArray = new Uint8Array(aHash);
const bArray = new Uint8Array(bHash);
let result = 0;
for (let i = 0; i < aArray.length; i++) {
result |= aArray[i] ^ bArray[i];
}
return result === 0;
}CI/CD Integration
GitHub Actions
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v1
- name: Deploy
run: bunx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- name: Set secrets
run: |
echo "${{ secrets.API_KEY }}" | bunx wrangler secret put API_KEY
echo "${{ secrets.JWT_SECRET }}" | bunx wrangler secret put JWT_SECRET
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}Secret Validation on Deploy
// src/validate-secrets.ts
interface Env {
API_KEY: string;
JWT_SECRET: string;
DATABASE_URL: string;
}
function validateSecrets(env: Env): void {
const required = ['API_KEY', 'JWT_SECRET', 'DATABASE_URL'];
for (const name of required) {
if (!env[name as keyof Env]) {
throw new Error(`Missing required secret: ${name}`);
}
}
// Validate format
if (!env.API_KEY.startsWith('sk_')) {
throw new Error('API_KEY must start with sk_');
}
if (env.JWT_SECRET.length < 32) {
throw new Error('JWT_SECRET must be at least 32 characters');
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// Validate on startup
validateSecrets(env);
// Continue with handler
return handleRequest(request, env);
}
};Security Headers for Cloudflare Workers
Comprehensive guide to HTTP security headers for protecting web applications.
Essential Security Headers
| Header | Purpose | Recommended Value |
|---|---|---|
| Content-Security-Policy | XSS prevention | Restrictive policy |
| X-Content-Type-Options | MIME sniffing prevention | nosniff |
| X-Frame-Options | Clickjacking prevention | DENY or SAMEORIGIN |
| X-XSS-Protection | Legacy XSS filter | 1; mode=block |
| Strict-Transport-Security | HTTPS enforcement | max-age=31536000 |
| Referrer-Policy | Referrer leakage | strict-origin-when-cross-origin |
| Permissions-Policy | Feature restrictions | As needed |
Complete Security Headers
function addSecurityHeaders(response: Response): Response {
const headers = new Headers(response.headers);
// Prevent MIME type sniffing
headers.set('X-Content-Type-Options', 'nosniff');
// Prevent clickjacking
headers.set('X-Frame-Options', 'DENY');
// Legacy XSS protection (for older browsers)
headers.set('X-XSS-Protection', '1; mode=block');
// Force HTTPS for 1 year, including subdomains
headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload');
// Control referrer information
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
// Content Security Policy
headers.set('Content-Security-Policy', buildCSP());
// Restrict browser features
headers.set('Permissions-Policy', buildPermissionsPolicy());
// Prevent information leakage
headers.delete('Server');
headers.delete('X-Powered-By');
return new Response(response.body, {
status: response.status,
headers,
});
}Content Security Policy (CSP)
Building CSP
interface CSPDirectives {
'default-src'?: string[];
'script-src'?: string[];
'style-src'?: string[];
'img-src'?: string[];
'font-src'?: string[];
'connect-src'?: string[];
'frame-src'?: string[];
'object-src'?: string[];
'media-src'?: string[];
'worker-src'?: string[];
'frame-ancestors'?: string[];
'form-action'?: string[];
'base-uri'?: string[];
'upgrade-insecure-requests'?: boolean;
'block-all-mixed-content'?: boolean;
}
function buildCSP(directives?: CSPDirectives): string {
const defaults: CSPDirectives = {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'"],
'img-src': ["'self'", 'data:', 'https:'],
'font-src': ["'self'"],
'connect-src': ["'self'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
'base-uri': ["'self'"],
'object-src': ["'none'"],
'upgrade-insecure-requests': true,
};
const merged = { ...defaults, ...directives };
const parts: string[] = [];
for (const [directive, value] of Object.entries(merged)) {
if (value === true) {
parts.push(directive);
} else if (Array.isArray(value) && value.length > 0) {
parts.push(`${directive} ${value.join(' ')}`);
}
}
return parts.join('; ');
}CSP for APIs
// Strict CSP for API responses
function buildApiCSP(): string {
return buildCSP({
'default-src': ["'none'"],
'frame-ancestors': ["'none'"],
'form-action': ["'none'"],
});
}CSP for Web Apps
// CSP for web applications with CDN assets
function buildWebAppCSP(): string {
return buildCSP({
'default-src': ["'self'"],
'script-src': ["'self'", 'https://cdn.example.com'],
'style-src': ["'self'", 'https://cdn.example.com', "'unsafe-inline'"], // unsafe-inline for styled-components
'img-src': ["'self'", 'data:', 'https:', 'blob:'],
'font-src': ["'self'", 'https://fonts.gstatic.com'],
'connect-src': ["'self'", 'https://api.example.com', 'wss://ws.example.com'],
'frame-ancestors': ["'self'"],
'upgrade-insecure-requests': true,
});
}CSP with Nonces
function generateNonce(): string {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array));
}
function buildCSPWithNonce(nonce: string): string {
return buildCSP({
'script-src': ["'self'", `'nonce-${nonce}'`],
'style-src': ["'self'", `'nonce-${nonce}'`],
});
}
// Usage in HTML response
function createHTMLResponse(body: string): Response {
const nonce = generateNonce();
const csp = buildCSPWithNonce(nonce);
// Inject nonce into script tags
const html = body.replace(/<script/g, `<script nonce="${nonce}"`);
return new Response(html, {
headers: {
'Content-Type': 'text/html',
'Content-Security-Policy': csp,
},
});
}CSP Reporting
function buildCSPWithReporting(reportUri: string): string {
const policy = buildCSP();
return `${policy}; report-uri ${reportUri}`;
}
// Report-only mode for testing
function buildReportOnlyCSP(reportUri: string): Headers {
const headers = new Headers();
const policy = buildCSP() + `; report-uri ${reportUri}`;
headers.set('Content-Security-Policy-Report-Only', policy);
return headers;
}
// CSP violation handler
app.post('/csp-report', async (c) => {
const report = await c.req.json();
console.log('CSP Violation:', {
blockedUri: report['csp-report']?.['blocked-uri'],
violatedDirective: report['csp-report']?.['violated-directive'],
documentUri: report['csp-report']?.['document-uri'],
});
return c.text('OK');
});Permissions-Policy
Building Permissions Policy
interface PermissionsPolicyDirectives {
accelerometer?: string[];
camera?: string[];
geolocation?: string[];
gyroscope?: string[];
magnetometer?: string[];
microphone?: string[];
payment?: string[];
usb?: string[];
fullscreen?: string[];
'picture-in-picture'?: string[];
}
function buildPermissionsPolicy(directives?: PermissionsPolicyDirectives): string {
const defaults: PermissionsPolicyDirectives = {
accelerometer: [],
camera: [],
geolocation: [],
gyroscope: [],
magnetometer: [],
microphone: [],
payment: [],
usb: [],
};
const merged = { ...defaults, ...directives };
const parts: string[] = [];
for (const [feature, origins] of Object.entries(merged)) {
if (origins.length === 0) {
parts.push(`${feature}=()`); // Disable feature
} else if (origins.includes('*')) {
parts.push(`${feature}=*`); // Allow all
} else {
parts.push(`${feature}=(${origins.join(' ')})`);
}
}
return parts.join(', ');
}
// Allow camera only for specific domain
const policy = buildPermissionsPolicy({
camera: ['self', 'https://video.example.com'],
geolocation: ['self'],
});Strict-Transport-Security (HSTS)
interface HSTSOptions {
maxAge?: number;
includeSubDomains?: boolean;
preload?: boolean;
}
function buildHSTS(options: HSTSOptions = {}): string {
const { maxAge = 31536000, includeSubDomains = true, preload = false } = options;
let value = `max-age=${maxAge}`;
if (includeSubDomains) {
value += '; includeSubDomains';
}
if (preload) {
value += '; preload';
}
return value;
}Security Headers Middleware
interface SecurityHeadersOptions {
csp?: CSPDirectives;
hsts?: HSTSOptions;
permissionsPolicy?: PermissionsPolicyDirectives;
xFrameOptions?: 'DENY' | 'SAMEORIGIN' | string;
referrerPolicy?: string;
}
function securityHeaders(options: SecurityHeadersOptions = {}) {
return async (c: Context, next: Next) => {
await next();
const response = c.res;
const headers = new Headers(response.headers);
// X-Content-Type-Options
headers.set('X-Content-Type-Options', 'nosniff');
// X-Frame-Options
headers.set('X-Frame-Options', options.xFrameOptions || 'DENY');
// X-XSS-Protection
headers.set('X-XSS-Protection', '1; mode=block');
// HSTS
headers.set('Strict-Transport-Security', buildHSTS(options.hsts));
// Referrer-Policy
headers.set(
'Referrer-Policy',
options.referrerPolicy || 'strict-origin-when-cross-origin'
);
// CSP
if (options.csp) {
headers.set('Content-Security-Policy', buildCSP(options.csp));
}
// Permissions-Policy
if (options.permissionsPolicy) {
headers.set('Permissions-Policy', buildPermissionsPolicy(options.permissionsPolicy));
}
// Remove server information
headers.delete('Server');
headers.delete('X-Powered-By');
c.res = new Response(response.body, {
status: response.status,
headers,
});
};
}
// Usage
const app = new Hono();
app.use('*', securityHeaders({
csp: {
'default-src': ["'self'"],
'script-src': ["'self'", 'https://cdn.example.com'],
},
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true,
},
permissionsPolicy: {
camera: [],
microphone: [],
},
}));Environment-Specific Headers
function getSecurityHeaders(env: Env): SecurityHeadersOptions {
const baseOptions: SecurityHeadersOptions = {
hsts: { maxAge: 31536000, includeSubDomains: true },
xFrameOptions: 'DENY',
referrerPolicy: 'strict-origin-when-cross-origin',
};
if (env.ENVIRONMENT === 'development') {
return {
...baseOptions,
csp: {
'default-src': ["'self'", 'http://localhost:*'],
'script-src': ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // For HMR
'connect-src': ["'self'", 'ws://localhost:*'], // For HMR websocket
},
};
}
return {
...baseOptions,
csp: {
'default-src': ["'self'"],
'script-src': ["'self'"],
'style-src': ["'self'"],
'img-src': ["'self'", 'https:', 'data:'],
'connect-src': ["'self'", 'https://api.example.com'],
'frame-ancestors': ["'none'"],
'upgrade-insecure-requests': true,
},
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
};
}Testing Security Headers
# Check headers with curl
curl -I https://example.com
# Test with securityheaders.com
# https://securityheaders.com/?q=example.com
# Test CSP with browser DevTools
# Open Network tab, look for CSP violations in Console// Automated testing
describe('Security Headers', () => {
it('sets required security headers', async () => {
const response = await fetch(url);
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
expect(response.headers.get('Strict-Transport-Security')).toContain('max-age=');
expect(response.headers.get('Content-Security-Policy')).toBeTruthy();
});
it('removes server information headers', async () => {
const response = await fetch(url);
expect(response.headers.get('Server')).toBeNull();
expect(response.headers.get('X-Powered-By')).toBeNull();
});
});#!/bin/bash
# Security Audit Script for Cloudflare Workers
#
# Features:
# - Security headers check
# - CORS configuration test
# - Rate limiting verification
# - SSL/TLS validation
# - Content Security Policy analysis
# - HSTS verification
#
# Usage:
# ./security-audit.sh <url>
#
# Examples:
# ./security-audit.sh https://api.example.com
# ./security-audit.sh https://api.example.com --verbose
set -e
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# Configuration
URL=""
VERBOSE=false
OUTPUT_FILE=""
# Counters
PASS=0
FAIL=0
WARN=0
# Logging
info() { echo -e "${BLUE}[INFO]${NC} $1"; }
pass() { echo -e "${GREEN}[PASS]${NC} $1"; ((PASS++)); }
fail() { echo -e "${RED}[FAIL]${NC} $1"; ((FAIL++)); }
warn() { echo -e "${YELLOW}[WARN]${NC} $1"; ((WARN++)); }
debug() { [ "$VERBOSE" = true ] && echo -e "${BLUE}[DEBUG]${NC} $1"; }
# Usage
usage() {
cat << EOF
Security Audit Script for Cloudflare Workers
Usage: $0 <url> [options]
Options:
-v, --verbose Verbose output
-o, --output FILE Save results to file
-h, --help Show this help
Examples:
$0 https://api.example.com
$0 https://api.example.com --verbose
$0 https://api.example.com -o audit-results.txt
EOF
exit 0
}
# Parse arguments
parse_args() {
if [ $# -eq 0 ]; then
usage
fi
URL="$1"
shift
while [ $# -gt 0 ]; do
case "$1" in
-v|--verbose)
VERBOSE=true
shift
;;
-o|--output)
OUTPUT_FILE="$2"
shift 2
;;
-h|--help)
usage
;;
*)
echo "Unknown option: $1"
exit 1
;;
esac
done
# Validate URL
if ! [[ "$URL" =~ ^https?:// ]]; then
echo "Error: Invalid URL. Must start with http:// or https://"
exit 1
fi
}
# Check dependencies
check_deps() {
for cmd in curl jq; do
if ! command -v "$cmd" &> /dev/null; then
echo "Error: $cmd is required"
exit 1
fi
done
}
# Fetch headers
fetch_headers() {
local url="$1"
curl -sI -X GET --max-time 30 "$url" 2>/dev/null
}
# Get specific header
get_header() {
local headers="$1"
local name="$2"
echo "$headers" | grep -i "^$name:" | sed "s/^$name: *//i" | tr -d '\r'
}
# ============================================
# SECURITY CHECKS
# ============================================
check_https() {
echo ""
echo "=== HTTPS Check ==="
if [[ "$URL" =~ ^https:// ]]; then
pass "URL uses HTTPS"
else
fail "URL does not use HTTPS"
fi
}
check_security_headers() {
local headers="$1"
echo ""
echo "=== Security Headers ==="
# X-Content-Type-Options
local xcto=$(get_header "$headers" "X-Content-Type-Options")
if [ "$xcto" = "nosniff" ]; then
pass "X-Content-Type-Options: nosniff"
elif [ -n "$xcto" ]; then
warn "X-Content-Type-Options: $xcto (expected: nosniff)"
else
fail "X-Content-Type-Options header missing"
fi
# X-Frame-Options
local xfo=$(get_header "$headers" "X-Frame-Options")
if [ "$xfo" = "DENY" ] || [ "$xfo" = "SAMEORIGIN" ]; then
pass "X-Frame-Options: $xfo"
elif [ -n "$xfo" ]; then
warn "X-Frame-Options: $xfo (expected: DENY or SAMEORIGIN)"
else
fail "X-Frame-Options header missing"
fi
# X-XSS-Protection
local xxss=$(get_header "$headers" "X-XSS-Protection")
if [[ "$xxss" == *"1"* ]] && [[ "$xxss" == *"mode=block"* ]]; then
pass "X-XSS-Protection: $xxss"
elif [ -n "$xxss" ]; then
warn "X-XSS-Protection: $xxss (expected: 1; mode=block)"
else
warn "X-XSS-Protection header missing (deprecated but still recommended)"
fi
# Referrer-Policy
local rp=$(get_header "$headers" "Referrer-Policy")
if [ -n "$rp" ]; then
if [[ "$rp" == *"strict-origin"* ]] || [[ "$rp" == *"no-referrer"* ]]; then
pass "Referrer-Policy: $rp"
else
warn "Referrer-Policy: $rp (consider strict-origin-when-cross-origin)"
fi
else
fail "Referrer-Policy header missing"
fi
}
check_hsts() {
local headers="$1"
echo ""
echo "=== HSTS Check ==="
local hsts=$(get_header "$headers" "Strict-Transport-Security")
if [ -n "$hsts" ]; then
# Check max-age
if [[ "$hsts" =~ max-age=([0-9]+) ]]; then
local max_age="${BASH_REMATCH[1]}"
if [ "$max_age" -ge 31536000 ]; then
pass "HSTS max-age: $max_age (>= 1 year)"
elif [ "$max_age" -ge 2592000 ]; then
warn "HSTS max-age: $max_age (consider >= 1 year)"
else
fail "HSTS max-age: $max_age (too short, minimum 30 days recommended)"
fi
fi
# Check includeSubDomains
if [[ "$hsts" == *"includeSubDomains"* ]]; then
pass "HSTS includeSubDomains present"
else
warn "HSTS missing includeSubDomains"
fi
# Check preload
if [[ "$hsts" == *"preload"* ]]; then
pass "HSTS preload present"
else
info "HSTS preload not present (optional)"
fi
else
fail "Strict-Transport-Security header missing"
fi
}
check_csp() {
local headers="$1"
echo ""
echo "=== Content Security Policy ==="
local csp=$(get_header "$headers" "Content-Security-Policy")
if [ -n "$csp" ]; then
pass "Content-Security-Policy present"
debug "CSP: $csp"
# Check for unsafe directives
if [[ "$csp" == *"'unsafe-inline'"* ]]; then
warn "CSP contains 'unsafe-inline' (security risk)"
fi
if [[ "$csp" == *"'unsafe-eval'"* ]]; then
warn "CSP contains 'unsafe-eval' (security risk)"
fi
# Check for default-src
if [[ "$csp" == *"default-src"* ]]; then
pass "CSP has default-src directive"
else
warn "CSP missing default-src directive"
fi
# Check for frame-ancestors
if [[ "$csp" == *"frame-ancestors"* ]]; then
pass "CSP has frame-ancestors directive"
else
warn "CSP missing frame-ancestors directive"
fi
else
# Check for CSP Report-Only
local csp_ro=$(get_header "$headers" "Content-Security-Policy-Report-Only")
if [ -n "$csp_ro" ]; then
warn "Only Content-Security-Policy-Report-Only present (not enforced)"
else
fail "Content-Security-Policy header missing"
fi
fi
}
check_permissions_policy() {
local headers="$1"
echo ""
echo "=== Permissions Policy ==="
local pp=$(get_header "$headers" "Permissions-Policy")
if [ -n "$pp" ]; then
pass "Permissions-Policy present"
debug "Permissions-Policy: $pp"
# Check for sensitive features
if [[ "$pp" == *"camera=()"* ]]; then
pass "Camera disabled"
fi
if [[ "$pp" == *"microphone=()"* ]]; then
pass "Microphone disabled"
fi
if [[ "$pp" == *"geolocation=()"* ]]; then
pass "Geolocation disabled"
fi
else
warn "Permissions-Policy header missing"
fi
}
check_cors() {
local headers="$1"
echo ""
echo "=== CORS Check ==="
# Make request with Origin header
local cors_headers=$(curl -sI -X OPTIONS \
-H "Origin: https://evil.com" \
-H "Access-Control-Request-Method: POST" \
--max-time 30 "$URL" 2>/dev/null)
local acao=$(get_header "$cors_headers" "Access-Control-Allow-Origin")
if [ "$acao" = "*" ]; then
warn "CORS allows all origins (*) - may be insecure"
elif [ "$acao" = "https://evil.com" ]; then
fail "CORS reflects arbitrary origin - VULNERABLE"
elif [ -n "$acao" ]; then
pass "CORS origin restricted: $acao"
else
pass "CORS not enabled for unauthorized origins"
fi
local acac=$(get_header "$cors_headers" "Access-Control-Allow-Credentials")
if [ "$acac" = "true" ] && [ "$acao" = "*" ]; then
fail "CORS allows credentials with wildcard origin - VULNERABLE"
fi
}
check_server_info() {
local headers="$1"
echo ""
echo "=== Server Information Disclosure ==="
local server=$(get_header "$headers" "Server")
if [ -n "$server" ]; then
warn "Server header present: $server (information disclosure)"
else
pass "Server header not present"
fi
local powered_by=$(get_header "$headers" "X-Powered-By")
if [ -n "$powered_by" ]; then
warn "X-Powered-By header present: $powered_by (information disclosure)"
else
pass "X-Powered-By header not present"
fi
}
check_rate_limiting() {
echo ""
echo "=== Rate Limiting Check ==="
# Check for rate limit headers
local headers=$(fetch_headers "$URL")
local rl_limit=$(get_header "$headers" "X-RateLimit-Limit")
local rl_remaining=$(get_header "$headers" "X-RateLimit-Remaining")
local retry_after=$(get_header "$headers" "Retry-After")
if [ -n "$rl_limit" ] || [ -n "$rl_remaining" ]; then
pass "Rate limiting headers present"
debug "X-RateLimit-Limit: $rl_limit"
debug "X-RateLimit-Remaining: $rl_remaining"
else
warn "No rate limiting headers detected"
fi
}
check_cookies() {
local headers="$1"
echo ""
echo "=== Cookie Security ==="
local cookies=$(echo "$headers" | grep -i "^Set-Cookie:" || true)
if [ -n "$cookies" ]; then
while IFS= read -r cookie; do
local name=$(echo "$cookie" | sed 's/Set-Cookie: \([^=]*\)=.*/\1/i')
if [[ "$cookie" == *"Secure"* ]]; then
pass "Cookie '$name' has Secure flag"
else
fail "Cookie '$name' missing Secure flag"
fi
if [[ "$cookie" == *"HttpOnly"* ]]; then
pass "Cookie '$name' has HttpOnly flag"
else
warn "Cookie '$name' missing HttpOnly flag"
fi
if [[ "$cookie" == *"SameSite"* ]]; then
if [[ "$cookie" == *"SameSite=Strict"* ]]; then
pass "Cookie '$name' has SameSite=Strict"
elif [[ "$cookie" == *"SameSite=Lax"* ]]; then
pass "Cookie '$name' has SameSite=Lax"
else
warn "Cookie '$name' has SameSite=None (requires Secure)"
fi
else
warn "Cookie '$name' missing SameSite attribute"
fi
done <<< "$cookies"
else
info "No cookies set"
fi
}
# ============================================
# MAIN
# ============================================
print_summary() {
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ AUDIT SUMMARY ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo -e " ${GREEN}PASS:${NC} $PASS"
echo -e " ${YELLOW}WARN:${NC} $WARN"
echo -e " ${RED}FAIL:${NC} $FAIL"
echo ""
if [ $FAIL -gt 0 ]; then
echo -e "${RED}Security issues detected. Please review and fix.${NC}"
exit 1
elif [ $WARN -gt 0 ]; then
echo -e "${YELLOW}Some warnings found. Consider addressing them.${NC}"
exit 0
else
echo -e "${GREEN}All checks passed!${NC}"
exit 0
fi
}
main() {
parse_args "$@"
check_deps
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ CLOUDFLARE WORKERS SECURITY AUDIT ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo "Target: $URL"
echo "Date: $(date)"
# Fetch headers once
info "Fetching headers..."
HEADERS=$(fetch_headers "$URL")
if [ -z "$HEADERS" ]; then
fail "Could not fetch headers from $URL"
print_summary
exit 1
fi
debug "Headers:"
debug "$HEADERS"
# Run checks
check_https
check_security_headers "$HEADERS"
check_hsts "$HEADERS"
check_csp "$HEADERS"
check_permissions_policy "$HEADERS"
check_cors "$HEADERS"
check_server_info "$HEADERS"
check_rate_limiting
check_cookies "$HEADERS"
# Print summary
print_summary
}
# Capture output if output file specified
if [ -n "$OUTPUT_FILE" ]; then
main "$@" 2>&1 | tee "$OUTPUT_FILE"
else
main "$@"
fi
/**
* Authentication Middleware for Cloudflare Workers
*
* Features:
* - JWT verification (HS256, RS256)
* - API key validation
* - Session-based auth
* - Role-based access control
* - Token refresh
*
* Usage:
* 1. Configure auth strategy
* 2. Wrap handlers with middleware
* 3. Access user in handler
*/
// ============================================
// TYPES
// ============================================
interface Env {
JWT_SECRET: string;
KV: KVNamespace;
}
interface User {
id: string;
email: string;
role: 'user' | 'admin' | 'moderator';
permissions: string[];
}
interface JWTPayload {
sub: string;
email: string;
role: string;
permissions: string[];
iat: number;
exp: number;
}
interface AuthResult {
authenticated: boolean;
user?: User;
error?: string;
}
type Handler = (
request: Request,
env: Env,
ctx: ExecutionContext
) => Promise<Response>;
type AuthenticatedHandler = (
request: Request,
env: Env,
ctx: ExecutionContext,
user: User
) => Promise<Response>;
// ============================================
// JWT UTILITIES
// ============================================
function base64UrlEncode(str: string): string {
return btoa(str)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
function base64UrlDecode(str: string): Uint8Array {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const binary = atob(base64 + padding);
return Uint8Array.from(binary, (c) => c.charCodeAt(0));
}
async function createJWT(payload: Omit<JWTPayload, 'iat'>, secret: string): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT' };
const fullPayload: JWTPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
};
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(fullPayload));
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(`${encodedHeader}.${encodedPayload}`)
);
const encodedSignature = base64UrlEncode(
String.fromCharCode(...new Uint8Array(signature))
);
return `${encodedHeader}.${encodedPayload}.${encodedSignature}`;
}
async function verifyJWT(token: string, secret: string): Promise<AuthResult> {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return { authenticated: false, error: 'Invalid token format' };
}
const [headerB64, payloadB64, signatureB64] = parts;
// Verify signature
const key = await crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const signature = base64UrlDecode(signatureB64);
const data = new TextEncoder().encode(`${headerB64}.${payloadB64}`);
const valid = await crypto.subtle.verify('HMAC', key, signature, data);
if (!valid) {
return { authenticated: false, error: 'Invalid signature' };
}
// Decode payload
const payloadJson = new TextDecoder().decode(base64UrlDecode(payloadB64));
const payload = JSON.parse(payloadJson) as JWTPayload;
// Check expiration
if (payload.exp && Date.now() / 1000 > payload.exp) {
return { authenticated: false, error: 'Token expired' };
}
// Build user object
const user: User = {
id: payload.sub,
email: payload.email,
role: payload.role as User['role'],
permissions: payload.permissions || [],
};
return { authenticated: true, user };
} catch (error) {
return { authenticated: false, error: (error as Error).message };
}
}
// ============================================
// API KEY UTILITIES
// ============================================
async function sha256(str: string): Promise<string> {
const buffer = await crypto.subtle.digest(
'SHA-256',
new TextEncoder().encode(str)
);
return [...new Uint8Array(buffer)]
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
async function validateApiKey(
apiKey: string,
kv: KVNamespace
): Promise<AuthResult> {
if (!apiKey) {
return { authenticated: false, error: 'Missing API key' };
}
// Validate format
if (!apiKey.startsWith('sk_')) {
return { authenticated: false, error: 'Invalid API key format' };
}
// Hash for lookup
const keyHash = await sha256(apiKey);
// Lookup in KV
const clientData = await kv.get<{
userId: string;
email: string;
role: User['role'];
permissions: string[];
rateLimit: number;
}>(`apikey:${keyHash}`, 'json');
if (!clientData) {
return { authenticated: false, error: 'Invalid API key' };
}
const user: User = {
id: clientData.userId,
email: clientData.email,
role: clientData.role,
permissions: clientData.permissions,
};
return { authenticated: true, user };
}
// ============================================
// AUTH MIDDLEWARE
// ============================================
/**
* JWT Bearer Token Authentication
*/
export function withJWTAuth(handler: AuthenticatedHandler): Handler {
return async (request, env, ctx) => {
const authHeader = request.headers.get('Authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return new Response(
JSON.stringify({ error: 'Missing or invalid authorization header' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const token = authHeader.slice(7);
const result = await verifyJWT(token, env.JWT_SECRET);
if (!result.authenticated || !result.user) {
return new Response(
JSON.stringify({ error: result.error || 'Authentication failed' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
return handler(request, env, ctx, result.user);
};
}
/**
* API Key Authentication
*/
export function withApiKeyAuth(handler: AuthenticatedHandler): Handler {
return async (request, env, ctx) => {
const apiKey = request.headers.get('X-API-Key');
if (!apiKey) {
return new Response(
JSON.stringify({ error: 'Missing X-API-Key header' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
const result = await validateApiKey(apiKey, env.KV);
if (!result.authenticated || !result.user) {
return new Response(
JSON.stringify({ error: result.error || 'Invalid API key' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
return handler(request, env, ctx, result.user);
};
}
/**
* Combined Auth (Bearer or API Key)
*/
export function withAuth(handler: AuthenticatedHandler): Handler {
return async (request, env, ctx) => {
const authHeader = request.headers.get('Authorization');
const apiKey = request.headers.get('X-API-Key');
let result: AuthResult;
if (authHeader?.startsWith('Bearer ')) {
result = await verifyJWT(authHeader.slice(7), env.JWT_SECRET);
} else if (apiKey) {
result = await validateApiKey(apiKey, env.KV);
} else {
return new Response(
JSON.stringify({
error: 'Missing authentication. Provide Bearer token or X-API-Key',
}),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
if (!result.authenticated || !result.user) {
return new Response(
JSON.stringify({ error: result.error || 'Authentication failed' }),
{ status: 401, headers: { 'Content-Type': 'application/json' } }
);
}
return handler(request, env, ctx, result.user);
};
}
// ============================================
// ROLE-BASED ACCESS CONTROL
// ============================================
/**
* Require specific role
*/
export function requireRole(
handler: AuthenticatedHandler,
allowedRoles: User['role'][]
): AuthenticatedHandler {
return async (request, env, ctx, user) => {
if (!allowedRoles.includes(user.role)) {
return new Response(
JSON.stringify({
error: 'Forbidden',
message: `Required role: ${allowedRoles.join(' or ')}`,
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
return handler(request, env, ctx, user);
};
}
/**
* Require specific permission
*/
export function requirePermission(
handler: AuthenticatedHandler,
requiredPermission: string
): AuthenticatedHandler {
return async (request, env, ctx, user) => {
if (!user.permissions.includes(requiredPermission)) {
return new Response(
JSON.stringify({
error: 'Forbidden',
message: `Required permission: ${requiredPermission}`,
}),
{ status: 403, headers: { 'Content-Type': 'application/json' } }
);
}
return handler(request, env, ctx, user);
};
}
// ============================================
// OPTIONAL AUTH
// ============================================
/**
* Optional authentication - doesn't fail if no auth provided
*/
export function withOptionalAuth(
handler: (
request: Request,
env: Env,
ctx: ExecutionContext,
user: User | null
) => Promise<Response>
): Handler {
return async (request, env, ctx) => {
const authHeader = request.headers.get('Authorization');
let user: User | null = null;
if (authHeader?.startsWith('Bearer ')) {
const result = await verifyJWT(authHeader.slice(7), env.JWT_SECRET);
if (result.authenticated && result.user) {
user = result.user;
}
}
return handler(request, env, ctx, user);
};
}
// ============================================
// TOKEN GENERATION
// ============================================
export async function generateTokens(
user: User,
secret: string
): Promise<{ accessToken: string; refreshToken: string }> {
const accessToken = await createJWT(
{
sub: user.id,
email: user.email,
role: user.role,
permissions: user.permissions,
exp: Math.floor(Date.now() / 1000) + 3600, // 1 hour
},
secret
);
const refreshToken = await createJWT(
{
sub: user.id,
email: user.email,
role: user.role,
permissions: [],
exp: Math.floor(Date.now() / 1000) + 604800, // 7 days
},
secret
);
return { accessToken, refreshToken };
}
// ============================================
// EXAMPLE USAGE
// ============================================
/*
import { Hono } from 'hono';
import {
withJWTAuth,
withApiKeyAuth,
withAuth,
requireRole,
requirePermission,
generateTokens,
} from './auth-middleware';
const app = new Hono<{ Bindings: Env }>();
// Public endpoint
app.get('/health', (c) => c.json({ status: 'ok' }));
// JWT protected endpoint
app.get('/api/me', async (c) => {
return withJWTAuth(async (request, env, ctx, user) => {
return Response.json({ user });
})(c.req.raw, c.env, c.executionCtx);
});
// Admin only endpoint
app.delete('/api/users/:id', async (c) => {
return withAuth(
requireRole(
async (request, env, ctx, user) => {
// Handle delete
return Response.json({ deleted: true });
},
['admin']
)
)(c.req.raw, c.env, c.executionCtx);
});
// Permission-based endpoint
app.post('/api/posts', async (c) => {
return withAuth(
requirePermission(
async (request, env, ctx, user) => {
// Handle create post
return Response.json({ created: true });
},
'posts:write'
)
)(c.req.raw, c.env, c.executionCtx);
});
// Login endpoint
app.post('/auth/login', async (c) => {
const { email, password } = await c.req.json();
// Validate credentials (implement your own logic)
const user = await validateCredentials(email, password);
if (!user) {
return c.json({ error: 'Invalid credentials' }, 401);
}
const tokens = await generateTokens(user, c.env.JWT_SECRET);
return c.json(tokens);
});
*/
/**
* CORS Handler for Cloudflare Workers
*
* Features:
* - Origin validation
* - Preflight handling
* - Credentials support
* - Dynamic origins
* - Environment-based config
*
* Usage:
* 1. Configure allowed origins
* 2. Use middleware or handler
* 3. Apply to routes
*/
// ============================================
// TYPES
// ============================================
interface Env {
ENVIRONMENT: string;
ALLOWED_ORIGINS?: string; // Comma-separated
}
interface CORSConfig {
/**
* Allowed origins. Can be:
* - Array of specific origins
* - '*' for all origins (not recommended with credentials)
* - Function for dynamic validation
*/
origins: string[] | '*' | ((origin: string) => boolean);
/** Allowed HTTP methods */
methods?: string[];
/** Allowed request headers */
allowedHeaders?: string[];
/** Headers to expose to client */
exposedHeaders?: string[];
/** Allow credentials (cookies, auth headers) */
credentials?: boolean;
/** Preflight cache duration in seconds */
maxAge?: number;
}
type Handler = (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>;
// ============================================
// DEFAULT CONFIG
// ============================================
const DEFAULT_CONFIG: Required<CORSConfig> = {
origins: [],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
exposedHeaders: ['X-Request-ID', 'X-RateLimit-Remaining'],
credentials: false,
maxAge: 86400, // 24 hours
};
// ============================================
// CORS UTILITIES
// ============================================
function isOriginAllowed(
origin: string,
config: CORSConfig['origins']
): boolean {
if (config === '*') return true;
if (typeof config === 'function') return config(origin);
if (Array.isArray(config)) return config.includes(origin);
return false;
}
function getOriginHeader(
origin: string | null,
config: CORSConfig
): string | null {
if (!origin) return null;
if (config.origins === '*') return '*';
if (isOriginAllowed(origin, config.origins)) return origin;
return null;
}
// ============================================
// PREFLIGHT HANDLER
// ============================================
function handlePreflight(
request: Request,
config: Required<CORSConfig>
): Response {
const origin = request.headers.get('Origin');
const requestMethod = request.headers.get('Access-Control-Request-Method');
const requestHeaders = request.headers.get('Access-Control-Request-Headers');
const headers: Record<string, string> = {};
// Set origin header
const allowedOrigin = getOriginHeader(origin, config);
if (allowedOrigin) {
headers['Access-Control-Allow-Origin'] = allowedOrigin;
}
// Set allowed methods
if (requestMethod && config.methods.includes(requestMethod.toUpperCase())) {
headers['Access-Control-Allow-Methods'] = config.methods.join(', ');
}
// Set allowed headers
if (requestHeaders) {
// Allow requested headers if they're in our allowlist
const requested = requestHeaders.split(',').map((h) => h.trim().toLowerCase());
const allowed = config.allowedHeaders.map((h) => h.toLowerCase());
const matching = requested.filter((h) => allowed.includes(h));
if (matching.length > 0) {
headers['Access-Control-Allow-Headers'] = config.allowedHeaders.join(', ');
}
}
// Set credentials
if (config.credentials) {
headers['Access-Control-Allow-Credentials'] = 'true';
}
// Set max age
headers['Access-Control-Max-Age'] = config.maxAge.toString();
// Vary header for caching
headers['Vary'] = 'Origin, Access-Control-Request-Method, Access-Control-Request-Headers';
return new Response(null, { status: 204, headers });
}
// ============================================
// ADD CORS HEADERS
// ============================================
function addCORSHeaders(
response: Response,
origin: string | null,
config: Required<CORSConfig>
): Response {
const newResponse = new Response(response.body, response);
const headers = newResponse.headers;
// Set origin
const allowedOrigin = getOriginHeader(origin, config);
if (allowedOrigin) {
headers.set('Access-Control-Allow-Origin', allowedOrigin);
}
// Set credentials
if (config.credentials) {
headers.set('Access-Control-Allow-Credentials', 'true');
}
// Set exposed headers
if (config.exposedHeaders.length > 0) {
headers.set('Access-Control-Expose-Headers', config.exposedHeaders.join(', '));
}
// Vary header for proper caching
headers.append('Vary', 'Origin');
return newResponse;
}
// ============================================
// CORS MIDDLEWARE
// ============================================
/**
* Create CORS middleware with config
*/
export function createCORSMiddleware(config: Partial<CORSConfig> = {}) {
const mergedConfig: Required<CORSConfig> = {
...DEFAULT_CONFIG,
...config,
};
return function corsMiddleware(handler: Handler): Handler {
return async (request, env, ctx) => {
const origin = request.headers.get('Origin');
// Handle preflight
if (request.method === 'OPTIONS') {
return handlePreflight(request, mergedConfig);
}
// Get response from handler
const response = await handler(request, env, ctx);
// Add CORS headers
return addCORSHeaders(response, origin, mergedConfig);
};
};
}
/**
* Simple CORS wrapper for handlers
*/
export function withCORS(handler: Handler, config: Partial<CORSConfig> = {}): Handler {
return createCORSMiddleware(config)(handler);
}
// ============================================
// ENVIRONMENT-BASED CONFIG
// ============================================
/**
* Get CORS config based on environment
*/
export function getCORSConfig(env: Env): CORSConfig {
// Development: Allow localhost
if (env.ENVIRONMENT === 'development') {
return {
origins: [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:8787',
'http://127.0.0.1:3000',
],
methods: DEFAULT_CONFIG.methods,
allowedHeaders: [...DEFAULT_CONFIG.allowedHeaders, 'X-Debug'],
credentials: true,
maxAge: 600, // 10 minutes for dev
};
}
// Production: Use configured origins
if (env.ALLOWED_ORIGINS) {
return {
origins: env.ALLOWED_ORIGINS.split(',').map((o) => o.trim()),
methods: DEFAULT_CONFIG.methods,
allowedHeaders: DEFAULT_CONFIG.allowedHeaders,
credentials: true,
maxAge: 86400,
};
}
// Default: Restrictive
return {
origins: [],
credentials: false,
};
}
// ============================================
// DYNAMIC ORIGIN VALIDATION
// ============================================
/**
* Validate subdomain origins
*/
export function subdomainOriginValidator(baseDomain: string) {
return (origin: string): boolean => {
try {
const url = new URL(origin);
return (
url.protocol === 'https:' &&
(url.hostname === baseDomain || url.hostname.endsWith(`.${baseDomain}`))
);
} catch {
return false;
}
};
}
/**
* Validate regex pattern origins
*/
export function regexOriginValidator(pattern: RegExp) {
return (origin: string): boolean => pattern.test(origin);
}
// ============================================
// HONO MIDDLEWARE
// ============================================
/**
* Hono-compatible CORS middleware
*/
export function honoCORS(config: Partial<CORSConfig> = {}) {
const mergedConfig: Required<CORSConfig> = {
...DEFAULT_CONFIG,
...config,
};
return async (c: { req: { raw: Request; method: string }; res: Response; header: (name: string, value: string) => void }, next: () => Promise<void>) => {
const origin = c.req.raw.headers.get('Origin');
// Handle preflight
if (c.req.method === 'OPTIONS') {
const response = handlePreflight(c.req.raw, mergedConfig);
return response;
}
// Continue with handler
await next();
// Add CORS headers to response
const allowedOrigin = getOriginHeader(origin, mergedConfig);
if (allowedOrigin) {
c.header('Access-Control-Allow-Origin', allowedOrigin);
}
if (mergedConfig.credentials) {
c.header('Access-Control-Allow-Credentials', 'true');
}
if (mergedConfig.exposedHeaders.length > 0) {
c.header('Access-Control-Expose-Headers', mergedConfig.exposedHeaders.join(', '));
}
c.header('Vary', 'Origin');
};
}
// ============================================
// STANDALONE CORS HANDLER
// ============================================
/**
* Standalone CORS request handler
*/
export class CORSHandler {
private config: Required<CORSConfig>;
constructor(config: Partial<CORSConfig> = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
/**
* Check if request is a preflight
*/
isPreflight(request: Request): boolean {
return (
request.method === 'OPTIONS' &&
request.headers.has('Access-Control-Request-Method')
);
}
/**
* Handle preflight request
*/
preflight(request: Request): Response {
return handlePreflight(request, this.config);
}
/**
* Add CORS headers to response
*/
addHeaders(response: Response, request: Request): Response {
const origin = request.headers.get('Origin');
return addCORSHeaders(response, origin, this.config);
}
/**
* Check if origin is allowed
*/
isAllowed(origin: string): boolean {
return isOriginAllowed(origin, this.config.origins);
}
}
// ============================================
// EXAMPLE USAGE
// ============================================
/*
import { Hono } from 'hono';
import {
createCORSMiddleware,
withCORS,
getCORSConfig,
honoCORS,
subdomainOriginValidator,
} from './cors-handler';
const app = new Hono<{ Bindings: Env }>();
// Option 1: Hono middleware
app.use('*', honoCORS({
origins: ['https://app.example.com', 'https://admin.example.com'],
credentials: true,
}));
// Option 2: Dynamic config from env
app.use('*', async (c, next) => {
const config = getCORSConfig(c.env);
return honoCORS(config)(c, next);
});
// Option 3: Subdomain validation
app.use('*', honoCORS({
origins: subdomainOriginValidator('example.com'),
credentials: true,
}));
// Option 4: Wrap individual handlers
app.get('/api/public', async (c) => {
return withCORS(async () => {
return Response.json({ data: 'public' });
}, { origins: '*' })(c.req.raw, c.env, c.executionCtx);
});
// Option 5: Standalone handler
const corsHandler = new CORSHandler({
origins: ['https://app.example.com'],
credentials: true,
});
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Handle preflight
if (corsHandler.isPreflight(request)) {
return corsHandler.preflight(request);
}
// Check origin
const origin = request.headers.get('Origin');
if (origin && !corsHandler.isAllowed(origin)) {
return new Response('Origin not allowed', { status: 403 });
}
// Handle request
const response = await handleRequest(request, env);
// Add CORS headers
return corsHandler.addHeaders(response, request);
},
};
*/
/**
* Rate Limiter for Cloudflare Workers
*
* Features:
* - Fixed window rate limiting
* - Sliding window rate limiting
* - Token bucket algorithm
* - Per-user/API key limits
* - Multi-tier limits
* - Durable Objects support
*
* Usage:
* 1. Choose algorithm
* 2. Configure limits
* 3. Apply middleware
*/
// ============================================
// TYPES
// ============================================
interface Env {
KV: KVNamespace;
RATE_LIMITER?: DurableObjectNamespace;
}
interface RateLimitResult {
allowed: boolean;
remaining: number;
limit: number;
resetAt: number;
retryAfter?: number;
}
interface RateLimitConfig {
/** Maximum requests in window */
limit: number;
/** Window size in seconds */
windowSeconds: number;
/** Key generator function */
keyGenerator?: (request: Request) => string;
/** Skip rate limiting for certain requests */
skip?: (request: Request) => boolean;
/** Custom response for rate limited requests */
onLimited?: (result: RateLimitResult) => Response;
}
type Handler = (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response>;
// ============================================
// KEY GENERATORS
// ============================================
export const KeyGenerators = {
/** Rate limit by IP address */
byIP: (request: Request): string => {
return request.headers.get('CF-Connecting-IP') || 'unknown';
},
/** Rate limit by API key */
byApiKey: (request: Request): string => {
return request.headers.get('X-API-Key') || 'no-key';
},
/** Rate limit by user ID (from auth header) */
byUser: (request: Request): string => {
const auth = request.headers.get('Authorization');
if (auth?.startsWith('Bearer ')) {
try {
// Extract user ID from JWT (base64 decode payload)
const parts = auth.slice(7).split('.');
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
return `user:${payload.sub}`;
} catch {
return 'invalid-token';
}
}
return 'anonymous';
},
/** Rate limit by IP + path */
byIPAndPath: (request: Request): string => {
const ip = request.headers.get('CF-Connecting-IP') || 'unknown';
const path = new URL(request.url).pathname;
return `${ip}:${path}`;
},
/** Composite key generator */
composite: (...generators: ((request: Request) => string)[]): ((request: Request) => string) => {
return (request: Request) => generators.map((g) => g(request)).join(':');
},
};
// ============================================
// FIXED WINDOW RATE LIMITER
// ============================================
export async function fixedWindowRateLimit(
kv: KVNamespace,
key: string,
limit: number,
windowSeconds: number
): Promise<RateLimitResult> {
const now = Date.now();
const windowStart = Math.floor(now / (windowSeconds * 1000)) * (windowSeconds * 1000);
const windowEnd = windowStart + windowSeconds * 1000;
const kvKey = `ratelimit:fixed:${key}:${windowStart}`;
// Get current count
const countStr = await kv.get(kvKey);
const count = countStr ? parseInt(countStr, 10) : 0;
if (count >= limit) {
const retryAfter = Math.ceil((windowEnd - now) / 1000);
return {
allowed: false,
remaining: 0,
limit,
resetAt: windowEnd,
retryAfter,
};
}
// Increment atomically-ish (KV doesn't have atomic increment)
await kv.put(kvKey, (count + 1).toString(), {
expirationTtl: windowSeconds + 60, // Extra minute for safety
});
return {
allowed: true,
remaining: limit - count - 1,
limit,
resetAt: windowEnd,
};
}
// ============================================
// SLIDING WINDOW RATE LIMITER
// ============================================
interface SlidingWindowData {
requests: number[];
}
export async function slidingWindowRateLimit(
kv: KVNamespace,
key: string,
limit: number,
windowSeconds: number
): Promise<RateLimitResult> {
const now = Date.now();
const windowMs = windowSeconds * 1000;
const windowStart = now - windowMs;
const kvKey = `ratelimit:sliding:${key}`;
// Get request timestamps
const data = await kv.get<SlidingWindowData>(kvKey, 'json');
let requests = data?.requests || [];
// Filter to current window
requests = requests.filter((ts) => ts > windowStart);
if (requests.length >= limit) {
// Find when oldest request expires
const oldestRequest = Math.min(...requests);
const retryAfter = Math.ceil((oldestRequest + windowMs - now) / 1000);
return {
allowed: false,
remaining: 0,
limit,
resetAt: oldestRequest + windowMs,
retryAfter,
};
}
// Add current request
requests.push(now);
// Store updated list
await kv.put(kvKey, JSON.stringify({ requests }), {
expirationTtl: windowSeconds + 60,
});
return {
allowed: true,
remaining: limit - requests.length,
limit,
resetAt: now + windowMs,
};
}
// ============================================
// TOKEN BUCKET RATE LIMITER
// ============================================
interface TokenBucketData {
tokens: number;
lastRefill: number;
}
interface TokenBucketConfig {
capacity: number;
refillRate: number; // Tokens per second
}
export async function tokenBucketRateLimit(
kv: KVNamespace,
key: string,
config: TokenBucketConfig,
tokensRequested = 1
): Promise<RateLimitResult> {
const now = Date.now();
const kvKey = `ratelimit:bucket:${key}`;
// Get or initialize bucket
let bucket = await kv.get<TokenBucketData>(kvKey, 'json');
if (!bucket) {
bucket = { tokens: config.capacity, lastRefill: now };
}
// Calculate tokens to add
const timePassed = (now - bucket.lastRefill) / 1000;
const tokensToAdd = timePassed * config.refillRate;
bucket.tokens = Math.min(config.capacity, bucket.tokens + tokensToAdd);
bucket.lastRefill = now;
// Check if we have enough tokens
if (bucket.tokens < tokensRequested) {
const tokensNeeded = tokensRequested - bucket.tokens;
const waitTime = Math.ceil(tokensNeeded / config.refillRate);
await kv.put(kvKey, JSON.stringify(bucket), { expirationTtl: 3600 });
return {
allowed: false,
remaining: Math.floor(bucket.tokens),
limit: config.capacity,
resetAt: now + waitTime * 1000,
retryAfter: waitTime,
};
}
// Consume tokens
bucket.tokens -= tokensRequested;
await kv.put(kvKey, JSON.stringify(bucket), { expirationTtl: 3600 });
return {
allowed: true,
remaining: Math.floor(bucket.tokens),
limit: config.capacity,
resetAt: now + Math.ceil((config.capacity - bucket.tokens) / config.refillRate) * 1000,
};
}
// ============================================
// RATE LIMIT RESPONSE
// ============================================
function createRateLimitResponse(result: RateLimitResult): Response {
return new Response(
JSON.stringify({
error: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${result.retryAfter} seconds.`,
retryAfter: result.retryAfter,
}),
{
status: 429,
headers: {
'Content-Type': 'application/json',
'Retry-After': (result.retryAfter || 60).toString(),
'X-RateLimit-Limit': result.limit.toString(),
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': result.resetAt.toString(),
},
}
);
}
function addRateLimitHeaders(response: Response, result: RateLimitResult): Response {
const newResponse = new Response(response.body, response);
newResponse.headers.set('X-RateLimit-Limit', result.limit.toString());
newResponse.headers.set('X-RateLimit-Remaining', result.remaining.toString());
newResponse.headers.set('X-RateLimit-Reset', result.resetAt.toString());
return newResponse;
}
// ============================================
// RATE LIMIT MIDDLEWARE
// ============================================
export function withRateLimit(config: RateLimitConfig) {
return function rateLimitMiddleware(handler: Handler): Handler {
return async (request, env, ctx) => {
// Check skip condition
if (config.skip?.(request)) {
return handler(request, env, ctx);
}
// Generate key
const keyGenerator = config.keyGenerator || KeyGenerators.byIP;
const key = keyGenerator(request);
// Check rate limit
const result = await fixedWindowRateLimit(
env.KV,
key,
config.limit,
config.windowSeconds
);
if (!result.allowed) {
return config.onLimited?.(result) || createRateLimitResponse(result);
}
// Continue with handler
const response = await handler(request, env, ctx);
// Add rate limit headers
return addRateLimitHeaders(response, result);
};
};
}
// ============================================
// MULTI-TIER RATE LIMITER
// ============================================
interface RateLimitTier {
name: string;
limit: number;
windowSeconds: number;
}
export async function multiTierRateLimit(
kv: KVNamespace,
key: string,
tiers: RateLimitTier[]
): Promise<RateLimitResult & { tier?: string }> {
for (const tier of tiers) {
const result = await fixedWindowRateLimit(kv, `${key}:${tier.name}`, tier.limit, tier.windowSeconds);
if (!result.allowed) {
return { ...result, tier: tier.name };
}
}
return {
allowed: true,
remaining: -1, // Multiple tiers
limit: -1,
resetAt: Date.now() + tiers[0].windowSeconds * 1000,
};
}
export function withMultiTierRateLimit(tiers: RateLimitTier[], keyGenerator = KeyGenerators.byIP) {
return function rateLimitMiddleware(handler: Handler): Handler {
return async (request, env, ctx) => {
const key = keyGenerator(request);
const result = await multiTierRateLimit(env.KV, key, tiers);
if (!result.allowed) {
return createRateLimitResponse(result);
}
return handler(request, env, ctx);
};
};
}
// ============================================
// RATE LIMITER CLASS
// ============================================
export class RateLimiter {
private kv: KVNamespace;
private config: RateLimitConfig;
constructor(kv: KVNamespace, config: RateLimitConfig) {
this.kv = kv;
this.config = config;
}
async check(request: Request): Promise<RateLimitResult> {
const keyGenerator = this.config.keyGenerator || KeyGenerators.byIP;
const key = keyGenerator(request);
return fixedWindowRateLimit(this.kv, key, this.config.limit, this.config.windowSeconds);
}
middleware(handler: Handler): Handler {
return withRateLimit(this.config)(handler);
}
}
// ============================================
// EXAMPLE USAGE
// ============================================
/*
import { Hono } from 'hono';
import {
withRateLimit,
withMultiTierRateLimit,
KeyGenerators,
RateLimiter,
} from './rate-limiter';
const app = new Hono<{ Bindings: Env }>();
// Option 1: Simple rate limit
app.use('/api/*', async (c, next) => {
const limiter = withRateLimit({
limit: 100,
windowSeconds: 60,
keyGenerator: KeyGenerators.byIP,
skip: (req) => new URL(req.url).pathname === '/api/health',
});
return limiter(async (request, env, ctx) => {
await next();
return c.res;
})(c.req.raw, c.env, c.executionCtx);
});
// Option 2: Multi-tier limits
const tiers = [
{ name: 'second', limit: 10, windowSeconds: 1 },
{ name: 'minute', limit: 100, windowSeconds: 60 },
{ name: 'hour', limit: 1000, windowSeconds: 3600 },
];
app.use('/api/*', async (c, next) => {
const limiter = withMultiTierRateLimit(tiers);
return limiter(async () => {
await next();
return c.res;
})(c.req.raw, c.env, c.executionCtx);
});
// Option 3: Per-endpoint limits
app.post('/api/expensive', async (c) => {
const limiter = new RateLimiter(c.env.KV, {
limit: 10,
windowSeconds: 60,
keyGenerator: KeyGenerators.byUser,
});
const result = await limiter.check(c.req.raw);
if (!result.allowed) {
return c.json({ error: 'Rate limited' }, 429);
}
// Handle expensive operation
return c.json({ success: true });
});
*/