Updated October 2026: Fixed the SameSite section (browser defaults, why SameSite=None weakens protection and needs Secure), corrected how JavaScript sends the token, and added masked tokens, the Origin check and skip_forgery_protection for APIs.

I. What is CSRF and How Does It Work?

CSRF (Cross-Site Request Forgery) is a type of attack where an attacker tricks a user into performing unintended actions on a web application in which the user is authenticated. In Rails, CSRF protection is implemented to prevent such attacks. This article explains what CSRF is, how it works in Rails, and the mechanisms used to prevent CSRF attacks.

  • Attack Scenario: An attacker creates a malicious website that contains a form or script that sends a request to a target website where the user is authenticated.
  • User Interaction: The attacker tricks the user into visiting the malicious website, which automatically submits the form or executes the script.
  • Unintended Actions: The browser attaches the user’s session cookie to the request sent to the target website, allowing the attacker to perform actions on behalf of the user without their consent.

II. How CSRF Protection Works in Rails

  1. CSRF Token Generation: Rails stores a random secret token in the user’s session (the attacker cannot read it). Every time a form or the csrf_meta_tags helper is rendered, Rails masks that token with a fresh one-time pad, so the value printed in the HTML changes on every request while still verifying against the same session secret. With config.action_controller.per_form_csrf_tokens (on by default since Rails 5.0), form tokens are additionally bound to the form’s action and method.

  2. Token Inclusion in Forms: Form helpers such as form_with and form_tag automatically add a hidden authenticity_token field.

  3. Token Inclusion in JavaScript Requests: Put csrf_meta_tags in the <head> of your layout. It renders csrf-param and csrf-token meta tags. Turbo and Rails Request.JS read the token from those tags and send it in the X-CSRF-Token request header. If you use fetch or another library directly, you have to add the header yourself:

    <%# app/views/layouts/application.html.erb %>
    <head>
      <%= csrf_meta_tags %>
    </head>
    
    const token = document.querySelector('meta[name="csrf-token"]').content;
    
    fetch("/posts", {
      method: "POST",
      headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
      body: JSON.stringify({ post: { title: "Hello" } }),
    });
    
  4. Token Verification: For every non-GET/HEAD request, Rails compares the token from the form parameter or the X-CSRF-Token header with the token in the session. If it is missing or does not match, the request is rejected. GET and HEAD requests are not checked, so they must never change state — use POST, PATCH, PUT or DELETE for anything that writes.

  5. Origin Check: With config.action_controller.forgery_protection_origin_check (on by default since Rails 5.0), Rails also compares the request’s Origin header with the application’s own origin and rejects mismatches. A blank Origin is accepted because some user agents don’t send it, so this complements the token rather than replacing it.

Enabling, configuring and skipping the check

New applications don’t need any code for this: ActionController::Base subclasses are protected by default with the :exception strategy (config.action_controller.default_protect_from_forgery, on since Rails 5.2 defaults), which raises ActionController::InvalidAuthenticityToken. You only need protect_from_forgery to change the behaviour — and note that calling it without with: falls back to the :null_session strategy, so be explicit:

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception # or :reset_session / :null_session
end

APIs authenticated with a token in a header (not a cookie) are not exposed to CSRF, because the browser does not attach that header automatically. ActionController::API does not include forgery protection at all; for a JSON endpoint that inherits from ActionController::Base, skip it explicitly:

class Api::V1::BaseController < ApplicationController
  skip_forgery_protection # wrapper for skip_before_action :verify_authenticity_token
end

Only do this for endpoints that do not rely on the session cookie for authentication. Also remember that an XSS vulnerability bypasses every CSRF protection, because injected script can read the token from the page.

III. SameSite Cookies: Defense in Depth

The SameSite cookie attribute tells the browser whether to send a cookie with cross-site requests:

  • Strict: the cookie is only sent with requests originating from the same site.
  • Lax: the cookie is sent with same-site requests, and with cross-site requests only when they are top-level navigations (the URL in the address bar changes, e.g. clicking a link) and use a safe method such as GET. Cross-site POST forms, fetch() calls, <img>/<script> subresources and <iframe> navigations don’t get the cookie.
  • None: the cookie is sent with all cross-site requests. Browsers require the Secure attribute with None.

Since Rails 6.1 (config.load_defaults 6.1 or later), config.action_dispatch.cookies_same_site_protection is :lax, so Rails’ cookies — including the session cookie — are marked SameSite=Lax without any configuration. You can tighten the session cookie further:

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store, key: "_your_app_session", same_site: :strict

Strict is the safest, but users who follow a link from another site (an email, a search result) will arrive logged out on that first request, so Lax is the usual choice.

Browsers also differ in what they do when a cookie has no SameSite attribute. Chrome (80+) and Edge (86+) treat it as Lax (with a short allowance for top-level cross-site POSTs on cookies set less than two minutes earlier). Firefox has this behaviour only behind the network.cookie.sameSite.laxByDefault preference, and Safari does not apply it. That is exactly why you should set the attribute explicitly — which Rails does for you — instead of relying on browser defaults.

When to use SameSite=None

None does not “disable” SameSite: it explicitly opts the cookie in to being sent with cross-site requests, which removes this layer of CSRF protection. Use it only when the cookie genuinely has to work in a cross-site context (for example, your app embedded in an <iframe> on another site), always together with secure: true, and keep the CSRF token check on:

# config/initializers/session_store.rb
Rails.application.config.session_store :cookie_store,
  key: "_your_app_session",
  same_site: :none,
  secure: true

IV. Conclusion

CSRF protection in Rails is built on per-session authenticity tokens that are masked on every render and verified on every non-GET request, backed by an Origin check and SameSite=Lax cookies by default. Keep GET requests side-effect free, include csrf_meta_tags in your layout so JavaScript requests can send the X-CSRF-Token header, skip the check only for endpoints that don’t use cookie authentication, and reach for SameSite=None only when a cross-site embed really needs it.