Cross-Site Request Forgery (CSRF)

A Cross-Site Request Forgery (CSRF) attack exploits a vulnerability where a malicious website tricks an authenticated user into unknowingly submitting sensitive requests to a legitimate website where they are logged in. This is possible because browsers automatically include all cookies, including session cookies, when making requests to a website. Consequently, the legitimate website may be unable to differentiate between a genuine user request and a fraudulent one.

A CSRF attack can also be executed through a webpage that includes an automatic form submission. In this scenario, the attacker creates a webpage with a hidden form that triggers a sensitive action—like a money transfer or a password change—and is programmed to submit automatically via JavaScript. When the user visits the attacker’s site, the form is submitted to the target website, using the user’s active session to execute the action.

Attacker vs Genuine

CSRF Protection in Rails

To mitigate CSRF attacks, web frameworks use anti-CSRF tokens. These tokens are unique, unpredictable values linked to a user’s session. They are incorporated into forms and requests and verified by the server before any sensitive action is executed. By ensuring that only requests with a valid token are processed, the application is shielded from unauthorized cross-site requests.

Rails generates a CSRF token once per session rather than for every request to avoid usability issues. For example, if a token were generated for every request, a user might encounter errors if they used the back button to resubmit a form. By keeping the token consistent throughout the session, Rails ensures a smoother user experience while maintaining robust security.

Starting with Rails 5, you can also enable per-form CSRF tokens. This feature can be activated globally or at the controller level, providing even more granular security.

class ActivitiesController < ApplicationController
  self.per_form_csrf_tokens = true
end
# config/application.rb
config.action_controller.per_form_csrf_tokens = true

The Authenticity Token

In Rails, the term authenticity_token is used in forms. While the underlying csrf_token is session-based, the authenticity_token included in the HTML is masked and changes with each request (or page refresh) to increase security.

The authenticity_token is derived from the csrf_token. Because it relies on the session’s secret, it effectively expires when the user logs out or the session is cleared.

A Form with an Authenticity Token

<form method="post" action="http://www.travel.com/activities">
  <input type="hidden" name="authenticity_token" value="HTyAhRpOcZ1OyKjmq12hH4RYiMrFY4cVD2J54uPnCrE4qblDCqqcMSwAR59QtvthPWPd3BxG_MFAybc_HnipvA">
</form>

How the Authenticity Token is Generated

The logic for generating a masked authenticity token looks like this:

Base64::encode( one_time_pad + (one_time_pad XOR session[:_csrf_token]) )

The use of a one-time pad is why the authenticity_token appears different every time a page is rendered. When a form is submitted, Rails decodes the token, removes the mask using the one-time pad, and verifies that the resulting csrf_token matches the one stored in the user’s session.

This makes an attacker’s job extremely difficult, as they cannot forge a valid request without access to the user’s secret csrf_token.

Below is the Rails source code for this verification (link):

def valid_authenticity_token?(session, encoded_masked_token) # :doc:
  if encoded_masked_token.nil? || encoded_masked_token.empty? || !encoded_masked_token.is_a?(String)
    return false
  end

  begin
    masked_token = decode_csrf_token(encoded_masked_token)
  rescue ArgumentError # encoded_masked_token is invalid Base64
    return false
  end

  if masked_token.length == AUTHENTICITY_TOKEN_LENGTH
    # Handle unmasked tokens for backward compatibility
    compare_with_real_token masked_token, session

  elsif masked_token.length == AUTHENTICITY_TOKEN_LENGTH * 2
    csrf_token = unmask_token(masked_token)

    compare_with_global_token(csrf_token, session) ||
    compare_with_real_token(csrf_token, session) ||
    valid_per_form_csrf_token?(csrf_token, session)
  else
    false # Token is malformed
  end
end

def unmask_token(masked_token) # :doc:
  # Split the token into the one-time pad and the encrypted
  # value and decrypt it.
  one_time_pad = masked_token[0...AUTHENTICITY_TOKEN_LENGTH]
  encrypted_csrf_token = masked_token[AUTHENTICITY_TOKEN_LENGTH..-1]
  xor_byte_strings(one_time_pad, encrypted_csrf_token)
end