Rails
protect_from_forgery, why :null_session is worse than it looks, forgery_protection_origin_check, and API-only mode.
The rule
Leave the defaults alone, and never use with: :null_session on a controller that still authenticates by cookie.
Since Rails 5.2, config.action_controller.default_protect_from_forgery = true is set by the framework defaults, so ApplicationController is protected without writing anything. Rails also enables an Origin check (forgery_protection_origin_check) and masks the token per render for BREACH.
The two things that go wrong:
with: :null_session— the historical default, and still widely copied. It does not reject a forged request; it lets it through with an empty session. On an endpoint that acts without needing a session, or in combination with any other authentication path, the request succeeds.- API-only mode —
--apiskipsActionController::RequestForgeryProtectionentirely. That is correct for a token-authenticated API and dangerous the moment a cookie is introduced.
Why :null_session is the trap
class ApplicationController < ActionController::Base
# This was the Rails default for years and is still copied from old
# tutorials constantly.
#
# It does NOT reject a forged request. It nils out the session and lets the
# action run. The intent was graceful degradation for API clients.
#
# The consequences:
# - An action that does not read the session still executes.
# - An action authenticated by anything OTHER than session -- an API key
# cookie, a "remember me" token, a devise scope -- still executes,
# because only the session was nilled.
# - Failures are silent. Nothing is logged and nothing 403s, so the gap
# is invisible in monitoring.
protect_from_forgery with: :null_session
end
class ReportsController < ApplicationController
# Skipping entirely, usually to make a webhook or a mobile client work.
skip_forgery_protection
def destroy_all
current_user.reports.destroy_all
head :ok
end
endAPI-only mode
# rails new myapp --api produces controllers inheriting from
# ActionController::API, which does NOT include
# ActionController::RequestForgeryProtection.
#
# That is correct for an API authenticated by a bearer token: the credential
# is not ambient, so there is nothing to forge.
#
# It becomes a vulnerability the moment a cookie enters the picture -- and
# that happens more often than teams expect, usually when a browser client
# is added to a project that started as a mobile backend.
class Api::BaseController < ActionController::API
include ActionController::Cookies # <- the moment this appears...
before_action :authenticate
private
def authenticate
# ...and authentication reads a cookie, every endpoint under this
# controller is CSRF-able. There is no token check anywhere in
# ActionController::API to fall back on.
@current_user = User.find_by(id: cookies.signed[:user_id])
head :unauthorized unless @current_user
end
end
# Two ways out:
#
# 1. Authenticate with a bearer token instead of a cookie. Preferred --
# it removes the ambient credential and the problem with it.
#
# 2. If a cookie is required, add protection back explicitly:
#
# class Api::BaseController < ActionController::API
# include ActionController::RequestForgeryProtection
# protect_from_forgery with: :exception
# end
#
# plus an Origin or Sec-Fetch-Site check.Views and JavaScript
<%# form_with includes the authenticity token automatically. A hand-written
<form> tag does not -- which is the usual reason a legitimate form starts
returning 422 after protection is enabled. %>
<%= form_with model: @account, url: account_path, method: :patch do |f| %>
<%= f.email_field :email %>
<%= f.submit 'Update' %>
<% end %>
<%# For fetch/XHR, expose the token in the head. csrf_meta_tags is in the
default application layout; check it has not been removed. %>
<%# <head> <%= csrf_meta_tags %> </head> %>Checklist
grep -rn 'null_session'returns nothing. It is not protection.grep -rn 'skip_forgery_protection\|skip_before_action :verify_authenticity_token'— every hit justified in a comment, and none of them on a cookie-authenticated action.protect_from_forgery with: :exceptionis the effective setting, whether stated or inherited fromload_defaults.config.load_defaultsis at least5.2, sodefault_protect_from_forgeryis on.forgery_protection_origin_checkis enabled.- API-only controllers do not include
ActionController::Cookiesunless protection was added back. - Session cookie:
secure: true,httponly: true,same_site: :lax, and a__Host-prefixed key with nodomainoption. - Routes use explicit verbs — no
via: :all, nomatchwithoutvia. csrf_meta_tagsis present in the application layout.reset_sessionis called on login. Devise does this; a hand-rolledSessionsControllerfrequently does not. See Login CSRF.
Related
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Forcing the victim into the attacker's session, the OAuth state parameter, callback CSRF, and account-linking takeover.
GET and POST routing, _method and X-HTTP-Method-Override, and body parsers that ignore the declared content type.
The layering order — SameSite=Lax, __Host- prefix, Fetch Metadata rejection, then a token — plus re-authentication for high-value operations.
Token not checked, checked only when present, empty accepted, not tied to the session, reused across users, or predictable.