CORS Policy
CORS Policy
This document describes the Cross-Origin Resource Sharing (CORS) policy enforced by the Bloqr Cloudflare Worker. It covers allowed origins, the preflight flow, no-origin requests, error responses, and Zero Trust integration.
Overview
All HTTP responses from the Worker include CORS headers computed at request time. The policy is strict by default:
- Only origins listed in
CORS_ALLOWED_ORIGINS(a Worker environment binding) receive a permissiveAccess-Control-Allow-Originheader. - Requests from unlisted origins are actively rejected with a
403 ProblemDetails(corsRejection) — the Worker does not silently omit the header. - Requests with no
Originheader (direct server-to-server, CLI, or Postman) are allowed through unconditionally. - Preflight
OPTIONSrequests return the full policy headers and204 No Content.
CORS Request Decision Flow
flowchart TD
A[Incoming Request] --> B{Origin header present?}
B -- No --> C[Allow: direct / server-to-server]
B -- Yes --> D{Origin in\nCORS_ALLOWED_ORIGINS?}
D -- Yes --> E[Set Access-Control-Allow-Origin\n= request Origin]
D -- No --> F[Return 403 ProblemDetails\ncorsRejection]
E --> G{Method = OPTIONS?}
G -- Yes --> I[Return 204 with\npreflight headers]
G -- No --> J[Continue to route handler\nwith CORS headers set]
Environment Binding
The allowed-origins list is configured via the CORS_ALLOWED_ORIGINS Worker binding (a comma-delimited string):
[vars]CORS_ALLOWED_ORIGINS = "https://app.bloqr.app,https://admin.bloqr.app"For local development, override via .dev.vars:
CORS_ALLOWED_ORIGINS = "http://localhost:4200,http://localhost:4201"Middleware Implementation
CORS is implemented in two steps inside worker/hono-app.ts:
Step 4 — Hono cors() middleware computes and attaches CORS response headers. For public endpoints (health, metrics, docs) it returns '*'; for all other endpoints it echoes the request Origin only when it matches the allowlist.
Step 4a — Origin enforcement middleware rejects disallowed origins with a 403 ProblemDetails response. Requests without an Origin header pass through unconditionally; public-endpoint requests also pass through.
// worker/hono-app.ts — Step 4: Hono cors() middlewareapp.use( '*', cors({ origin: (origin, c) => { const pathname = new URL(c.req.url).pathname; if (isPublicEndpoint(pathname)) return '*'; return matchOrigin(origin, c.env as Env) ?? undefined; }, allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], allowHeaders: [ 'Content-Type', 'Authorization', 'X-Turnstile-Token', 'X-Payg-Session', 'X-Payment-Response', 'X-Stripe-Customer-Id', ], exposeHeaders: ['X-Payg-Session-Remaining', 'X-Payment-Required', 'X-Request-Id'], maxAge: 86400, credentials: true, }),);
// worker/hono-app.ts — Step 4a: explicit origin enforcementapp.use('*', async (c, next) => { const origin = c.req.header('Origin');
// No Origin header → non-browser client → allow through unconditionally. if (!origin) { await next(); return; }
const pathname = c.req.path;
// Public endpoints allow any origin (wildcard * returned by step 4). if (isPublicEndpoint(pathname)) { await next(); return; }
const allowed = matchOrigin(origin, c.env as Env); if (!allowed) { analytics?.trackSecurityEvent({ eventType: 'cors_rejection', ... }); return ProblemResponse.corsRejection(pathname, `Origin '${origin}' is not permitted.`); }
await next();});The matchOrigin helper (in worker/utils/cors.ts) checks the request Origin against the comma-delimited CORS_ALLOWED_ORIGINS Worker binding.
Preflight OPTIONS Response Headers
A complete preflight response for an allowed origin:
HTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://app.bloqr.appAccess-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONSAccess-Control-Allow-Headers: Content-Type, Authorization, X-Turnstile-Token, X-Payg-Session, X-Payment-Response, X-Stripe-Customer-IdAccess-Control-Expose-Headers: X-Payg-Session-Remaining, X-Payment-Required, X-Request-IdAccess-Control-Allow-Credentials: trueAccess-Control-Max-Age: 86400Vary: OriginX-Turnstile-Token is retained in Access-Control-Allow-Headers for backward compatibility with older callers. The Angular frontend now injects the Turnstile token into the JSON request body (turnstileToken field) rather than as a header — see Turnstile Middleware for the current implementation.
No-Origin Requests
Requests without an Origin header — typically from:
- cURL / Postman / Newman (API testing)
- Server-to-server calls (CI pipeline, Worker-to-Worker)
- Cloudflare Cron Triggers
…are allowed through without any CORS header manipulation. The route handler processes them normally. No CORS headers are added to the response for these requests; this is intentional and correct.
Security note (ZTA): Absence of an
Originheader does not bypass authentication. All endpoints that require a session still invokeauth.api.getSession(). CORS is a browser enforcement mechanism — it does not substitute for server-side access control.
Error Responses
When a request arrives from a disallowed origin (i.e., the Origin header is present but not in CORS_ALLOWED_ORIGINS), the Worker returns a 403 Forbidden response with a ProblemDetails JSON body (ProblemResponse.corsRejection). The request is terminated — it does not proceed to the route handler.
For preflight (OPTIONS) requests from disallowed origins, the Worker likewise returns a 403 with no Access-Control-Allow-Origin header. The browser will not proceed with the actual request.
Adding a New Allowed Origin
- Update
CORS_ALLOWED_ORIGINSinwrangler.toml(for production) or.dev.vars(for local dev). - If the origin is on a new subdomain of
bloqr.app, ensure thebetter-authtrustedOriginsbinding (TRUSTED_ORIGINS) also includes it — see Better Auth Security Audit, AUDIT-11. - If the origin requires cookie sharing, verify the session cookie
Domainattribute is set to.bloqr.app.
Zero Trust Integration
| ZTA Principle | CORS Implementation |
|---|---|
| Verify explicitly | Every request from a browser origin is checked against the explicit allow-list |
| Least privilege | Access-Control-Allow-Origin is echoed only for approved origins |
| Never trust, always verify | Credentials (Authorization, session cookie) are still validated server-side regardless of CORS outcome |
| Short-lived access | Access-Control-Max-Age: 86400 caps preflight caching at 24 hours |
Related Documentation
- Turnstile Middleware — reads
turnstileTokenfrom the JSON request body;X-Turnstile-Tokenheader retained in CORS allowlist for backward compatibility - Better Auth Security Audit, AUDIT-11 —
trustedOriginswildcard finding - Worker Request Lifecycle — where CORS middleware fits in the pipeline