CORS Preflight Tester
Simulate browser CORS preflight OPTIONS requests and analyze ACAO/ACAH headers - debug cross-origin API access from the edge
Send an OPTIONS preflight to a target URL with a simulated origin, method, and requested headers. Reports which CORS response headers are present, missing, or misconfigured relative to what the browser would require.
Features
- POST /test - simulate Access-Control-Request-Method/Headers preflight
- Analyzes Allow-Origin, Allow-Methods, Allow-Headers, Allow-Credentials
- Returns actionable pass/fail per header requirement
API Reference
POST /test
url string (required) - Target URL (http or https).
origin string (required) - Simulated Origin header value.
method string (required) - Intended request method (e.g. POST).
headers string[] (optional) - Request headers for preflight (e.g. ["Content-Type"]).
Example Request
curl -X POST "https://your-worker.workers.dev/test" \
-H "Content-Type: application/json" \
-d '{"url":"https://api.example.com/data","origin":"https://app.example.com","method":"POST","headers":["Content-Type"]}'Error Codes
400-INVALID_BODY,INVALID_URL,INVALID_ORIGIN,INVALID_METHOD502-PREFLIGHT_ERROR
Use Cases
- Debug why a browser blocks cross-origin API calls
- Validate CORS headers before deploying a new frontend origin
- Compare preflight behavior across staging and production APIs
Limitations
- Simulates preflight from Workers egress, not from an end-user browser network
- Does not follow complex redirect chains for OPTIONS
- Single preflight scenario per request
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/cors-preflight-tester.
import { ALLOWED_METHODS, MAX_REQUESTED_HEADERS, PREFLIGHT_TIMEOUT_MS,} from "../constants/defaults";import type { CorsHeaderCheck, CorsTestRequest, CorsTestResponse } from "../types/cors";function normalizeHeaderList(headers: string[] | undefined): string[] { if (!headers?.length) return []; return [ ...new Set( headers.map((header) => header.trim().toLowerCase()).filter((header) => header.length > 0) ), ].slice(0, MAX_REQUESTED_HEADERS);}export function validateCorsTestRequest(body: Partial<CorsTestRequest>): CorsTestRequest | null { if (!body.url || typeof body.url !== "string") return null; if (!body.origin || typeof body.origin !== "string") return null; if (!body.method || typeof body.method !== "string") return null; const method = body.method.trim().toUpperCase(); if (!ALLOWED_METHODS.includes(method as (typeof ALLOWED_METHODS)[number])) return null; const origin = body.origin.trim(); if (!origin) return null; return { url: body.url, origin, method, headers: normalizeHeaderList(body.headers), };}function parseAllowedList(value: string | null): string[] { if (!value) return []; return value .split(",") .map((item) => item.trim()) .filter(Boolean);}function includesToken(listValue: string | null, token: string, caseInsensitive = true): boolean { const items = parseAllowedList(listValue); const needle = caseInsensitive ? token.toLowerCase() : token; return items.some((item) => { const haystack = caseInsensitive ? item.toLowerCase() : item; return haystack === needle; });}function checkAllowOrigin(origin: string, value: string | null): CorsHeaderCheck { if (!value) { return { header: "Access-Control-Allow-Origin", present: false, value: null, status: "missing", detail: "Required for credentialed or cross-origin responses.", }; } if (value === "*" || value === origin) { return { header: "Access-Control-Allow-Origin", present: true, value, status: "ok", detail: value === "*" ? "Wildcard origin allowed." : "Echoes requested origin.", }; } return { header: "Access-Control-Allow-Origin", present: true, value, status: "misconfigured", detail: `Does not match requested origin ${origin}.`, };}function checkAllowMethods(method: string, value: string | null): CorsHeaderCheck { if (!value) { return { header: "Access-Control-Allow-Methods", present: false, value: null, status: "missing", detail: `Preflight should allow ${method}.`, }; } if (includesToken(value, method)) { return { header: "Access-Control-Allow-Methods", present: true, value, status: "ok", detail: `Includes requested method ${method}.`, }; } return { header: "Access-Control-Allow-Methods", present: true, value, status: "misconfigured", detail: `Does not include requested method ${method}.`, };}function checkAllowHeaders(requestedHeaders: string[], value: string | null): CorsHeaderCheck { if (requestedHeaders.length === 0) { return { header: "Access-Control-Allow-Headers", present: Boolean(value), value, status: value ? "ok" : "missing", detail: value ? "No custom headers requested; header present." : "No custom headers requested; header optional.", }; } if (!value) { return { header: "Access-Control-Allow-Headers", present: false, value: null, status: "missing", detail: `Should allow: ${requestedHeaders.join(", ")}`, }; } const missing = requestedHeaders.filter((header) => !includesToken(value, header)); if (missing.length === 0) { return { header: "Access-Control-Allow-Headers", present: true, value, status: "ok", detail: "Includes all requested headers.", }; } return { header: "Access-Control-Allow-Headers", present: true, value, status: "misconfigured", detail: `Missing requested headers: ${missing.join(", ")}`, };}export async function runCorsPreflightTest(request: CorsTestRequest): Promise<CorsTestResponse> { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), PREFLIGHT_TIMEOUT_MS); const started = performance.now(); const res = await fetch(request.url, { method: "OPTIONS", headers: { Origin: request.origin, "Access-Control-Request-Method": request.method, ...(request.headers?.length ? { "Access-Control-Request-Headers": request.headers.join(", ") } : {}), }, signal: controller.signal, }).finally(() => clearTimeout(timeout)); const responseTimeMs = Math.round(performance.now() - started); const allowOrigin = res.headers.get("Access-Control-Allow-Origin"); const allowMethods = res.headers.get("Access-Control-Allow-Methods"); const allowHeaders = res.headers.get("Access-Control-Allow-Headers"); const maxAge = res.headers.get("Access-Control-Max-Age"); const allowCredentials = res.headers.get("Access-Control-Allow-Credentials"); const checks: CorsHeaderCheck[] = [ checkAllowOrigin(request.origin, allowOrigin), checkAllowMethods(request.method, allowMethods), checkAllowHeaders(request.headers ?? [], allowHeaders), { header: "Access-Control-Max-Age", present: Boolean(maxAge), value: maxAge, status: maxAge ? "ok" : "missing", detail: maxAge ? "Preflight cache duration provided." : "Optional but recommended.", }, { header: "Access-Control-Allow-Credentials", present: Boolean(allowCredentials), value: allowCredentials, status: allowCredentials === "true" && allowOrigin === "*" ? "misconfigured" : allowCredentials ? "ok" : "missing", detail: allowCredentials === "true" && allowOrigin === "*" ? "Credentials cannot be used with wildcard origin." : allowCredentials ? "Credentials allowed." : "Optional unless cookies or auth headers are sent.", }, ]; const valid = checks.every((check) => { if ( (check.header === "Access-Control-Max-Age" || check.header === "Access-Control-Allow-Credentials") && check.status === "missing" ) { return true; } if ( check.header === "Access-Control-Allow-Headers" && check.status === "missing" && (request.headers?.length ?? 0) === 0 ) { return true; } return check.status === "ok"; }); return { url: request.url, origin: request.origin, method: request.method, requestedHeaders: request.headers ?? [], statusCode: res.status, responseTimeMs, checks, valid, };}Deployment
Configure bindings
See wrangler.json and the experiment README for required bindings.
Test your deployment
See the experiment README for curl examples.
Local Development
cd apps/experiments/cors-preflight-tester
npm install
npm run devConfiguration
No bindings required beyond the Workers runtime.