Skip to content

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.Cors globally with an explicit origin allowlist
  • The full set of constructor options and their real defaults
  • How preflight (OPTIONS) requests are handled automatically
  • What allowCredentials=true unlocks — 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

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).

Register Cors in config/settings.cfm with an explicit allowlist:

config/settings.cfm
<cfscript>
set(middleware = [
new wheels.middleware.Cors(
allowOrigins="https://myapp.com,https://admin.myapp.com",
allowCredentials=true,
maxAge=3600
)
]);
</cfscript>

These are the constructor arguments on wheels.middleware.Cors, verified against vendor/wheels/middleware/Cors.cfc:

OptionDefaultMeaning
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.
allowCredentialsfalseWhen true, browsers may include cookies and Authorization headers. Forbids allowOrigins="*".
maxAge86400 (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.

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 Origin is in allowOrigins (or allowOrigins is "*"), the middleware writes Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, optionally Access-Control-Allow-Credentials, plus Access-Control-Max-Age, and returns an empty response without running your controller.
  • If the Origin is not in the allowlist, the middleware still short-circuits the OPTIONS request with an empty body but omits the Access-Control-Allow-* headers. The browser sees no opt-in and blocks the subsequent real request. (Access-Control-Max-Age is emitted on every OPTIONS response, including disallowed origins.)

You do not write an options action on your controllers. The middleware owns OPTIONS entirely.

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:

  1. allowOrigins="*" is forbidden. The CORS spec bans this combination, browsers will reject the response, and the middleware throws Wheels.Cors.InvalidConfiguration at construction time to fail loud instead of silently.
  2. The Access-Control-Allow-Origin response header must be the specific requesting origin, not *. The middleware echoes the matched origin automatically when it’s in your allowlist.
  3. 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.

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():

config/routes.cfm
<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.

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:
    Terminal window
    curl -H "Origin: https://client.myapp.com" -v https://api.myapp.com/posts
    Look for Access-Control-Allow-Origin in the response. If it’s missing, the middleware rejected your origin.
  • Reproduce the preflight. To see what the middleware returns for an OPTIONS preflight:
    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.com is not http://myapp.com, not https://www.myapp.com, not https://myapp.com:8080. All four are distinct origins.
  • Missing scheme. allowOrigins="myapp.com" won’t match anything. Origins always include the scheme: https://myapp.com.
  • Port drift. https://myapp.com and https://myapp.com:8080 are different origins. Dev on :3000 and prod on :443 need separate entries (or separate environments).
  • Subdomain drift. https://myapp.com does not cover https://www.myapp.com or https://admin.myapp.com. List each.
  • Credentials with wildcard. allowOrigins="*" + allowCredentials=true throws Wheels.Cors.InvalidConfiguration at app start. Good — silent versions of this bug have haunted JavaScript developers for a decade.
  • No Origin header, no CORS header. On specific-origin and comma-list allowOrigins configurations, requests that arrive without an Origin header — same-origin browser navigation, server-to-server calls, and curl without -H "Origin: ..." — never receive an Access-Control-Allow-Origin response header. This is correct CORS spec behavior. (Wildcard configurations allowOrigins="*" are the exception: they emit Access-Control-Allow-Origin: * on every response regardless of whether Origin is set.) If you test a non-wildcard allowlist with bare curl and see no header, add -H "Origin: https://yourallowedorigin.com" to verify it is working.
  • Double headers. If your controller also writes Access-Control-Allow-Origin via cfheader, you’ll end up with two values in one response. Browsers reject it. Pick one: the middleware or the controller, not both.

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.