0%
Reading Settings
Font Size
18px
Line Height
1.5
Letter Spacing
0.01em
Font Family
Table of contents
CORS and CSRF: How Attackers Exploit the Gaps
Software Engineer
Software Engineer
I used to think CORS was a security feature. It is, partially. But the more I understood it, the more I realized how narrow its protection actually is. This post walks through how CORS works under the hood, where it breaks down, what CSRF is, and how an attacker can chain both to do real damage.
What is an origin?
Before anything else, the browser defines origin as the combination of scheme, host, and port. All three must match for two URLs to be considered the same origin.
// language: javascript # Same origin https://app.com/dashboard https://app.com/api/users # Different origin — different host https://evil.com https://app.com # Different origin — different scheme http://app.com https://app.com # Different origin — different port https://app.com:3000 https://app.com:4000
The Same-Origin Policy (SOP) is the browser's default: scripts on one origin cannot read responses from another.
How Cross-Origin Resource Sharing (CORS) works
When a browser makes a cross-origin request, the server responds with headers that tell the browser whether to allow the script to read the response.
// language: javascript # Server response headers Access-Control-Allow-Origin: https://app.com Access-Control-Allow-Methods: GET, POST Access-Control-Allow-Headers: Content-Type, Authorization Access-Control-Allow-Credentials: true
If the origin is not listed, the browser blocks the JavaScript from reading the response. The key point most people miss: the server still received and processed the request. CORS only controls whether the browser exposes the response to the calling script.
Note: CORS is enforced by the browser. curl, Postman, and server-to-server requests are completely unaffected. CORS protects users' browsers, not your API endpoints.
Simple requests vs. preflight
This is where it gets subtle. Not all cross-origin requests go through the same path. The browser divides them into two categories.
Simple requests
A request is "simple" when all three conditions hold: the method is GET , POST , or HEAD ; the headers are only basic ones like Accept or Content-Type; and the content type is one of application/x-www-form-urlencoded , multipart/form-data , or text/plain .
For simple requests, the browser sends the request immediately. The server processes it, responds, and only then does the browser decide whether to expose the response to JavaScript. If the origin isn't allowed, the script never sees the data. But the action already happened.
Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#simple_requests
Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#simple_requests
Preflighted requests
Any request that breaks the simple criteria: PUT , DELETE , a POST with application/json, custom headers will trigger a preflight. The browser first sends an OPTIONS request to ask for permission.
// language: javascript # Browser sends first: OPTIONS /api/transfer HTTP/1.1 Origin: https://evil.com Access-Control-Request-Method: POST Access-Control-Request-Headers: Content-Type # Server responds: Access-Control-Allow-Origin: https://app.com # evil.com is not listed — browser aborts the real request
For preflighted requests, CORS genuinely prevents the request from reaching the server if the origin is not permitted. This is the meaningful protection.
Ref: https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS#preflighted_requests
Attacking a misconfigured CORS
Attack scenario
CORS misconfiguration is common. The most dangerous pattern is reflecting the origin back dynamically without any validation.
In Rails, CORS is handled by therack-cors gem. You configure it in an initializer.
In Rails, CORS is handled by the
// language: ruby
# config/initializers/cors.rb — VULNERABLE
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins do |source, env|
source # blindly trust any origin
end
resource '*',
headers: :any,
methods: [:get, :post, :put, :delete],
credentials: true
end
endAn attacker hosts a page at evil.com . They send a credentialed fetch to api.bank.com/account . The server reflects evil.com as the allowed origin and sets Allow-Credentials: true . The browser happily exposes the full response, including account numbers, tokens, or session data to the attacker's script.
// language: javascript
# Attacker's page on evil.com
fetch('https://api.bank.com/account', {
credentials: 'include' // sends session cookies
})
.then(r => r.json())
.then(data => {
// attacker now has your account data
stealMoney(data);
});Another common mistake: using a loose regex to validate origins.
// language: ruby # Broken origin check origins /bank\.com/ # Passes for: https://evil-bank.com, https://notabank.com
How to fix this
Maintain an explicit allowlist. Never reflect the incoming origin and never use loose string matching.
// language: ruby
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'https://app.bank.com', 'https://admin.bank.com'
resource '/api/*',
headers: :any,
methods: [:get, :post, :put, :delete, :options],
credentials: true
end
endCSRF: what CORS doesn't cover
Cross-Site Request Forgery is a different class of attack. Instead of trying to read your data, an attacker tricks your browser into sending a request that carries your credentials, without needing to read the response at all.
This works because browsers automatically attach cookies to requests that match the cookie's domain, regardless of which site initiated the request. CORS only governs whether JavaScript can read the response. It says nothing about whether the browser should send the cookie.
CORS blocks cross-origin reads. CSRF exploits the fact that cross-origin writes can still carry your session.
Attacking via CSRF
The classic attack requires only that the victim is logged in and the transfer endpoint accepts a simple POST.
// language: html
<!-- Hosted on evil.com -->
<form
action="https://bank.com/transfer"
method="POST"
id="csrf-form"
>
<input type="hidden" name="to" value="attacker-account">
<input type="hidden" name="amount" value="9999">
</form>
<script>document.getElementById('csrf-form').submit();</script>The form POST to bank.com is a simple request - no preflight. The browser sends the user's session cookie automatically. bank.com receives what looks like a legitimate authenticated request and processes the transfer. CORS never enters the picture: evil.com doesn't need to read the response.
The same attack works without a visible form. A hidden image tag also triggers a cross-origin GET with cookies attached.
// language: html <!-- Triggers a credentialed GET — no user interaction needed --> <img src="https://bank.com/logout" width="0" height="0">
How do you protect against CSRF attacks?
SameSite cookies
The most effective modern defense. With SameSite=Strict , the browser will not attach the cookie to any cross-site request. Form posts, image tags, fetch - none of it.
// language: ruby Rails.application.config.session_store :cookie_store, key: '_myapp_session', secure: Rails.env.production?, httponly: true, same_site: :strict
CSRF tokens
Rails has this built in. protect_from_forgery is enabled by default in ApplicationController and handles token generation, embedding, and validation automatically.
// language: ruby # app/controllers/application_controller.rb class ApplicationController < ActionController::Base protect_from_forgery with: :exception # Raises ActionController::InvalidAuthenticityToken on mismatch end # Rails embeds the token in every HTML form automatically # <%= form_with url: transfer_path do |f| %> # renders a hidden field: # <input type="hidden" name="authenticity_token" value="abc123..." />
If you have a mixed app that HTML views and a JSON API share the same session, you need to pass the CSRF token via a meta tag and include it in fetch requests.
// language: javascript
<!-- app/views/layouts/application.html.erb -->
<%= csrf_meta_tags %>
<!-- renders: <meta name="csrf-token" content="abc123..." /> -->
// In your JavaScript:
fetch('/transfers', {
method: 'POST',
headers: {
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').content,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to: '...', amount: 100 }),
});For API controllers that use token auth instead of sessions:
// language: ruby class Api::BaseController < ActionController::API # ActionController::API skips CSRF by default - rely on Authorization header instead end
Check the Origin / Referer header
A lightweight server-side check. Browsers send the Origin header on cross-origin requests. If it's not in your allowlist, reject the request. This doesn't require any client-side changes.
// language: ruby
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :verify_origin
private
ALLOWED_ORIGINS = %w[
https://app.bank.com
https://admin.bank.com
].freeze
def verify_origin
origin = request.headers['Origin'] || request.headers['Referer']
return if origin.nil? # same-origin requests don't send Origin
unless ALLOWED_ORIGINS.any? { |o| origin.start_with?(o) }
render json: { error: 'Forbidden' }, status: :forbidden
end
end
endConclusion
Use SameSite cookies (Lax or Strict) to reduce cross-site request risks, and keep CSRF protection enabled for any session-based authentication. For APIs, prefer stateless authentication (e.g., Authorization headers with tokens) instead of cookies.
Configure CORS explicitly:
- Allow only trusted origins
- Avoid wildcards (*) when credentials are involved
- Never implement origin reflection without strict validation
Related blogs
Make Our Utils Functions Immutable
I chose JavaScript for this blog because it has both mutable and immutable objects and methods, while language like Haskell enforces immutability everywhere1. Mutable vs Immutable Objects and MethodsWhen we say an object is immutable, we mean i...
Software Engineer
Software Engineer
Optimizing Bulk Create Operations in Rails
Recently, my team ran into performance issues while handling bulk CUD (Create, Update, Delete) operations in a Rails application.Active Record provides validations and callbacks, which are excellent tools for maintaining data consistency. Rails contr...
Software Engineer
Software Engineer
Ruby on Rails
Ruby on Rails