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.comhttps://api.example.com
  • http://localhost:5173http://localhost:3000
  • https://example.comhttp://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 ETag and Link

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 0 matters. 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: :any is usually fine for APIs.
  • max_age reduces 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/json in 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:

  • ETag
  • Link
  • Location
  • Retry-After
  • X-RateLimit-Limit
  • X-RateLimit-Remaining
  • X-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.