Rails rack-cors Allowlist Configuration and Initializer Placement
Failure symptom:
Access to XMLHttpRequest at 'https://api.parcelyard.io/api/v1/shipments' from
origin 'https://web.parcelyard.io' has been blocked by CORS policy: Response to
preflight request doesn't pass access control check: It does not have HTTP ok
status.
Chrome only prints “It does not have HTTP ok status” when the OPTIONS request came back with a non-2xx code — typically 404, sometimes 403 or 429. Something answered the preflight, and it was not rack-cors. This page belongs to Framework CORS Middleware Configuration, and it deals with the two Rails-specific reasons that happens: the middleware is not where you think it is, or its resource pattern never matched the path.
Root Cause
rack-cors is a Rack middleware, not a Rails controller concern, so its effect depends entirely on its position in the stack. Rails builds that stack top-down and hands the request to the first layer that will answer it. config.middleware.use Rack::Cors appends the middleware to the bottom of the stack, underneath ActionDispatch::HostAuthorization, any rate limiter you added, session handling and the rest — so a preflight that one of those layers rejects never reaches the CORS layer at all. And even in the right position, rack-cors only short-circuits an OPTIONS request when one of its resource globs matches the path; when nothing matches, the preflight continues down into Rails routing, where a route declared with get or post does not answer OPTIONS, and the router returns 404. Both roads end at the same console message, because from the browser’s point of view the only fact that matters is that the preflight was not 2xx — the classification the browser applies is described in Simple vs Preflight Requests: CORS Mechanics.
Position is the part most teams get wrong, because both spellings look equally reasonable in a diff:
Prerequisite State
- A Rails 6.1 or newer application. If it was generated with
--api,config/initializers/cors.rbalready exists as a commented-out template. - The frontend origin string is known exactly, including scheme and port:
https://web.parcelyard.ioin production,http://localhost:5173for a Vite dev server. - If the frontend sends cookies, the session cookie is already issued with
SameSite=None; Secure; CORS permission alone does not make a browser attach it. - No other layer is already emitting
Access-Control-Allow-Origin. A load balancer or an Nginxadd_headerin front of Puma will produce two copies of the header, which browsers reject outright — see Fixing Duplicate Access-Control-Allow-Origin Headers.
Step-by-Step Fix
Step 1 — Add the gem
# Gemfile
gem "rack-cors", "~> 2.0"
bundle install
Step 2 — Insert the middleware at position 0
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "https://web.parcelyard.io"
resource "/api/*",
headers: :any,
methods: %i[get post patch put delete options head],
credentials: true,
expose: %w[X-Request-Id X-Total-Count],
max_age: 600
end
end
insert_before 0 is not a stylistic preference. It places Rack::Cors above every layer Rails installed, which is the only position where a preflight is guaranteed to be answered before something else rejects it. Confirm the result rather than assuming it:
bin/rails middleware | head -3
# use Rack::Cors
# use ActionDispatch::HostAuthorization
# use Rack::Sendfile
Step 3 — Build the allowlist for real environments
Hard-coding one origin works until staging appears. Read the list from the environment and keep development permissive only for loopback:
# config/initializers/cors.rb
ALLOWED_ORIGINS = ENV.fetch("CORS_ORIGINS", "http://localhost:5173").split(",").map(&:strip).freeze
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins(*ALLOWED_ORIGINS)
resource "/api/*",
headers: :any,
methods: %i[get post patch put delete options head],
credentials: true,
max_age: 600
end
end
origins accepts three shapes, and they are tried in the order you list them until one matches:
| Form | Example | When to use it |
|---|---|---|
| Exact string | origins "https://web.parcelyard.io" |
Production and staging hosts you control |
| Regexp | origins %r{\Ahttps://[a-z0-9-]+\.preview\.parcelyard\.io\z} |
Generated preview hosts, anchored at both ends |
| Block | origins { |source, env| Tenant.origin?(source) } |
Origins held in the database for a multi-tenant API |
The block form runs on every request, so back it with a cache; a database round trip per preflight is a latency tax on every non-simple call, as Preflight vs Simple Request: The Performance Cost quantifies.
Step 4 — Keep credentials and wildcards apart
credentials: true and origins "*" are mutually exclusive: the browser refuses the combination, and rack-cors refuses to boot with it rather than shipping a policy no browser will honour. If part of your API really is public, express that as a second allow block over a different path — never by widening the credentialed one.
Rails.application.config.middleware.insert_before 0, Rack::Cors do
# Narrow, credentialed block FIRST
allow do
origins "https://web.parcelyard.io"
resource "/api/*", headers: :any, methods: :any, credentials: true, max_age: 600
end
# Public, credential-free block SECOND
allow do
origins "*"
resource "/public/*", headers: :any, methods: %i[get options], credentials: false
end
end
Order matters here in a way that is easy to miss. rack-cors walks the allow blocks in declaration order and uses the first one whose origin and path both match; a broad block written above a narrow one silently shadows it.
Step 5 — Turn on the debug log while you work
rack-cors can explain its own decisions. Enable it in development only; the output is noisy and prints request headers.
Rails.application.config.middleware.insert_before 0, Rack::Cors,
debug: Rails.env.development?,
logger: -> { Rails.logger } do
allow do
origins(*ALLOWED_ORIGINS)
resource "/api/*", headers: :any, methods: :any, credentials: true, max_age: 600
end
end
With debug on, every request prints whether it was treated as a preflight, which resource matched, and which headers were emitted — which turns “CORS is broken” into a specific line of the initializer within one request.
Verification
# 1) The preflight must be answered by rack-cors with a 2xx and the exact origin
curl -sS -i -X OPTIONS https://api.parcelyard.io/api/v1/shipments \
-H 'Origin: https://web.parcelyard.io' \
-H 'Access-Control-Request-Method: PATCH' \
-H 'Access-Control-Request-Headers: content-type,authorization' \
| grep -iE '^(HTTP|access-control|vary)'
# 2) An origin outside the allowlist must receive no access-control headers
curl -sS -i -X OPTIONS https://api.parcelyard.io/api/v1/shipments \
-H 'Origin: https://web.parcelyard.io.attacker.example' \
-H 'Access-Control-Request-Method: PATCH' | grep -ci access-control-allow-origin
# 3) The actual request must carry the echo too, not just the preflight
curl -sS -i https://api.parcelyard.io/api/v1/shipments \
-H 'Origin: https://web.parcelyard.io' | grep -iE '^(access-control|vary)'
Security Boundary Note
The most dangerous line you can write in this initializer is an unanchored regular expression. origins %r{parcelyard\.io} matches https://parcelyard.io.attacker.example and https://evil-parcelyard.io.example alike, because without \A and \z the pattern only has to appear somewhere in the origin string. Paired with credentials: true, that single missing anchor hands every authenticated response to any host an attacker can register. Anchor every pattern, escape every dot, and prefer an exact string list whenever the set of origins is finite. The wider family of matching mistakes is covered in Origin Matching Rules & Validation.
Common Mistakes
| Issue | Technical impact | Mitigation |
|---|---|---|
config.middleware.use Rack::Cors instead of insert_before 0 |
Host authorization, throttling or session layers answer the preflight first with no CORS headers | Insert at position 0 and verify with bin/rails middleware |
resource "*" narrowed to resource "api/*" without the leading slash |
The glob never matches, the preflight falls through to routing, and Rails returns 404 |
Write the pattern as /api/*, matching the request path exactly |
A wildcard allow block declared above the credentialed one |
The broad block claims every request; credentials are silently dropped | Declare narrow, credentialed blocks first |
Nginx or the load balancer also adding Access-Control-Allow-Origin |
Two header copies arrive and the browser rejects the response as malformed | Pick one authoritative layer; remove the header from the other |
FAQ
Why does rack-cors need insert_before 0 instead of config.middleware.use?
config.middleware.use appends the middleware to the bottom of the stack, below host authorization, rate limiting, session handling and everything else Rails ships. Any of those layers can answer the credential-free OPTIONS preflight first, and whatever they return carries no Access-Control headers. insert_before 0 puts rack-cors at the very top, so the preflight is answered and short-circuited before any other layer can reject it. Confirm the position with bin/rails middleware.
Can I keep origins ‘*’ for public endpoints and credentials for private ones?
Yes, but they must be separate allow blocks scoped to non-overlapping resource paths, and the credentialed block must come first. rack-cors evaluates allow blocks in declaration order and the first block whose origin and path both match wins, so a broad wildcard block declared above a credentialed one shadows it completely. Put the narrow, credentialed resource first and the public wildcard resource second.
Why does my preflight return 404 even though the endpoint exists?
Because no rack-cors resource pattern matched the request path, so the OPTIONS request fell through the middleware stack into Rails routing, and Rails does not answer OPTIONS for a route declared with get or post. Check that the resource glob covers the path exactly: a pattern of /api/* matches /api/v1/shipments but not /v1/shipments, and a leading slash is required. Turning on the debug option prints the matching decision for every request.