If you build a Rails API and your frontend runs on a different origin, CORS stops being theory pretty fast. You ship an endpoint, the browser blocks it, and suddenly everyone is staring at a console error that says “No ‘Access-Control-Allow-Origin’ header.”
Rails itself does not magically solve CORS. You need to configure it intentionally, and if you get lazy with wildcards or credentials, you can open up more access than you meant to.
What CORS actually does in a Rails API
CORS is a browser-enforced policy. Your Rails server can return JSON just fine to any client, but a browser decides whether frontend JavaScript is allowed to read that response.
A cross-origin request happens when scheme, host, or port changes:
https://app.example.com→https://api.example.comhttp://localhost:5173→http://localhost:3000https://example.com→http://example.com
For a browser to allow access, your Rails API needs to send the right headers.
A real example from api.github.com:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset
That tells the browser two useful things:
- any origin can read the response
- frontend code can access specific non-simple response headers like
ETagandLink
That second part gets missed a lot.
The Rails way: rack-cors
For Rails APIs, the standard approach is the rack-cors gem.
Add it to your Gemfile:
gem 'rack-cors'
Then install dependencies:
bundle install
Now configure it in config/initializers/cors.rb.
Basic CORS config for local development
Here’s a practical starting point for a frontend running on Vite or React dev server:
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'http://localhost:5173', 'http://127.0.0.1:5173'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ['ETag', 'Link'],
max_age: 600
end
end
A few opinions here:
insert_before 0matters. Put CORS middleware early so it runs before other middleware can interfere.- Don’t use
origins '*'in development unless you really mean “any website can read my API.” headers: :anyis usually fine for APIs.max_agereduces repeated preflight requests.
API-only Rails app example
If you generated your app with:
rails new my_api --api
rack-cors still works the same way. API-only mode does not remove the need for CORS config.
Sample controller:
class Api::V1::PostsController < ApplicationController
def index
response.set_header('ETag', 'W/"posts-v1"')
response.set_header('Link', '</api/v1/posts?page=2>; rel="next"')
render json: [
{ id: 1, title: "Hello" },
{ id: 2, title: "World" }
]
end
end
If your frontend wants to read ETag or Link using JavaScript, you must expose them in CORS:
expose: ['ETag', 'Link']
Without that, the browser receives the headers, but your frontend code can’t access them.
What preflight requests look like
Not every cross-origin request is a simple GET. Browsers send a preflight OPTIONS request before requests that use:
- custom headers like
Authorization - methods like
PUT,PATCH,DELETE - content types like
application/jsonin some cases that trigger preflight behavior
Example browser flow:
OPTIONS /api/v1/posts
Origin: http://localhost:5173
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
Your Rails API needs to respond with headers like:
Access-Control-Allow-Origin: http://localhost:5173
Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
rack-cors handles this for you if configured correctly. If preflight is failing, I usually check middleware order first.
Allowing credentials: cookies and session-based auth
This is where people break CORS security.
If you want cross-origin cookies or authenticated requests with credentials: 'include', you cannot use Access-Control-Allow-Origin: *.
Bad config:
allow do
origins '*'
resource '*',
headers: :any,
methods: [:get, :post],
credentials: true
end
Browsers reject this combination, and they should.
Correct approach:
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'https://app.example.com'
resource '/api/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true,
expose: ['X-Request-Id']
end
end
Frontend fetch example:
fetch("https://api.example.com/api/profile", {
method: "GET",
credentials: "include"
})
.then(res => res.json())
.then(data => console.log(data));
If you use session cookies across origins, you also need cookie settings that match modern browser rules, usually SameSite=None; Secure.
Restrict resources instead of allowing everything
A lot of examples use resource '*', which is fine for demos but sloppy in production.
I prefer this:
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'https://app.example.com'
resource '/api/v1/*',
headers: :any,
methods: [:get, :post, :patch, :delete, :options],
max_age: 600
end
end
That keeps CORS scoped to your API routes and avoids exposing unrelated endpoints.
Dynamic origin matching
Sometimes you need multiple subdomains or separate staging environments.
You can use a block:
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins do |source, _env|
allowed_hosts = [
'app.example.com',
'staging-app.example.com'
]
uri = URI.parse(source) rescue nil
uri && allowed_hosts.include?(uri.host)
end
resource '/api/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head]
end
end
I like this better than sprinkling regexes everywhere unless the subdomain pattern is truly stable.
Exposing response headers your frontend actually needs
By default, browser JavaScript can only read a limited set of “simple” response headers. If your API returns pagination or rate limit headers, expose them explicitly.
GitHub does this well. Their exposed headers include:
ETagLinkLocationRetry-AfterX-RateLimit-LimitX-RateLimit-RemainingX-RateLimit-Reset
Rails config example:
resource '/api/*',
headers: :any,
methods: [:get, :post, :options],
expose: [
'ETag',
'Link',
'Location',
'Retry-After',
'X-RateLimit-Limit',
'X-RateLimit-Remaining',
'X-RateLimit-Reset'
]
And on the frontend:
const res = await fetch("https://api.example.com/api/v1/posts");
console.log(res.headers.get("ETag"));
console.log(res.headers.get("Link"));
console.log(res.headers.get("X-RateLimit-Remaining"));
If res.headers.get(...) keeps returning null, your expose list is probably missing something.
Environment-specific config
I usually keep development and production origins separate.
# config/initializers/cors.rb
allowed_origins =
if Rails.env.production?
['https://app.example.com']
else
['http://localhost:5173', 'http://127.0.0.1:5173']
end
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins(*allowed_origins)
resource '/api/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
max_age: 600
end
end
Simple beats clever here.
Debugging CORS in Rails without guessing
When CORS fails, the browser error is often vague. I check these in order:
1. Is the request reaching Rails?
Look at development logs or production request logs.
2. Is the Origin header present?
No Origin, no CORS behavior.
3. Is the origin exactly allowed?
http://localhost:3000 and http://127.0.0.1:3000 are different origins.
4. Is preflight failing?
Use curl:
curl -i -X OPTIONS http://localhost:3000/api/v1/posts \
-H "Origin: http://localhost:5173" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization,content-type"
5. Are headers exposed?
If the request succeeds but JS can’t read ETag or Link, that’s an Access-Control-Expose-Headers issue.
If you want a quick way to inspect live response headers and verify what your API is actually returning, headertest.com is handy.
CORS is not your auth layer
This part gets misunderstood constantly.
CORS does not protect your API from non-browser clients. It does not replace authentication, authorization, CSRF protection, or rate limiting. It only controls what browsers allow frontend JavaScript to read across origins.
If your API is public, Access-Control-Allow-Origin: * may be perfectly fine. GitHub does it for public endpoints. If your API uses cookies or sensitive user data, lock origins down tightly.
And if you’re reviewing headers more broadly, CORS is only one piece of the response hardening story. Things like CSP, HSTS, and framing rules live elsewhere; if you need a reference for that side of the stack, csp-guide.com is worth keeping around.
A solid production starter config
Here’s a config I’d actually ship for a token-based Rails API behind a separate frontend:
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'https://app.example.com'
resource '/api/v1/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ['ETag', 'Link', 'X-Request-Id', 'X-RateLimit-Remaining'],
max_age: 600
end
end
And for a cookie-based app:
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'https://app.example.com'
resource '/api/v1/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true,
expose: ['X-Request-Id'],
max_age: 600
end
end
That’s enough for most Rails APIs. The trick is resisting the urge to slap * everywhere and call it done. CORS is easy when your policy is clear. It gets ugly when your frontend, auth model, and infrastructure all disagree about what “allowed” means.