In preparation for our next course, we’re building a Vue.js front-end web application (an SPA) that uses a Rails 5 API backend with authenticated endpoints. The web app and the API are two separate applications:

No part of the web app is rendered by Rails. The Rails app is strictly a JSON API. And the web app is a standalone bundle of HTML, CSS, and JavaScript.

Given that the API has protected resources that require authentication, I had a decision to make regarding how best to “log in” users of the web app.

A Common Approach: Token-Based Authentication

A common approach would have been to use an API access token. Token-based authentication goes something like this:

  1. The web app captures a username and password from a form and sends a POST request with the credentials to an API “login” endpoint (/auth) to exchange the credentials for an API access token. The request (sent over HTTPS, of course) would look something like this:

    POST /auth
    Host: api.your-app.com
    Content-Type: application/json
    
    {
      "username": "mike",
      "password": "supersecret"
    }
    
  2. The API attempts to authenticate the user given his/her credentials.

  3. If the user is successfully authenticated by the API, the JSON response body includes a user-specific API access token. Here’s an example response body:

    { "token":"eyJhbGciOiJIUzI1NiJ9" }
    
  4. The web app is then responsible for storing that token (more on that later) and sending it back to the API with every request for a protected API resource. The standard way to do this is to include the token in the HTTP Authorization header of the request. Here’s an example request for a protected my-stuff resource:

    GET /my-stuff
    Host: api.your-app.com
    Authorization: Bearer eyJhbGciOiJIUzI1NiJ9
    
  5. The API uses the token in the request header to identify the user making the request for a protected resource, ensuring that only authorized users are allowed access to the resource.

  6. If the user is authorized, the response for the authenticated request is sent back to the web app. Otherwise, if a valid token isn’t included in the request header, the API sends a 401 - Unauthorized response back which the web app handles by rendering the login form.

But There’s A Drawback…

The API access token can be any URL-safe base64 string that’s unique to a user. These days it’s popular to use a JSON Web Token (JWT) which carries a user’s ID as its digitally-signed payload.

Regardless of what kind of token you use, the web app needs to store the token somewhere. Otherwise the token is lost when the browser is reloaded, which forces the user to log in again.

So here’s the question: Where do you store the token in the browser so that the token survives browser reloads? The off-the-cuff answer is localStorage because it’s simple and effective:

localStorage.setItem("token", token);

But there’s a drawback that I didn’t like about this option: localStorage is vulnerable to Cross-site Scripting (XSS) attacks.

That means if an attacker can inject some JavaScript code that runs on the web app’s domain, they can steal all the data in localStorage. The same is true for any third-party JavaScript libraries used by the web app. Indeed, any sensitive data stored in localStorage can be compromised by JavaScript. In particular, if an attacker is able to snag an API token, then they can access the API masquerading as an authenticated user.

I realize that the browser environment is inherently insecure, but storing tokens in localStorage just felt like asking for trouble. Now, one benefit of using JWTs as compared to plain ol’ token strings is that JWTs have built-in expirations. If the API were to issue JWTs with relatively short expirations then the risk of storing those tokens in localStorage is minimized. However, refreshing tokens on a frequent basis increases the complexity of the web app and API.

I’d rather avoid introducing an XSS attack vector if there’s a viable alternative. And indeed there is…

A Different Approach: Rails Session Cookies

The tried-and-true Rails way of securely storing information about a user across requests is to use a session cookie. This is only viable if the API client is a browser, but we don’t have any plans to create a non-browser client for this API.

As a refresher, cookie-based authentication goes something like this:

  1. The web app captures a username and password from a form and sends a POST request with the credentials to an API “login” endpoint (/session). The request looks something like this:

    POST /session
    Host: api.your-app.com
    Content-Type: application/json
    
    {
      "username": "mike",
      "password": "supersecret"
    }
    
  2. The API attempts to authenticate the user given his/her credentials. If the user is successfully authenticated, then the API stashes the user’s ID in the session:

    session[:user_id] = user.id
    

    That simple assignment sets a cookie in the response. In this case, the cookie contains the user’s unique ID.

  3. The session cookie is returned to the browser. Here’s an example response with the Set-Cookie HTTP header:

    Status: 200
    Content-Type: application/json
    Set-Cookie: _session_id=kTNmUk23l0xxXyDB7rPCcEl6yVet1ahaofUJLd6DxS1XrhbPvU4gF%2B%2BmeMJ%2B4CDYXCcKWssfTM8VzE6YlDnaiVT1iTjg8YRx8DIjFO0BoC%2FT8He09iS5k%2FpBJFD7GD120plb7vxOGkqRWuT1egok6rS7sAQsK21MRIXDFlQJ6QJR3EZycU9CsuV9iHxKZP0UlfHcPQpqUTBDCetoF4PKFNcn%2FzPi0P0%2BunQq5i6YTyXFvaXIks0azNJnXXyFX%2FnAdiaNAFsHAsbhHO5zaHQ%2BxFWbkphV4O42p4s4--gS%2BPgAF3t14Nu6AX--J9MVefd84BIjxylRqjNT2g%3D%3D; path=/; HttpOnly
    

    The cookie (_session_id) has the HttpOnly flag set which means it can’t be accessed by JavaScript, thus it’s not vulnerable to XSS attacks. As well, the cookie value is encrypted.

    Note: Instead of storing a user’s ID in the session cookie you could store a JWT, but I’m not sure what that buys you. However, you may be using specific JWT claims that make this worthwhile.

  4. The browser that made the request then stores that cookie until it expires. And every time the web app sends a request to the API, the browser automatically sends along the session cookie. Here’s an example request for a protected my-stuff resource:

    GET /my-stuff
    Host: api.your-app.com
    Cookie: _session_id=kTNmUk23l0xxXyDB7rPCcEl6yVet1ahaofUJLd6DxS1XrhbPvU4gF%2B%2BmeMJ%2B4CDYXCcKWssfTM8VzE6YlDnaiVT1iTjg8YRx8DIjFO0BoC%2FT8He09iS5k%2FpBJFD7GD120plb7vxOGkqRWuT1egok6rS7sAQsK21MRIXDFlQJ6QJR3EZycU9CsuV9iHxKZP0UlfHcPQpqUTBDCetoF4PKFNcn%2FzPi0P0%2BunQq5i6YTyXFvaXIks0azNJnXXyFX%2FnAdiaNAFsHAsbhHO5zaHQ%2BxFWbkphV4O42p4s4--gS%2BPgAF3t14Nu6AX--J9MVefd84BIjxylRqjNT2g%3D%3D
    
  5. The API then checks for a user’s ID in the session cookie to identify the user making the request for a protected resource, ensuring that only authorized users are allowed access to the resource.

  6. If the user is authorized, the response for the authenticated request is sent back to the web app. Otherwise the API sends a 401 - Unauthorized response back which the web app handles by rendering the login form.

Protection From Forgery

Using a Rails session cookie sidesteps the potential for XSS attacks. However cookies are vulnerable to Cross-Site Request Forgery (CSRF). Thankfully, Rails has excellent CSRF protection baked in.

By default, Rails embeds a unique CSRF token in the rendered HTML: both in a meta tag and as a hidden field in each form. Here’s an example meta tag:

<meta name="csrf-token" content="z/ra9NM9bQCCZM8LI4Yx5nlojtpp58Z7VLPLR32sqPrVvsx2ckZMSAbFD6M4FiGxzdItINFBuhBkVcywD/oMmg==" />

Conveniently, that same CSRF token is stored in the session cookie.

Then whenever a user makes a non-GET request, the CSRF token gets sent along with the request. And, by default, a Rails ApplicationController includes this line:

protect_from_forgery :exception

This adds a before_action that compares the CSRF token in the request with the token in the session cookie to ensure they match. Otherwise, an exception is raised.

All the comes for free with a traditional Rails app that renders HTML. But remember, we’re only using Rails as a backend API. It’s not responsible for rendering HTML. That’s the web app’s responsibility. And, without a bit of extra work, the web app doesn’t know anything about Rails CSRF tokens.

But all is not lost!

Mitigating CSRF Attacks

It turns out there’s a straightforward solution:

  1. In the Rails app, generate a valid CSRF token using the form_authenticity_token method and send that token to the web app. The token can be sent in a JSON response body, an HTTP response header, or a non-session cookie. (You can’t just put the CSRF token in the session cookie because that cookie is flagged as HttpOnly which means it can’t be read by JavaScript.)

  2. In the web app, send along the CSRF token with every non-GET request, so that Rails can perform its built-in CSRF token validation—comparing the token in the request with the token in the session cookie to ensure they match. The trick is knowing that Rails will automatically look for a CSRF token in the X-CSRF-Token HTTP request header.

OK, so here’s how I went about implementing that…

First, I decided to use a non-session cookie to send the CSRF token from Rails to the web app. I don’t have a strong argument for this. Using a cookie is just a convenient way to make the token always available to the web app. That and, as we’ll see later, axios has a neat way of dealing with CSRF tokens in cookies.

Anyway, in the ApplicationController I set up a before_action that puts the CSRF token in the cookie:

class ApplicationController < ActionController
  before_action :set_csrf_cookie

private

  def set_csrf_cookie
    cookies["CSRF-TOKEN"] = form_authenticity_token
  end
end

(In case you’re wondering, there’s nothing special about the name CSRF-TOKEN.)

Although it’s not strictly necessary to set the cookie for every request, doing so mimics the default Rails behavior. If you reload a typical Rails-generated page, you’ll notice that the embedded CSRF token changes. Indeed, Rails appears to generate a new CSRF token on every request. But in fact what’s happening is the “real” CSRF token is simply being masked with a one-time pad to protect against SSL BREACH attacks. So even though the token appears to vary, any token generated from a user’s session (by calling form_authenticity_token) will be accepted by Rails as a valid CSRF token for that session.

Then, when the web app sends non-GET requests, it can read the CSRF token from the cookie and pass the token back to Rails in the X-CSRF-Token HTTP request header. I’m using the axios HTTP client in the web app, so here’s how to fetch a protected my-stuff resource:

axios
  .get("/my-stuff", {
    headers: { "X-CSRF-Token": getCookie("CSRF-TOKEN") }
  })
  .then(response => {
    const myStuff = response.data;
    // render my stuff
  });

That works, but rather than reading the cookie and setting the header for every request, I was happy to discover that axios will do that automatically if you set these defaults:

axios.defaults.xsrfCookieName = "CSRF-TOKEN";

axios.defaults.xsrfHeaderName = "X-CSRF-Token";

axios.defaults.withCredentials = true;
  • xsrfCookieName is the name of the cookie containing the CSRF token

  • xsrfHeaderName is the name of the HTTP request header that carries the CSRF token. Remember, Rails automatically checks the X-CSRF-Token request header for the CSRF token.

  • withCredentials set to true indicates that cross-site Access-Control requests should be made using credentials (cookies)

And that’s all there is to mitigating CSRF attacks!

Enabling CORS

Regardless of the authentication approach, the same origin policy restricts the JavaScript web app (one origin) from accessing the resources of the Rails API (another origin). To allow the JavaScript web app to send requests to the Rails API, Cross-Origin Resource Sharing (CORS) needs to be enabled.

Handling “cross-domain” requests in Rails is made easy thanks to the rack-cors gem. I dropped it in the Gemfile:

gem 'rack-cors'

And then I slipped the code below into a config/initializers/cors.rb file:

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins 'localhost:8080'

    resource '*',
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      credentials: true
  end
end

This allows all types of requests from the localhost:8080 origin (the web app) to access any resource of the Rails API (localhost:3000). If your web app were running on the your-app.com domain in production, you would need to add it as an origin:

origins 'localhost:8080', 'your-app.com'

Note in the resource section that the credentials option must be set to true to indicate that a request can be made using cookies. This option works in conjunction with setting axios.defaults.withCredentials to true in the web app.

While on the topic of origins, by default Rails 5 now checks a request’s Origin header as an additional defense against CSRF attacks. Since our web app origin will always be different than the API origin, the check will fail. To disable the check, I added the following line to cors.rb:

Rails.application.config.action_controller.forgery_protection_origin_check = false

Required API-Only Middleware

If you generate a Rails 5 app with the --api flag, the application doesn’t include the middleware for cookies and sessions. However, you can explicitly add the cookie and session management middleware using config.middleware.use in the application.rb file, like so:

class Application < Rails::Application
  config.load_defaults 5.2

  config.api_only = true

  config.middleware.use ActionDispatch::Cookies
  config.middleware.use ActionDispatch::Session::CookieStore
end

Also, the generated ApplicationController will subclass ActionController::API which doesn’t include cookie and session functionality. You can add that functionality by including two modules:

class ApplicationController < ActionController::API
  include ActionController::Cookies
  include ActionController::RequestForgeryProtection

  protect_from_forgery with: :exception

  ...

end

Alternatively, you can change ApplicationController to subclass ActionController::Base which includes all the functionality required for browser access, including cookies and sessions.

Displaying a Login/Logout Status

Our web app needs to know if a user is logged in or not so that appropriate login/logout HTML elements can be rendered. My first inclination was to check for the presence of the session cookie. However, it’s an HttpOnly cookie (for good reason) which means JavaScript can’t even detect its presence.

In lieu of that, I started with a simple loggedIn flag. When a user successfully logs in, the flag gets tucked away in localStorage so that it survives browser reloads. Here’s a simplified version:

axios
  .post("/session", { username, password })
  .then(response => {
    localStorage.setItem("loggedIn", true);
  })

Using localStorage is safe in this case because we’re not worried about the flag getting stolen, or even changed in the browser console. The flag is only used to determine which UI elements are rendered and to prevent unnecessary redirects to the login page. Setting the flag to true does not allow access to protected API resources. Any attempt to access a protected API resource without a valid session cookie will fail.

Taking this a step further, I wanted to display the logged-in user’s name. So I changed the /session endpoint to include the name in the response body if the user has successfully logged in. For example:

{ "username":"mike" }

Then instead of storing a simple boolean flag, the user object is cached in localStorage:

axios
  .post("/session", { username, password })
  .then(response => {
    localStorage.setItem("user", JSON.stringify(response.data));
  })

The user object serves as both cached user data that can be efficiently rendered in the UI and as a flag indicating that the user is logged in.

Using localStorage is perfect for cases like this where you want to cache non-sensitive data for performance reasons.

Conclusion

For our particular application, using session cookies for authentication seemed to make the most sense. It’s relatively easy and takes advantage of the battle-tested CSRF protection already in Rails.

That being said, my experience using this approach is limited to this educational application. So please don’t take any of this as the “right” or “only” way to do it. The right way, as always, will vary depending on the situation.

Many thanks to Rao for responding to my question on Twitter and sharing his thoughtful insights before I wrote this up. Your feedback was instrumental in arriving at this solution!

I would love to hear from folks who are either using session cookies in this way to good effect or have decided to take a different path. I fully expect that I’ve overlooked something. Your feedback would be very much appreciated!

Thanks!

Mike Clark

Unpack a Vue.js + Rails API App

Learn what it takes to put together a single-page web app using Vue.js backed by a Rails API in our Unpacked: Single Page App video course. Authentication is just one aspect of the application we unpack. No need to piece together solutions yourself. Use this full-stack application as a springboard for creating your own apps!