Deploying a Rails API/React App with Sessions and CSRF Tokens
What a learning experience I had in deploying a React/Rails API app! The process was much more difficult than I ever anticipated, and I spent dozens of hours Googling problems. The main issues I encountered involved properly structuring and configuring my project to work with Rails sessions and CSRF tokens.
My problems all started with the innocent idea of deploying my backend and frontend separately…
Issue #1: Project Structure
So, I decided to deploy my Rails API to Heroku and my React frontend to Netlify. After all, having the backend and frontend running locally on different ports worked just fine, so why should deploying to separate hosting providers be any different?
Well, as it turns out, it’s actually quite different, especially when user authentication is involved! After deployment, I discovered that Rails sessions stopped working entirely, which broke my app. Users could “log in”, but if they refreshed the page, they’d immediately be logged out. And users could not make successful POST requests to one of my resources, because the session_id
was empty.
Why was this? After some initial Googling, it turns out that Rails stores the session_id
in a cookie, and cookies are not transferred across domains. Meaning, if I wanted to use sessions, my frontend and backend would need to be deployed together, not on separate hosting platforms.
Rather than restructure my repos, I ran npm run build
inside my frontend directory and copied the contents of the resulting /build
folder over to my backend’s /public
directory. Because I used React Router in my project, I had to tweak a bit of code inside my application_controller.rb
file and in my config/routes.rb
file for Rails to render the index.html
generated by React (kudos to Charlie Gleason over at Heroku for writing a phenomenal tutorial, it greatly helped me here!)
# application_controller.rb# NOTE: ActionController needs to inherit from ::Base, NOT ::API, in order to render the index.html file:
# https://stackoverflow.com/a/59387177/11860889class ApplicationController < ActionController::Base
include ActionController::Cookies def fallback_index_html
render file: 'public/index.html'
end
end# config/routes.rb...get '*path', to: "application#fallback_index_html", constraints: ->(request) do
!request.xhr? && request.format.html?
end
Ok, perfect! Now, I was ready to redeploy this repo to Heroku!
Or was I?
Issue #2: Can’t Verify CSRF Token Authenticity
Having redeployed my project as one repo on Heroku, I then ran into an issue where I could not perform any kind of non-GET request. Instead, I was greeted with a lovely 422 Unprocessable Entity
error, with the details being:
Can't verify CSRF token authenticity
Completed 422 Unprocessable Entity in 1ms (Active Record: 0.0s)ActionController::InvalidAuthenticityToken (ActionController::InvalidAuthenticityToken)
Because my ActionController was now inheriting from ::Base, Rails was expecting any non-GET request to contain a CSRF token inside the header.
I found a quick “fix” that entailed disabling CSRF protection by adding the following line to my application_controller.rb
file:
skip_before_action :verify_authenticity_token
My app now worked like it did locally! But I wasn’t satisfied, as I thought it was lazy to disable security to make my app work. So back to Googling!
A bit of background on CSRF
CSRF stands for Cross-Site Request Forgery, a type of security attack commonly known as a “one-click attack”. For example, let’s say you have an account on a website without CSRF protection, e.g., www.my-bank.com. You log in, and a cookie with your session_id
is stored in your browser, which keeps you logged in as you browse the site. Then, without logging out, you happen to visit a malicious website that has some hidden code; this code might be triggered when clicking a link or when the page simply loads. The embedded code makes a non-GET request to www.my-bank.com (like changing your email address or transferring your money out of your account), and, because the cookie containing your session_id
for www.my-bank.com is still in your browser, www.my-bank.com gets tricked into thinking that a valid request was made. Your account just got compromised without your knowledge!
CSRF tokens are a countermeasure to this type of attack. Essentially, a token is created on the backend and sent to the browser. Then, whenever the browser sends a non-GET request to the backend, the CSRF token is sent in the request’s header, where the backend can verify its authenticity. A malicious website doesn’t have access to this token, so any non-GET request that was triggered while you browsed such a site would not affect your account on www.my-bank.com.
Issue #3: Getting CSRF Tokens to Play Nice with Sessions
The challenge now became to enable CSRF protection with sessions. I tried numerous ways of enabling CSRF protection, but many of the solutions out there either involved the use of Axios for fetching data on the React frontend, or entailed a full-stack Rails application. Below is the only solution that worked for me (kudos to Tomas Valent for his excellent blog post on CSRF tokens in Rails; it saved my sanity!).
I edited my application_controller.rb
file to the following:
# application_controller.rbclass ApplicationController < ActionController::Base
include ActionController::Cookies
after_action :set_csrf_cookie def fallback_index_html
render file: 'public/index.html'
end private def set_csrf_cookie
cookies["CSRF-TOKEN"] = {
value: form_authenticity_token,
secure: true,
same_site: :strict
domain: 'life-lister.herokuapp.com'
}
end
end
You’ll notice I did create and store the CSRF token inside a cookie, which theoretically means a malicious site could use it just like it could use the session_id
to compromise someone. However, when Rails receives a request, it doesn’t read the token from the cookie, but rather reads it from the request header.
I also used some settings when creating the cookie for my CSRF token to enhance security. Setting secure: true
prevents my Rails backend from accepting the token if the request was made over the less secure HTTP protocol (instead of HTTPS). Also, I set same_site: :strict
, meaning that the token will only be accepted from my domain (which I also defined in the cookie). I could have set httponly: true
, which would prevent a malicious site from reading my CSRF token, but doing so would also prevent my frontend from reading it too! (And my frontend needs to read the token to send it in the request headers).
Now, on my frontend, I had to tweak a bit of code as well. I created a new file to handle the logic of reading and grabbing the value from the CSRF token.
# cookies.jsfunction CSRFToken(cookies) {
const splitCookies = cookies.split('; ');
return splitCookies.find(cookie => cookie.startsWith("CSRF-TOKEN=")).split('=')[1];
}export default CSRFToken;
Then, inside any React component that made a non-GET request, I imported the function and used it inside the request headers:
# Component that needs to send a non-GET requestimport CSRFToken from './cookies';...function handleSubmit(e) {
e.preventDefault();
fetch("/login", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": CSRFToken(document.cookie)
},
body: ...
}
...
}
Here, I pass in document.cookie
as an argument to my CSRFToken()
function, which grabs all of the cookies stored on my app’s domain. Then, this function parses out the value of the CSRF token and returns it, which is set inside the “X-CSRF-Token”
header.
Now, after another redeployment, my app works properly without disabling CSRF protection! :)
Resources
- A Rock Solid, Modern Web Stack — Rails 5 API + ActiveAdmin + Create React App on Heroku
- Rails API Authentication with SPA CSRF Tokens
- Ruby on Rails CSRF Protection with React.js & Webpacker
- StackOverflow: Rails with React app on Heroku routes don’t work on refresh
- StackOverflow: Rails: Can’t verify CSRF token authenticity when making a POST request