Cloudflare Workers Security Guide for Vibe Coders

Share

Cloudflare Workers Security Guide for Vibe Coders

Published on January 23, 2026 - 11 min read

TL;DR

Cloudflare Workers run at the edge globally, which means security must be baked in from the start. Use wrangler secret for API keys and sensitive data (never wrangler.toml). Validate all incoming requests including headers and body. Use bindings for KV, D1, and R2 access control. Remember Workers have no persistent state, so implement proper authentication for every request.

Why Workers Security Matters for Vibe Coding

Cloudflare Workers execute at over 300 edge locations worldwide. When AI tools generate Workers code, they often produce functional handlers but miss security fundamentals. The edge deployment model means every security vulnerability is replicated globally.

Common issues include secrets in wrangler.toml, missing request validation, and improper use of environment bindings.

Secrets Management

Workers have two ways to store configuration: variables (public) and secrets (encrypted).

Wrangler Secrets

# Set a secret (encrypted, not visible in dashboard)
wrangler secret put API_KEY
# Enter the value when prompted

# Set multiple secrets
wrangler secret put DATABASE_URL
wrangler secret put JWT_SECRET

# List secrets (values hidden)
wrangler secret list

# Delete a secret
wrangler secret delete OLD_KEY

Never Put Secrets in wrangler.toml

# wrangler.toml - WRONG!
[vars]
API_KEY = "sk-secret-key"  # This is committed to git!

# wrangler.toml - CORRECT
[vars]
PUBLIC_API_URL = "https://api.example.com"
ENVIRONMENT = "production"
# Secrets are set via wrangler secret put

Accessing Secrets in Code

// Secrets are available on the env object
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Access secrets from env
    const apiKey = env.API_KEY;
    const dbUrl = env.DATABASE_URL;

    if (!apiKey) {
      console.error('API_KEY not configured');
      return new Response('Server configuration error', { status: 500 });
    }

    // Use the secret
    const response = await fetch('https://api.example.com', {
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    return response;
  },
};

// Type definition
interface Env {
  API_KEY: string;
  DATABASE_URL: string;
  JWT_SECRET: string;
  MY_KV_NAMESPACE: KVNamespace;
}

Request Validation

Validate every incoming request before processing:

import { z } from 'zod';

const CreateUserSchema = z.object({
  email: z.string().email().max(255),
  name: z.string().min(1).max(100),
});

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Validate method
    if (request.method !== 'POST') {
      return new Response('Method not allowed', { status: 405 });
    }

    // Validate Content-Type
    const contentType = request.headers.get('content-type');
    if (!contentType?.includes('application/json')) {
      return new Response('Invalid content type', { status: 415 });
    }

    // Parse and validate body
    let body: unknown;
    try {
      body = await request.json();
    } catch {
      return new Response('Invalid JSON', { status: 400 });
    }

    const result = CreateUserSchema.safeParse(body);
    if (!result.success) {
      return Response.json(
        { error: 'Validation failed', details: result.error.flatten() },
        { status: 400 }
      );
    }

    // Now safe to use
    const { email, name } = result.data;
    // Process the request...

    return Response.json({ success: true });
  },
};

Authentication Patterns

Workers are stateless, so authenticate every request:

JWT Validation

import { jwtVerify, createRemoteJWKSet } from 'jose';

async function validateJWT(request: Request, env: Env) {
  const authHeader = request.headers.get('authorization');

  if (!authHeader?.startsWith('Bearer ')) {
    return null;
  }

  const token = authHeader.slice(7);

  try {
    // For Auth0/Clerk/etc - use JWKS
    const JWKS = createRemoteJWKSet(
      new URL(`${env.AUTH_ISSUER}/.well-known/jwks.json`)
    );

    const { payload } = await jwtVerify(token, JWKS, {
      issuer: env.AUTH_ISSUER,
      audience: env.AUTH_AUDIENCE,
    });

    return payload;
  } catch (error) {
    console.error('JWT validation failed:', error);
    return null;
  }
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const user = await validateJWT(request, env);

    if (!user) {
      return new Response('Unauthorized', { status: 401 });
    }

    // User is authenticated
    return Response.json({ userId: user.sub });
  },
};

API Key Authentication

async function validateApiKey(request: Request, env: Env) {
  const apiKey = request.headers.get('x-api-key');

  if (!apiKey) {
    return null;
  }

  // Look up API key in KV
  const keyData = await env.API_KEYS.get(apiKey, 'json');

  if (!keyData) {
    return null;
  }

  // Check if key is expired
  if (keyData.expiresAt && keyData.expiresAt < Date.now()) {
    return null;
  }

  return keyData;
}

Bindings Security

Workers access storage services through bindings. Secure them properly:

KV Namespace

// KV operations
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Never store raw secrets in KV
    // KV is eventually consistent and accessible via API

    // Store user-scoped data
    const userId = await getUserId(request, env);
    const key = `user:${userId}:preferences`;

    // SAFE: User can only access their own data
    const prefs = await env.USER_DATA.get(key, 'json');

    // DANGEROUS: User-controlled key
    const userKey = new URL(request.url).searchParams.get('key');
    // const data = await env.USER_DATA.get(userKey); // Don't do this!

    // SAFE: Validate and scope the key
    if (userKey && /^[a-z0-9-]+$/.test(userKey)) {
      const scopedKey = `user:${userId}:${userKey}`;
      const data = await env.USER_DATA.get(scopedKey);
    }

    return Response.json(prefs);
  },
};

D1 Database

// D1 with parameterized queries
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const userId = await getUserId(request, env);

    // SAFE: Parameterized query
    const { results } = await env.DB.prepare(
      'SELECT * FROM posts WHERE author_id = ?'
    ).bind(userId).all();

    // DANGEROUS: String interpolation
    // const { results } = await env.DB.prepare(
    //   `SELECT * FROM posts WHERE author_id = '${userId}'`
    // ).all();

    return Response.json(results);
  },
};

R2 Storage

// R2 with access control
export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const userId = await getUserId(request, env);
    const url = new URL(request.url);
    const key = url.pathname.slice(1); // Remove leading /

    // Validate key format
    if (!key || key.includes('..') || key.startsWith('/')) {
      return new Response('Invalid key', { status: 400 });
    }

    // Scope to user's directory
    const scopedKey = `users/${userId}/${key}`;

    if (request.method === 'GET') {
      const object = await env.BUCKET.get(scopedKey);
      if (!object) {
        return new Response('Not found', { status: 404 });
      }
      return new Response(object.body);
    }

    if (request.method === 'PUT') {
      // Validate content type and size
      const contentType = request.headers.get('content-type');
      const contentLength = parseInt(request.headers.get('content-length') || '0');

      if (contentLength > 10 * 1024 * 1024) { // 10MB limit
        return new Response('File too large', { status: 413 });
      }

      await env.BUCKET.put(scopedKey, request.body, {
        httpMetadata: { contentType },
      });
      return new Response('Uploaded', { status: 201 });
    }

    return new Response('Method not allowed', { status: 405 });
  },
};

Cloudflare Workers Security Checklist

  • All secrets stored via wrangler secret put
  • No secrets or API keys in wrangler.toml
  • Request method and content-type validated
  • Request body validated with Zod or similar
  • Authentication checked on every request
  • JWT tokens validated with proper issuer and audience
  • KV keys scoped to authenticated user
  • D1 queries use parameterized statements
  • R2 paths validated and user-scoped
  • Error responses don't leak sensitive information
  • CORS configured appropriately
  • Rate limiting implemented for sensitive endpoints

CORS Configuration

const CORS_HEADERS = {
  'Access-Control-Allow-Origin': 'https://yourdomain.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
  'Access-Control-Max-Age': '86400',
};

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    // Handle preflight
    if (request.method === 'OPTIONS') {
      return new Response(null, { headers: CORS_HEADERS });
    }

    // Check origin
    const origin = request.headers.get('origin');
    if (origin !== 'https://yourdomain.com') {
      return new Response('Forbidden', { status: 403 });
    }

    const response = await handleRequest(request, env);

    // Add CORS headers to response
    Object.entries(CORS_HEADERS).forEach(([key, value]) => {
      response.headers.set(key, value);
    });

    return response;
  },
};

Are secrets encrypted in Cloudflare Workers?

Yes, secrets set via wrangler secret put are encrypted at rest and only decrypted when your Worker runs. They're not visible in the dashboard or wrangler output.

::

Can I use environment variables in wrangler.toml?

Yes, but only for non-sensitive configuration like feature flags or public URLs. Never put API keys, database credentials, or other secrets in wrangler.toml as it's committed to version control.

How do I handle different environments (dev/staging/prod)?

Use wrangler environments. Create separate Workers with different secrets: wrangler secret put API_KEY --env staging . Each environment has its own isolated secrets.

Is KV secure for storing sensitive data?

KV data is encrypted at rest, but it's accessible via the Cloudflare API with your account credentials. Don't store raw secrets in KV. For user data, always scope keys to prevent users from accessing each other's data.

::

What CheckYourVibe Detects

When scanning your Cloudflare Workers project, CheckYourVibe identifies:

  • Secrets hardcoded in wrangler.toml or source code
  • Missing request validation
  • D1 queries vulnerable to SQL injection
  • KV or R2 keys without user scoping
  • Missing authentication on protected routes
  • Overly permissive CORS configuration

Run npx checkyourvibe scan to catch these issues before they reach production.

Tool & Platform Guides

Cloudflare Workers Security Guide for Vibe Coders