Digging Deeper
CORS
This page shows you how to let a browser on https://client.example.com call your Wheels app at https://api.example.com without the browser blocking the response. You’ll enable the built-in Cors middleware, configure an allowlist of origins, understand how preflight OPTIONS requests are handled for you, decide whether to allow credentials (cookies, auth headers), scope Cors to a single URL prefix like /api, and debug the most common failures.
You’ll learn:
- What CORS is and why you need server-side help to opt in
- How to enable
wheels.middleware.Corsglobally with an explicit origin allowlist - The full set of constructor options and their real defaults
- How preflight (
OPTIONS) requests are handled automatically - What
allowCredentials=trueunlocks — and why it forbids wildcard origins - How to apply Cors only to a scope like
/api - How to debug failures with the browser console and
curl
What CORS is
Section titled “What CORS is”CORS — Cross-Origin Resource Sharing — is a browser-enforced rule. A script running on origin A (scheme + host + port, e.g. https://client.example.com) cannot read responses from a different origin B unless B explicitly opts in with Access-Control-* response headers. Server-to-server HTTP calls don’t have CORS; it’s purely a browser-side policy enforced for JavaScript fetch, XMLHttpRequest, and EventSource.
The wheels.middleware.Cors middleware writes those opt-in headers onto every matching response and short-circuits preflight OPTIONS requests with an empty-body response (HTTP 200).
Enable the middleware
Section titled “Enable the middleware”Register Cors in config/settings.cfm with an explicit allowlist:
<cfscript>set(middleware = [ new wheels.middleware.Cors( allowOrigins="https://myapp.com,https://admin.myapp.com", allowCredentials=true, maxAge=3600 )]);</cfscript>Configuration options
Section titled “Configuration options”These are the constructor arguments on wheels.middleware.Cors, verified against vendor/wheels/middleware/Cors.cfc:
| Option | Default | Meaning |
|---|---|---|
allowOrigins | "" | Comma-delimited allowlist of exact origins, or "*" for any. Empty means nothing is allowed. |
allowMethods | "GET,POST,PUT,PATCH,DELETE,OPTIONS" | Comma-delimited allowed HTTP methods, echoed in Access-Control-Allow-Methods. |
allowHeaders | "Content-Type,Authorization,X-Requested-With" | Comma-delimited request headers the browser may send, echoed in Access-Control-Allow-Headers. |
allowCredentials | false | When true, browsers may include cookies and Authorization headers. Forbids allowOrigins="*". |
maxAge | 86400 (24 hours) | Access-Control-Max-Age — how long the browser may cache the preflight response. |
Matching is an exact match on the incoming Origin header, compared case-insensitively. There is no wildcard-subdomain support: https://*.myapp.com is not a valid entry. Enumerate every origin you need — scheme + host + port, nothing more, nothing less.
Preflight (OPTIONS) requests
Section titled “Preflight (OPTIONS) requests”Before any non-simple cross-origin request (anything that uses a custom header, a non-GET/POST/HEAD method, or a non-simple content type), the browser sends a preflight OPTIONS request asking “may I?” The Cors middleware handles this for you:
- If the
Originis inallowOrigins(orallowOriginsis"*"), the middleware writesAccess-Control-Allow-Origin,Access-Control-Allow-Methods,Access-Control-Allow-Headers, optionallyAccess-Control-Allow-Credentials, plusAccess-Control-Max-Age, and returns an empty response without running your controller. - If the
Originis not in the allowlist, the middleware still short-circuits theOPTIONSrequest with an empty body but omits theAccess-Control-Allow-*headers. The browser sees no opt-in and blocks the subsequent real request. (Access-Control-Max-Ageis emitted on everyOPTIONSresponse, including disallowed origins.)
You do not write an options action on your controllers. The middleware owns OPTIONS entirely.
Credentials mode
Section titled “Credentials mode”allowCredentials=true tells the browser it may attach cookies and Authorization headers to cross-origin requests. You need this for session-based auth across origins (a React SPA on https://app.example.com calling https://api.example.com).
Three rules come with it:
allowOrigins="*"is forbidden. The CORS spec bans this combination, browsers will reject the response, and the middlewarethrowsWheels.Cors.InvalidConfigurationat construction time to fail loud instead of silently.- The
Access-Control-Allow-Originresponse header must be the specific requesting origin, not*. The middleware echoes the matched origin automatically when it’s in your allowlist. - You must enumerate every allowed origin explicitly. No wildcards, no subdomain patterns. Every variant (
https://app.example.com,https://admin.example.com) gets its own entry.
Per-route Cors
Section titled “Per-route Cors”Most real apps want strict CORS only on /api and not on the HTML pages. Pass middleware to a scope() block and the rules apply only to the routes declared before the scope’s closing .end():
<cfscript>mapper() .scope( path="/api", middleware=[ new wheels.middleware.Cors( allowOrigins="https://client.myapp.com", allowCredentials=true ) ] ) .resources("posts") .resources("comments") .end() .resources("pages") .wildcard().end();</cfscript>Routes under /api get the Cors middleware. resources("pages") and everything outside the scope do not. Scope-level middleware composes with global middleware — both run.
Close the scope with .end() before declaring routes outside it — an unclosed scope leaks its path prefix and middleware onto every subsequent route. Alternatively, pass the scoped routes as a callback= argument (.scope(path="/api", middleware=[...], callback=function(map) { map.resources("posts"); })) — scope() runs the callback and closes the scope automatically when it returns (#3072). On releases before that fix (4.0.3 and earlier), scope() silently ignored callback=, so use the explicit .end() form there.
Debugging CORS failures
Section titled “Debugging CORS failures”When a cross-origin request fails, work through this checklist:
- Check the browser console. The rejection message names the reason: missing
Access-Control-Allow-Origin, origin mismatch, credentials-vs-wildcard conflict, or a disallowed method or header. The browser is specific; read what it says. - Reproduce with curl. Curl doesn’t enforce CORS, but you can see exactly what headers the server returned:
Look for
Terminal window curl -H "Origin: https://client.myapp.com" -v https://api.myapp.com/postsAccess-Control-Allow-Originin the response. If it’s missing, the middleware rejected your origin. - Reproduce the preflight. To see what the middleware returns for an
OPTIONSpreflight:Terminal window curl -X OPTIONS \-H "Origin: https://client.myapp.com" \-H "Access-Control-Request-Method: POST" \-H "Access-Control-Request-Headers: Content-Type" \-v https://api.myapp.com/posts - Match the origin exactly. Origins are strings, compared exactly (case-insensitively).
https://myapp.comis nothttp://myapp.com, nothttps://www.myapp.com, nothttps://myapp.com:8080. All four are distinct origins.
Common pitfalls
Section titled “Common pitfalls”- Missing scheme.
allowOrigins="myapp.com"won’t match anything. Origins always include the scheme:https://myapp.com. - Port drift.
https://myapp.comandhttps://myapp.com:8080are different origins. Dev on:3000and prod on:443need separate entries (or separate environments). - Subdomain drift.
https://myapp.comdoes not coverhttps://www.myapp.comorhttps://admin.myapp.com. List each. - Credentials with wildcard.
allowOrigins="*"+allowCredentials=truethrowsWheels.Cors.InvalidConfigurationat app start. Good — silent versions of this bug have haunted JavaScript developers for a decade. - No
Originheader, no CORS header. On specific-origin and comma-listallowOriginsconfigurations, requests that arrive without anOriginheader — same-origin browser navigation, server-to-server calls, andcurlwithout-H "Origin: ..."— never receive anAccess-Control-Allow-Originresponse header. This is correct CORS spec behavior. (Wildcard configurationsallowOrigins="*"are the exception: they emitAccess-Control-Allow-Origin: *on every response regardless of whetherOriginis set.) If you test a non-wildcard allowlist with barecurland see no header, add-H "Origin: https://yourallowedorigin.com"to verify it is working. - Double headers. If your controller also writes
Access-Control-Allow-Originviacfheader, you’ll end up with two values in one response. Browsers reject it. Pick one: the middleware or the controller, not both.
SSE and CORS
Section titled “SSE and CORS”EventSource — the browser API behind Server-Sent Events — can’t send custom headers and relies on the browser’s ambient cookie flow for authentication. If your SSE endpoint is cross-origin, you need allowCredentials=true and session cookies, and the allowed origin must be listed explicitly (no "*"). Everything else follows the rules above.