0%
Reading Settings
Font Size
18px
Line Height
1.5
Letter Spacing
0.01em
Font Family
Table of contents
    blog cover

    CORS and CSRF: How Attackers Exploit the Gaps

    Software Engineer
    Software Engineer
    published 2026-04-04 18:08:15 +0700 · 5 mins read
    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

    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 the rack-cors gem. You configure it in an initializer.
    // 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
    end

    An 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
    end

    CSRF: 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

    SameSite=Lax is a softer option: cookies are sent on top-level navigations (like clicking a link) but not on subresource requests. It blocks most CSRF attacks while still letting users stay logged in when they follow links from other sites.

    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
    end

    Conclusion

    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