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-cachegems withRails.cacheon 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
III. Recommended: Fetch the Token from an Uncached Endpoint
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-cachetrace 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.
Public comments are closed, but I love hearing from readers. Feel free to contact me with your thoughts.