Invalid Authenticity Token Errors in Rails 5

in code


Over the last while we had persistent CSRF token issues in our natively-wrapped application.

ActionController::InvalidAuthenticityToken

The error only appeared to occur under specific circumstances:

  1. Open app.
  2. Perform any action, then logout.
  3. Log back in again. All remote POST/PUT/DELETE requests will fail with token errors.

Typical trace:

Started POST "/events/147/attendances" for 127.0.0.1 at 2017-09-12 12:17:54 +0000
Processing by AttendancesController#create as JS
  Parameters: {"authenticity_token"=>"kt/qu7KBT2ZLVTBs5ccuHubymW0pEsYPss86jtmY2u6qPf+CIilMJooWLGTEEd0rfx6/Q0ZvT7kG0JRl6LxySg==", "event_id"=>"147"}
Can't verify CSRF token authenticity.
Completed 422 Unprocessable Entity in 1ms (ActiveRecord: 0.0ms)

ActionController::InvalidAuthenticityToken (ActionController::InvalidAuthenticityToken):

actionpack (5.1.2) lib/action_controller/metal/request_forgery_protection.rb:195:in `handle_unverified_request'
actionpack (5.1.2) lib/action_controller/metal/request_forgery_protection.rb:227:in `handle_unverified_request'
...

A hard reload would restore app functionality in all cases. It took me three days of dedicated work to trace the problem, write a fix, and deploy the fix to production. (╯°□°)╯︵ ┻━┻

Load Balancer/Traces

Our first thought was that maybe it was a load balancer issue? Did one instance issue a token which another instance considered invalid? That led me to dive into how Rails validates a CSRF token, by way of stepping through the source file. I copied the file to our config/initializers/ folder, inserted a bunch of logger statements, and walked the token through the file.

Pathways in Phoenix Park, Dublin

Nothing. Like, sure, now I know what happens when I insert before_action :verify_authenticity_token into a controller, but that didn’t fix my problem. Traces from the error weren’t helpful either, because the trace only goes as far back as line 195, which can be called from several places in the file.

Turbolinks

Oh boy. Turbolinks acts as the backbone of our application’s view layer, and CSRF issues are an old, known problem with the library. Comparison of the expected (issued with form_authenticity_token) dumped to server logs, with the token set in the browser, showed me the root cause of the problem: the CSRF tokens set in (i) the header meta tag, and (ii) AJAX request headers, were both incorrect. Neither matched the issued token.

So why? Subsequent POST/PUT/DELETE actions would work after I hand-set the new token, but why wasn’t it set correctly? What happens normally:

  1. A new token is issued on each non-XHR GET request.
  2. The token is set in a header meta tag.
  3. The jquery-rails gem will read the new tag and update AJAX headers appropriately. The update fires (looks like) on each turbolinks:load event.

Step #2 fails and I still don’t know why. ¯\_(ツ)_/¯

Caching and Shite

Specifically, the error occurs only in our native app wrapper. As far as I can tell, either the app wrapper or the browser continues to cache parts of the older header during a HTTP 302 redirect.


Fixing the Problem/Single Source of Truth

With the problem (the old CSRF token persists) and cause (caching stuff?) established, I set out to fix the problem. Hours of trial and error and Google left me in favour of either a request response or a non-secure cookie to hold the token. Other programmers have resorted to the same solution.

While other developers favoured adding a new token to each response request through a custom, I didn’t like that each request authorizes the next, in essence. There’s no single source of truth in the page for the CSRF token. I hold that authorization to make actions should rest with the document/page/cookies instead. Fastly advocates the use of a secure cookie to hold the CSRF token. Good idea, except that the token doesn’t need to be secure because I have to expose the token to the client for it to make a successful request.

Fix: Application Controller

I added code to our application controller, such that when the site receives a valid non-XHR GET request, it’ll create a non-secure token which contains the new CSRF token.

class ApplicationController < ActionController::Base
  before_action :set_csrf_token, if: :valid_get_request?

  private

  def valid_get_request?
    protect_against_forgery? && !request.xhr? && request.get?
  end

  def set_csrf_token
    cookies[:csrf_token] = {
      value: form_authenticity_token,
      expires: 5.minutes.from_now,
      secure: false
    }
  end
end

Fix: document.cookie

The next thing I needed was the ability to parse and extract values from the document.cookie string. We use Lodash, which made my life way easier:

App.cookies = App.cookies || {
    get: function(cookie) {
        const cookies = _(document.cookie).split(';').map(this._splitCookie).fromPairs().value();

        if (cookie) {
            return _.get(cookies, cookie);
        } else {
            return cookies;
        }
    },

    _splitCookie: function(cookie) {
        return cookie.split('=').map(ck => ck.trim());
    }
};

The function will return either all non-secure cookies, the specified cookie key if it exists, or undefined if the key isn’t set:

App.cookies.get(); // { csrf_token: 'abc123' }
App.cookies.get('csrf_token'); // 'abc123'
App.cookies.get('sexy_pony_hooves'); // undefined

Fix: Set CSRF Token

The final step is to set the new CSRF token after I extract it from the cookie. We use Backbone.js-style event listeners. I devolve control to each script: they push their own handlers to the global stack. One thing I noticed is that the CSRF token is percent-encoded, and so must be run thorugh decodeURIComponent before I can use it.

Note: decodeURIComponent has no fucks to give: It will cast undefined as 'undefined'. The code first extracts the cookie-token and tests for truthiness before any further handling.

App.tokens = App.tokens || {
    ajax: function(newToken) {
        if (newToken) {
            $.ajaxSetup({
                headers: { 'X-CSRF-Token': newToken }
            });
        }

        return _.get($.ajaxSettings, 'headers.X-CSRF-Token');
    },

    html: function(newToken) {
        const meta = $('meta[name="csrf-token"]');

        if (newToken) {
            meta.attr('content', newToken);
        }

        return meta.attr('content');
    },

    set: function(newToken) {
        App.tokens.ajax(newToken);
        App.tokens.html(newToken);
    },

    listeners: {
        'turbolinks:load': function() {
            const token = App.cookies.get('csrf_token');

            if (token) {
                App.tokens.set(decodeURIComponent(token));
            }
        }
    }
};

App.listeners.add(App.tokens.listeners);

Last Word

My life would be easier without any of this code-y stuff. The $.rails.refreshCSRFTokens() method provided by jquery-ujs doesn’t help at all because it sets form element tags based on the meta tag.

> $.rails.refreshCSRFTokens
ƒ (){e('form input[name="'+n.csrfParam()+'"]').val(n.csrfToken())}

Wrong meta tag information, wrong form information.




Your email address will not be published. Required fields are marked *