Overview
Cross-Origin Resource Sharing (CORS) defines a protocol for browsers to determine whether cross-origin requests from web applications should be permitted. By default, browsers enforce the same-origin policy, which prevents JavaScript code from one origin from accessing resources at a different origin. CORS provides a controlled way to relax this restriction through a series of HTTP headers exchanged between the browser and server.
An origin consists of three components: protocol (scheme), domain (host), and port. Two URLs have the same origin only when all three components match exactly. For example, https://api.example.com:443/data and https://example.com:443/data have different origins due to different subdomains, even though they share the same protocol and port.
CORS applies only to requests initiated by browser-based code such as XMLHttpRequest or Fetch API calls. It does not affect server-to-server communication or requests from non-browser clients. The mechanism protects users by preventing malicious websites from making unauthorized requests on their behalf to other sites where they might be authenticated.
The CORS protocol distinguishes between simple requests and preflighted requests. Simple requests meet specific criteria regarding HTTP methods, headers, and content types. Browsers send these directly with an Origin header. Preflighted requests involve an additional OPTIONS request that the browser sends before the actual request to verify permissions. The server responds to this preflight with headers indicating which origins, methods, and headers are allowed.
# Example origins and their relationships
origin_a = "https://app.example.com"
origin_b = "https://app.example.com:443" # Same as origin_a (default HTTPS port)
origin_c = "http://app.example.com" # Different (protocol mismatch)
origin_d = "https://api.example.com" # Different (subdomain mismatch)
origin_e = "https://app.example.com:8443" # Different (port mismatch)
Key Principles
CORS operates through a request-response header exchange between the browser and server. When a browser makes a cross-origin request, it includes an Origin header identifying the requesting page's origin. The server examines this header and decides whether to permit the request by including appropriate CORS headers in the response.
The fundamental CORS header is Access-Control-Allow-Origin, which specifies which origins can access the resource. The server can return a specific origin, a wildcard * to allow any origin, or omit the header entirely to deny access. When credentials are involved (cookies, HTTP authentication), the wildcard is not permitted, and the server must explicitly name the allowed origin.
Simple requests bypass the preflight mechanism when they meet these conditions: the HTTP method is GET, HEAD, or POST; only simple headers are used (Accept, Accept-Language, Content-Language, Content-Type); and if Content-Type is present, its value is application/x-www-form-urlencoded, multipart/form-data, or text/plain. Any deviation from these criteria triggers a preflight.
The preflight request uses the OPTIONS method and includes Access-Control-Request-Method and optionally Access-Control-Request-Headers to inform the server which method and headers the actual request will use. The server responds with headers indicating permissions: Access-Control-Allow-Methods lists permitted methods, Access-Control-Allow-Headers lists permitted headers, and Access-Control-Max-Age specifies how long the preflight response can be cached.
Credentials present a special case in CORS. By default, cross-origin requests do not include credentials. The client must explicitly set the credentials flag, and the server must respond with Access-Control-Allow-Credentials: true. When credentials are involved, Access-Control-Allow-Origin cannot be *, and Access-Control-Allow-Headers and Access-Control-Allow-Methods cannot use wildcards either.
# Simple request - no preflight needed
# GET request with standard headers
response = HTTP.get("https://api.other-domain.com/data")
# Preflighted request - OPTIONS sent first
# PUT request with custom header triggers preflight
response = HTTP.headers("X-Custom-Header" => "value")
.put("https://api.other-domain.com/data", json: {data: "value"})
CORS provides additional headers for fine-grained control. Access-Control-Expose-Headers lists response headers that the browser should make available to the requesting code, as browsers only expose simple response headers by default. Access-Control-Allow-Private-Network addresses requests to private network resources from public websites, adding another security boundary.
The browser enforces CORS restrictions, not the server. The server receives and processes requests regardless of CORS headers. CORS prevents the browser JavaScript from reading the response if the headers are incorrect. This means CORS does not prevent the request from executing; it only prevents the client code from accessing the response data.
Ruby Implementation
Ruby web applications typically implement CORS through Rack middleware, which intercepts HTTP requests and adds appropriate headers before passing responses back to the client. The most common approach uses the rack-cors gem, which provides a domain-specific language for configuring CORS policies.
# Gemfile
gem 'rack-cors'
# config.ru or Rails config/application.rb
require 'rack/cors'
use Rack::Cors do
allow do
origins 'https://frontend.example.com'
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true
end
end
Rails applications can configure CORS in the initializers directory, creating a dedicated configuration file that gets loaded during application startup. The middleware integrates seamlessly with the Rails middleware stack.
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'localhost:3000', '127.0.0.1:3000', 'https://app.example.com'
resource '/api/*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
credentials: true,
max_age: 86400
end
allow do
origins '*'
resource '/public/*',
headers: :any,
methods: [:get, :head],
credentials: false
end
end
The origins method accepts multiple forms: a single string, an array of strings, a regular expression, or a block that receives the origin and returns a boolean. Regular expressions provide flexible matching for subdomain patterns or multiple environments.
# Regular expression for subdomain matching
allow do
origins /\Ahttps:\/\/.*\.example\.com\z/
resource '*', headers: :any, methods: [:get, :post]
end
# Block-based dynamic origin validation
allow do
origins do |source, env|
# Check against database of registered client origins
AllowedOrigin.exists?(url: source)
end
resource '*', headers: :any, methods: [:get, :post]
end
Resource-level configuration controls which paths and methods are exposed. The resource method accepts a path pattern and options hash. Multiple resource blocks enable different policies for different API endpoints.
allow do
origins 'https://trusted.example.com'
# Public read-only endpoints
resource '/api/v1/public/*',
headers: :any,
methods: [:get, :head],
credentials: false
# Authenticated endpoints
resource '/api/v1/users/*',
headers: :any,
methods: [:get, :post, :put, :delete, :options],
credentials: true,
expose: ['X-Total-Count', 'X-Page']
# Admin endpoints with additional restrictions
resource '/api/v1/admin/*',
headers: ['Authorization', 'Content-Type'],
methods: [:get, :post, :put, :delete],
credentials: true
end
Manual header implementation offers more control for applications not using rack-cors. This approach sets headers directly in controller actions or middleware.
# Manual CORS headers in Rails controller
class ApiController < ApplicationController
before_action :set_cors_headers
def set_cors_headers
allowed_origins = [
'https://app.example.com',
'https://staging.example.com'
]
origin = request.headers['Origin']
if allowed_origins.include?(origin)
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Methods'] = 'GET, POST, PUT, DELETE, OPTIONS'
response.headers['Access-Control-Allow-Headers'] = 'Origin, Content-Type, Accept, Authorization'
response.headers['Access-Control-Allow-Credentials'] = 'true'
response.headers['Access-Control-Max-Age'] = '86400'
end
end
# Handle preflight requests
def preflight
head :ok
end
end
# routes.rb
match '*path', to: 'api#preflight', via: :options
Sinatra applications configure CORS similarly, either through rack-cors in the config.ru file or manually in before filters.
# Sinatra with rack-cors
require 'sinatra'
require 'rack/cors'
use Rack::Cors do
allow do
origins 'https://frontend.example.com'
resource '*', headers: :any, methods: [:get, :post, :delete]
end
end
# Manual Sinatra CORS
before do
origin = request.env['HTTP_ORIGIN']
allowed = ['https://frontend.example.com', 'https://staging.frontend.com']
if allowed.include?(origin)
headers 'Access-Control-Allow-Origin' => origin,
'Access-Control-Allow-Methods' => 'GET, POST, DELETE, OPTIONS',
'Access-Control-Allow-Headers' => 'Content-Type, Accept',
'Access-Control-Allow-Credentials' => 'true'
end
end
options '*' do
200
end
Security Implications
CORS misconfigurations create significant security vulnerabilities. The most critical mistake involves using Access-Control-Allow-Origin: * with credentials enabled, which browsers reject. However, dynamically reflecting the Origin header without validation effectively creates the same vulnerability while bypassing the browser's protection.
# DANGEROUS: Reflects any origin
before_action :set_cors_headers
def set_cors_headers
# Accepts any origin - major security flaw
response.headers['Access-Control-Allow-Origin'] = request.headers['Origin']
response.headers['Access-Control-Allow-Credentials'] = 'true'
end
This pattern allows any website to make authenticated requests to the API, potentially exposing user data or enabling unauthorized actions. An attacker hosts a malicious site that makes requests to the API, and because the server reflects the attacker's origin, the browser permits the request and includes the user's cookies.
Proper origin validation requires explicit allowlisting. Applications should maintain a list of permitted origins and verify the request origin against this list before setting the CORS headers.
# Secure origin validation
ALLOWED_ORIGINS = [
'https://app.example.com',
'https://app.example.co.uk',
'https://mobile.example.com'
].freeze
before_action :set_cors_headers
def set_cors_headers
origin = request.headers['Origin']
if ALLOWED_ORIGINS.include?(origin)
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
end
end
Regular expression validation requires careful construction to prevent bypasses. Poorly written patterns can match unintended origins.
# DANGEROUS: Weak regex allows subdomain.evil.com
origins /example\.com/
# SAFE: Anchored regex with proper escaping
origins /\Ahttps:\/\/([a-z0-9-]+\.)?example\.com\z/
The preflight cache presents a security consideration. Long cache times improve performance but prevent rapid policy changes. If an origin becomes compromised and needs immediate removal from the allowlist, cached preflights continue allowing requests until the cache expires. Balance performance against the ability to revoke access quickly.
CORS does not prevent requests from being sent and processed. A common misconception holds that CORS prevents unauthorized access to APIs. CORS only controls whether browser JavaScript can read the response. State-changing operations execute regardless of CORS headers. Applications must implement authentication and authorization separately from CORS.
# CORS does not replace authentication
class UsersController < ApplicationController
before_action :set_cors_headers
before_action :authenticate_user! # Still required
before_action :authorize_user! # Still required
def update
# Request processes even with CORS failure
# Must validate user permissions
if current_user.can_update?(@user)
@user.update(user_params)
end
end
end
Exposing sensitive information through headers requires careful consideration. The Access-Control-Expose-Headers configuration determines which response headers JavaScript can read. Accidentally exposing headers containing sensitive data or internal system information creates information disclosure vulnerabilities.
# Review exposed headers carefully
resource '/api/*',
expose: ['X-Total-Count', 'X-Request-ID'], # Safe
# Avoid: 'X-Internal-Server', 'X-Database-Query-Time'
credentials: true
Subdomain attacks exploit overly permissive origin matching. If an attacker controls any subdomain and the CORS policy allows all subdomains, they can make requests from the compromised subdomain. Wildcard subdomain matching requires confidence in subdomain security.
Implementation Approaches
CORS implementation strategies vary based on application architecture, deployment environment, and security requirements. The middleware approach centralizes CORS handling, making policies consistent across all endpoints and separating concerns between business logic and cross-origin handling.
Middleware-based CORS works well for APIs serving multiple client applications. Configuration lives in a single location, and changes propagate automatically to all endpoints. This approach handles preflight requests transparently without requiring explicit OPTIONS route definitions.
# Middleware approach - centralized configuration
Rails.application.config.middleware.insert_before 0, Rack::Cors do
# Development environment - permissive
if Rails.env.development?
allow do
origins 'localhost:3000', '127.0.0.1:3000'
resource '*', headers: :any, methods: :any
end
end
# Production environment - restrictive
if Rails.env.production?
allow do
origins ENV.fetch('ALLOWED_ORIGINS', '').split(',')
resource '/api/v1/*',
headers: :any,
methods: [:get, :post, :put, :delete, :options],
credentials: true,
max_age: 86400
end
end
end
Controller-level implementation provides granular control when different endpoints require different CORS policies. This approach fits applications where certain controllers serve public data while others handle authenticated operations.
# Controller-level approach
class PublicApiController < ApplicationController
before_action :set_public_cors
def set_public_cors
response.headers['Access-Control-Allow-Origin'] = '*'
response.headers['Access-Control-Allow-Methods'] = 'GET, HEAD'
end
end
class AuthenticatedApiController < ApplicationController
before_action :set_authenticated_cors
TRUSTED_ORIGINS = ENV['TRUSTED_ORIGINS'].split(',').freeze
def set_authenticated_cors
origin = request.headers['Origin']
if TRUSTED_ORIGINS.include?(origin)
response.headers['Access-Control-Allow-Origin'] = origin
response.headers['Access-Control-Allow-Credentials'] = 'true'
end
end
end
Proxy-based CORS handling moves policy enforcement to a reverse proxy or API gateway. This approach removes CORS handling from application code entirely, making it the infrastructure's responsibility. Nginx, Apache, or cloud API gateways enforce CORS policies before requests reach the application.
# Nginx CORS configuration
location /api/ {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Authorization, Content-Type';
add_header 'Access-Control-Max-Age' 86400;
return 204;
}
add_header 'Access-Control-Allow-Origin' 'https://app.example.com';
add_header 'Access-Control-Allow-Credentials' 'true';
proxy_pass http://application_server;
}
Dynamic origin validation against a database supports applications where clients register dynamically. This pattern requires a query per request but enables flexible client management without redeployment.
# Database-backed origin validation
class AllowedOrigin < ApplicationRecord
validates :origin, presence: true, uniqueness: true
validates :origin, format: { with: URI::DEFAULT_PARSER.make_regexp(['http', 'https']) }
end
use Rack::Cors do
allow do
origins do |source, env|
AllowedOrigin.exists?(origin: source, active: true)
end
resource '*', headers: :any, methods: :any, credentials: true
end
end
Environment-based configuration separates CORS policies across deployment environments. Development environments use permissive policies for convenience, while production environments enforce strict controls.
# Environment-specific configuration
class CorsConfiguration
def self.allowed_origins
case Rails.env
when 'development'
['http://localhost:3000', 'http://localhost:8080']
when 'staging'
['https://staging.example.com']
when 'production'
['https://app.example.com', 'https://www.example.com']
end
end
end
use Rack::Cors do
allow do
origins CorsConfiguration.allowed_origins
resource '*', headers: :any, methods: :any, credentials: true
end
end
Practical Examples
A typical single-page application scenario involves a React or Vue.js frontend hosted on one domain making API requests to a Rails backend on a different domain. The frontend runs on https://app.example.com while the API serves from https://api.example.com.
# API server configuration
# 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],
credentials: true,
expose: ['X-Total-Count', 'X-Per-Page'],
max_age: 600
end
end
# Frontend JavaScript making authenticated request
fetch('https://api.example.com/api/v1/users', {
method: 'GET',
credentials: 'include', // Sends cookies
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + authToken
}
})
Multiple frontend applications accessing the same API require careful origin management. An organization might have production, staging, and development environments plus mobile web views, each needing access.
# Multiple environment support
class CorsOrigins
PRODUCTION = [
'https://app.example.com',
'https://www.example.com',
'https://mobile.example.com'
].freeze
STAGING = [
'https://staging-app.example.com',
'https://staging-mobile.example.com'
].freeze
DEVELOPMENT = [
'http://localhost:3000',
'http://localhost:8080',
'http://192.168.1.100:3000' # Local network testing
].freeze
def self.all
case Rails.env
when 'production'
PRODUCTION
when 'staging'
STAGING + PRODUCTION # Staging can access both
when 'development', 'test'
DEVELOPMENT + STAGING + PRODUCTION # Dev has access to all
end
end
end
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins CorsOrigins.all
resource '/api/*', headers: :any, methods: :any, credentials: true
end
end
File upload scenarios present specific challenges because they often use custom content types and may exceed simple request criteria. An application allowing users to upload images requires proper CORS configuration for preflight requests.
# File upload endpoint CORS
allow do
origins 'https://app.example.com'
resource '/api/v1/uploads',
headers: ['Content-Type', 'Content-Disposition', 'Accept'],
methods: [:post, :options],
credentials: true,
max_age: 1800
end
# Controller handling upload
class UploadsController < ApplicationController
def create
uploaded_file = params[:file]
if uploaded_file.content_type.start_with?('image/')
# Process upload
render json: { url: uploaded_file.url }, status: :created
else
render json: { error: 'Invalid file type' }, status: :unprocessable_entity
end
end
end
Third-party widget integration demonstrates CORS configuration for embedded components. A widget hosted on widget.example.com needs to communicate with the main API while being embedded in customer sites.
# Widget API configuration
allow do
# Widget origin
origins 'https://widget.example.com'
resource '/api/v1/widget/*',
headers: :any,
methods: [:get, :post],
credentials: false, # Public widget, no credentials
max_age: 86400
end
# Widget initialization endpoint
class WidgetController < ApplicationController
def initialize_widget
render json: {
widget_id: SecureRandom.uuid,
config: WidgetConfiguration.for_domain(params[:domain])
}
end
def track_event
WidgetEvent.create!(
widget_id: params[:widget_id],
event_type: params[:event_type],
data: params[:data]
)
head :ok
end
end
Common Pitfalls
Wildcard origin with credentials represents the most common CORS mistake. Browsers explicitly forbid this combination, yet developers frequently attempt it hoping for maximum flexibility.
# FAILS: Browser rejects this combination
resource '*',
headers: :any,
methods: :any,
credentials: true # Cannot use wildcard with credentials: true
# Alternative that works but is insecure
allow do
origins do |source, env|
true # Accepts any origin - security vulnerability
end
resource '*', credentials: true
end
Missing OPTIONS route handling causes preflight requests to fail with 404 responses. Applications must handle OPTIONS requests for any endpoint that might receive preflighted requests.
# Missing OPTIONS handling causes failures
resources :users do
member do
put :update # Has route for PUT
# Missing: match :update, via: :options
end
end
# Correct implementation
resources :users do
member do
put :update
match :update, via: :options # Explicit OPTIONS route
end
end
# Or use rack-cors which handles OPTIONS automatically
Incorrect header names in Access-Control-Allow-Headers prevent legitimate requests. The header names must exactly match what the client sends in Access-Control-Request-Headers during preflight.
# Client sends these headers
fetch(url, {
headers: {
'Content-Type': 'application/json',
'X-API-Key': apiKey,
'X-Request-ID': requestId
}
})
# Server must allow exact names (case-insensitive)
resource '*',
headers: ['Content-Type', 'X-API-Key', 'X-Request-ID']
# headers: :any works but is less secure
Caching preflight responses for too long prevents quick policy updates. When an origin becomes compromised, cached preflight responses continue allowing requests until expiry.
# Very long cache - inflexible
resource '*', max_age: 86400 # 24 hours
# Balanced approach
resource '/api/v1/public/*', max_age: 3600 # 1 hour for public
resource '/api/v1/users/*', max_age: 600 # 10 minutes for authenticated
resource '/api/v1/admin/*', max_age: 0 # No cache for admin
Forgetting to expose custom response headers prevents client code from accessing them. Browsers only expose simple response headers automatically.
# Server sends custom headers
def index
response.headers['X-Total-Count'] = User.count.to_s
response.headers['X-Page-Size'] = '20'
render json: users
end
# But doesn't expose them - client cannot read
resource '*',
methods: [:get],
credentials: true
# Missing: expose: ['X-Total-Count', 'X-Page-Size']
# Correct configuration
resource '*',
methods: [:get],
credentials: true,
expose: ['X-Total-Count', 'X-Page-Size']
Development versus production configuration mismatches cause unexpected failures when deploying. Permissive development settings work locally but fail in production with stricter policies.
# Development - permissive, works everywhere
if Rails.env.development?
allow do
origins '*'
resource '*', headers: :any, methods: :any
end
end
# Production - but frontend URL was missed
if Rails.env.production?
allow do
origins 'https://api.example.com' # Wrong - this is the API domain
# Should be: 'https://app.example.com' # The frontend domain
resource '*', headers: :any, methods: :any
end
end
Port mismatches cause origin comparison failures. Default ports (80 for HTTP, 443 for HTTPS) are equivalent to explicit port specifications, but non-standard ports must match exactly.
# These are equivalent - same origin
'https://example.com'
'https://example.com:443'
# These are different origins
'https://example.com'
'https://example.com:8443'
# Configuration must match exactly
allow do
origins 'https://app.example.com:8080' # Must include port if non-standard
resource '*', methods: :any
end
Reference
CORS Headers
| Header | Direction | Purpose |
|---|---|---|
| Origin | Request | Identifies the requesting origin |
| Access-Control-Allow-Origin | Response | Specifies allowed origin or wildcard |
| Access-Control-Allow-Methods | Response | Lists permitted HTTP methods |
| Access-Control-Allow-Headers | Response | Lists permitted request headers |
| Access-Control-Allow-Credentials | Response | Indicates if credentials are allowed |
| Access-Control-Expose-Headers | Response | Lists headers accessible to client code |
| Access-Control-Max-Age | Response | Specifies preflight cache duration in seconds |
| Access-Control-Request-Method | Preflight Request | Indicates intended method for actual request |
| Access-Control-Request-Headers | Preflight Request | Lists headers intended for actual request |
Simple Request Criteria
| Aspect | Requirement |
|---|---|
| Method | GET, HEAD, or POST only |
| Headers | Accept, Accept-Language, Content-Language, Content-Type, Range |
| Content-Type Values | application/x-www-form-urlencoded, multipart/form-data, text/plain |
| Event Listeners | No ReadableStream used in request body |
| XMLHttpRequest Upload | No event listeners registered |
Rack::Cors Configuration Options
| Option | Type | Description |
|---|---|---|
| origins | String, Array, Regex, Proc | Specifies allowed origins |
| resource | String | Path pattern for matching resources |
| methods | Array, Symbol | HTTP methods to allow |
| headers | Array, Symbol | Request headers to allow |
| expose | Array | Response headers to expose |
| credentials | Boolean | Whether to allow credentials |
| max_age | Integer | Preflight cache duration in seconds |
| if | Proc | Conditional logic for applying rules |
Common Origin Patterns
# Single origin
origins 'https://app.example.com'
# Multiple origins
origins 'https://app.example.com', 'https://mobile.example.com'
# Array
origins ['https://app.example.com', 'https://www.example.com']
# All subdomains
origins /\Ahttps:\/\/([a-z0-9-]+\.)?example\.com\z/
# Dynamic validation
origins do |source, env|
AllowedOrigin.exists?(origin: source)
end
# Environment-based
origins(Rails.env.production? ? 'https://app.example.com' : '*')
Security Checklist
| Check | Requirement |
|---|---|
| Origin Validation | Explicit allowlist, not reflection |
| Wildcard Usage | Never with credentials |
| Regex Patterns | Properly anchored and escaped |
| Sensitive Headers | Not included in expose list |
| State Changes | Protected by authentication/authorization |
| Cache Duration | Appropriate for security requirements |
| HTTPS | Required for production credentials |
| Error Responses | Do not reveal internal details |
Troubleshooting Decision Tree
| Symptom | Likely Cause | Solution |
|---|---|---|
| 404 on preflight | Missing OPTIONS route | Add OPTIONS handler or use middleware |
| Origin null in logs | File protocol or privacy mode | Use local server, not file:// |
| Request succeeds but response inaccessible | Missing CORS headers | Add Access-Control-Allow-Origin |
| Preflight succeeds but main request fails | Method not allowed | Add method to Access-Control-Allow-Methods |
| Cannot read custom headers | Headers not exposed | Add to Access-Control-Expose-Headers |
| Works in Postman but fails in browser | CORS not configured | Configure CORS headers |
| Wildcard rejected with credentials | Invalid configuration | Use specific origin or remove credentials |
| Works locally but fails in production | Environment mismatch | Verify production origin configuration |