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:

Where Rack::Cors sits in the Rails middleware stack An OPTIONS request enters the top of the Rails middleware stack. Rack::Cors inserted before position zero answers it immediately, while the layers below — host authorization, throttling, sessions and the router — never see it. A footnote warns that appending the middleware instead places it below every one of those layers. OPTIONS /api/v1/shipments arrives Rack::Cors — insert_before 0 ActionDispatch::HostAuthorization Rack::Attack throttling ActionDispatch::Cookies and Session fifteen further Rails layers Rails router — no OPTIONS route answers the preflight here and stops the descent a throttle above CORS would answer the preflight 429 an unmatched OPTIONS ends here as a bare 404 config.middleware.use Rack::Cors appends it below every layer drawn above — the same code, the wrong seat

Prerequisite State

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.

How rack-cors resolves an incoming Origin The Origin header is compared against each origins entry in declaration order: exact strings first, then a regular expression, then a block. The first entry that matches causes the exact origin to be echoed; if none match, no Access-Control headers are added at all. Entries are tested in declaration order and the first match wins Origin header on the incoming request exact strings byte-for-byte compare anchored Regexp preview hosts block, source and env tenant lookup match: the exact origin string is echoed back and Origin is appended to Vary no match: no Access-Control headers A denied origin still gets a normal response body — the browser, not Rails, is what withholds it from script

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.

The first matching allow block wins A request for an API path is tested against two allow blocks. When the wildcard block is declared first it matches everything, returns a wildcard origin without credentials, and the credentialed block below it is never evaluated. GET /api/v1/shipments with a session cookie allow block 1 — origins "*", resource "*" path matches, origin matches, so this block answers allow block 2 — exact origin, credentials: true never evaluated: block 1 already claimed the request the cookie is sent but the response is unreadable Emitted headers: Access-Control-Allow-Origin: * and no Access-Control-Allow-Credentials The browser drops the response because the credentials mode was include Fix: declare the narrow credentialed block above the public wildcard block

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.