Updated October 2026: Refocused the post on its real topic (CSRF tokens on pages served from a shared Rack::Cache store), replaced the unmaintained redis-rails/redis-rack-cache gems with Rails.cache on Redis, rewrote the broken middleware as a self-contained Rack 3 example, and added the recommended token-endpoint approach.

Page caching is a vital strategy to enhance the performance and responsiveness of web applications by serving cached HTML pages directly to users. Once the same HTML is shared by everyone, though, one Rails feature breaks: the CSRF token. In this blog post, we’ll cache pages with Rack::Cache (using Redis as the store) and then fix forms on those pages, first with a small JavaScript token endpoint (recommended), then with a Rack middleware that injects the token on the server.

I. The Problem: CSRF Tokens in a Shared Cache

Rails protects POST/PATCH/DELETE requests with an authenticity token. csrf_meta_tags puts it in a <meta name="csrf-token"> tag (Turbo and Rails UJS send it as the X-CSRF-Token header), and form_with adds it as a hidden authenticity_token field. The token is derived from a secret stored in the visitor’s session.

A shared page cache stores one copy of the HTML and serves it to everyone. That copy contains the token of whoever caused the page to be rendered, so for every other visitor it does not match their session and their form submissions fail with ActionController::InvalidAuthenticityToken. Rendering the token also writes to the session, which adds a Set-Cookie header to a page that should be identical for everyone.

The fix is always the same idea: keep the cached HTML free of per-user tokens and add the visitor’s own token outside the cache.

II. Cache Pages with Rack::Cache and Redis

Rack::Cache is an HTTP cache that runs as Rack middleware inside your app, in front of the Rails router. Rails still has built-in support for it via config.action_dispatch.rack_cache, and its rails:/ store keeps cached responses in Rails.cache.

Redis is an open-source, in-memory data store known for its speed and versatility. Using it as Rails.cache makes it the backend for the page cache too.

1. Add the Gems

The redis-rails and redis-rack-cache gems used in the first version of this post are no longer maintained (their last releases were in 2017 and 2019). Rails has shipped its own Redis cache store since 5.2, so all you need is:

# Gemfile
gem "rack-cache"
gem "redis"

Then run bundle install.

2. Configure the Cache Store and Rack::Cache

# config/environments/production.rb
config.cache_store = :redis_cache_store, { url: ENV.fetch("REDIS_URL") }

# Store cached responses in Rails.cache (Redis).
config.action_dispatch.rack_cache = {
  metastore: "rails:/",
  entitystore: "rails:/",
  verbose: false
}

Keep the rack-cache defaults for the rest. In particular, do not empty ignore_headers: by default Rack::Cache strips Set-Cookie before storing a response, and storing it would hand one visitor’s session cookie to everyone. Leave allow_reload off too, or any client can bypass the cache with Cache-Control: no-cache.

3. Mark Pages as Public

Browsers send the session cookie on every request, and Rack::Cache treats requests with Cookie or Authorization as private unless the response explicitly says public. Opt in per action:

class PagesController < ApplicationController
  def show
    @page = Page.find_by!(slug: params[:slug])
    @shared_cache = true # tells the layout not to render a CSRF token

    expires_in 6.hours, public: true
  end
end

Leave the token out of the cached HTML and let the browser fetch it from a tiny endpoint that is never cached.

1. The Token Endpoint

# config/routes.rb
get "csrf_token", to: "csrf_tokens#show"
# app/controllers/csrf_tokens_controller.rb
class CsrfTokensController < ApplicationController
  def show
    no_store # Cache-Control: no-store, so no cache ever keeps this response

    render json: {
      param: request_forgery_protection_token,
      token: form_authenticity_token
    }
  end
end

form_authenticity_token stores the CSRF secret in the session if there isn’t one yet, so this response also sets the visitor’s session cookie.

2. Render Cached Pages Without a Token

In the layout, render an empty token on shared pages:

<%# app/views/layouts/application.html.erb, inside <head> %>
<% if @shared_cache %>
  <meta name="csrf-param" content="<%= request_forgery_protection_token %>">
  <meta name="csrf-token" content="">
<% else %>
  <%= csrf_meta_tags %>
<% end %>

Forms on those pages must not render a token either, so pass authenticity_token: false:

<%= form_with model: Comment.new, url: comments_path, authenticity_token: false do |form| %>
  <%= form.text_area :body %>
  <%= form.submit %>
<% end %>

3. Fill in the Token with JavaScript

// app/javascript/csrf_token.js
async function refreshCsrfToken() {
  const response = await fetch("/csrf_token", {
    credentials: "same-origin",
    headers: { Accept: "application/json" },
  });
  if (!response.ok) return;

  const { param, token } = await response.json();

  // Turbo and Rails UJS read this meta tag and send it as X-CSRF-Token.
  document.querySelector('meta[name="csrf-token"]')?.setAttribute("content", token);

  // Plain HTML form submissions need the hidden field.
  for (const form of document.querySelectorAll('form[method="post"]')) {
    let input = form.querySelector(`input[name="${param}"]`);
    if (!input) {
      input = Object.assign(document.createElement("input"), { type: "hidden", name: param });
      form.prepend(input);
    }
    input.value = token;
  }
}

// With Turbo Drive, use "turbo:load": it fires on the first page load and after every visit.
document.addEventListener("DOMContentLoaded", refreshCsrfToken);

Import it from app/javascript/application.js. The cached page is now identical for every visitor, and each visitor’s forms carry a token that matches their own session. The token request is small and only has to run on pages that contain forms, so you can also call refreshCsrfToken() lazily, for example when a form first gets focus.

Looking ahead: Rails’ main branch (the upcoming Rails 8.2, unreleased at the time of writing) adds protect_from_forgery using: :header_only, which verifies requests with the browser’s Sec-Fetch-Site header instead of a token, and under that strategy forms and csrf_meta_tags render no token at all. That removes this problem for apps that can require modern browsers.

IV. Alternative: Inject the Token with Rack Middleware

If the page must work without JavaScript, a middleware placed in front of Rack::Cache can swap a placeholder in the cached HTML for the visitor’s own token. It gets the token by calling the same /csrf_token endpoint internally. This was the original approach of this post; the version below is rewritten for Rack 3 (lowercase header names, no direct each on another app’s body).

1. Render a Placeholder Instead of the Token

Use the layout from section III, but put the placeholder in the meta tag and in forms:

<meta name="csrf-token" content="<%= RackCacheCsrf::CSRF_PLACEHOLDER %>">
<%= form_with model: Comment.new, url: comments_path,
              authenticity_token: RackCacheCsrf::CSRF_PLACEHOLDER do |form| %>

2. The Middleware

Middleware is loaded while the app boots, so keep it out of the autoloaded app/ directory:

# lib/middleware/rack_cache_csrf.rb
# frozen_string_literal: true

require "json"

# Sits in front of Rack::Cache. Pages stored in the shared cache contain
# CSRF_PLACEHOLDER instead of a real token; this replaces it with a token
# minted for the current visitor's session.
class RackCacheCsrf
  CSRF_PLACEHOLDER = "__CSRF_TOKEN__"
  TOKEN_PATH = "/csrf_token"
  # x-rack-cache trace values for a response that is (or just became) shared.
  SHARED_TRACE = /\b(?:fresh|valid|store)\b/

  def initialize(app)
    @app = app
  end

  def call(env)
    token_env = env.dup # snapshot before the app adds its own keys to env
    status, headers, body = @app.call(env)
    return [status, headers, body] unless shared_html?(env, headers)

    token, set_cookie = fetch_token(token_env)
    return [status, headers, body] unless token

    headers["set-cookie"] = set_cookie if set_cookie
    headers["cache-control"] = "private, no-store" # this copy is personal now
    %w[content-length etag last-modified age].each { |name| headers.delete(name) }

    [status, headers, TokenBody.new(body, token)]
  end

  private

  def shared_html?(env, headers)
    env["REQUEST_METHOD"] == "GET" &&
      headers["content-type"].to_s.include?("text/html") &&
      headers["x-rack-cache"].to_s.match?(SHARED_TRACE)
  end

  # Calls the app again for the token endpoint, with the visitor's cookies.
  def fetch_token(env)
    token_env = env.merge(
      "REQUEST_METHOD" => "GET",
      "PATH_INFO" => TOKEN_PATH,
      "QUERY_STRING" => "",
      "REQUEST_URI" => TOKEN_PATH,
      "ORIGINAL_FULLPATH" => TOKEN_PATH,
      "HTTP_ACCEPT" => "application/json"
    )
    %w[HTTP_ACCEPT_ENCODING HTTP_IF_NONE_MATCH HTTP_IF_MODIFIED_SINCE].each do |key|
      token_env.delete(key)
    end

    status, headers, body = @app.call(token_env)
    return unless status == 200

    # This middleware acts as the server for this internal response,
    # so it reads the body itself.
    json = +""
    body.each { |chunk| json << chunk }
    [JSON.parse(json).fetch("token"), headers["set-cookie"]]
  rescue JSON::ParserError, KeyError
    nil
  ensure
    body.close if body.respond_to?(:close)
  end

  # Rack 3 forbids middleware from calling #each on the app's body directly,
  # so wrap it and replace the placeholder as the server streams it out.
  # Rails normally renders a page as a single chunk; if you stream
  # responses, make sure the placeholder cannot be split across chunks.
  class TokenBody
    def initialize(body, token)
      @body = body
      @token = token
    end

    def each
      @body.each { |chunk| yield chunk.gsub(CSRF_PLACEHOLDER) { @token } }
    end

    def close
      @body.close if @body.respond_to?(:close)
    end
  end
end

Notes on the design:

  • Only shared HTML is touched. The x-rack-cache trace tells whether Rack::Cache served the page from the store (fresh, valid) or just stored it (store). Uncached pages render real tokens and are left alone.
  • The result is private. After the token is injected the response belongs to one visitor, so it gets Cache-Control: private, no-store, and the length/validators computed for the shared copy are dropped.
  • Failures degrade gracefully. If the token request fails, the page is returned unchanged; forms on it will fail CSRF verification, so log it in production.
  • Each shared page costs one extra (cheap) request through the Rails stack. That is the price of not using JavaScript.

3. Insert It Before Rack::Cache

Rack::Cache is only in the middleware stack when config.action_dispatch.rack_cache is set, so add the middleware in the same file:

# config/environments/production.rb
require_relative "../../lib/middleware/rack_cache_csrf"

Rails.application.configure do
  # ... cache_store and rack_cache settings from section II ...

  config.middleware.insert_before Rack::Cache, RackCacheCsrf
end

If your config/application.rb calls config.autoload_lib, add middleware to its ignore: list (for example config.autoload_lib(ignore: %w[assets tasks middleware])) so Zeitwerk doesn’t also try to manage that file.

V. Run It

Start Redis, then the app in production mode (Rack::Cache is only enabled there in this setup):

redis-server
RAILS_ENV=production bin/rails server

Request a shared page twice with curl -sI and check the x-rack-cache header (miss, store on the first request, fresh on the second), then submit a form on it from two different browsers: both should succeed.

VI. Conclusion

A shared page cache and per-session CSRF tokens do not mix by default: whoever renders the page first gives their token to everyone else. With Rack::Cache on top of Rails.cache in Redis, the clean solution is to keep the cached HTML token-free and fetch each visitor’s token from a small no-store endpoint with JavaScript. When JavaScript is not an option, a Rack 3 middleware in front of Rack::Cache can inject the token on the server, at the cost of one internal request per shared page.