Securing a Ruby on Rails API with JWT

May 06, 2023

Introduction

When building a web application with separate frontend and backend, securing the API is a crucial aspect. In this blog post, we will discuss how to secure a Ruby on Rails API using authentication with JSON Web Tokens (JWT) and how to consume it in a React application.

Assumptions

This blog post assumes that the reader is familiar with Ruby on Rails and React. If you are not familiar with these technologies, you can find plenty of resources online to learn the basics. Additionally, we assume that you already have a working Rails API and a React application that needs to consume it.

Setting up the Rails API

First, let's start by setting up the Rails API. We will use the devise-jwt gem to handle authentication with JWT. Add the following lines to your Gemfile:

gem 'devise'
gem 'devise-jwt'

Then, run bundle install to install the gems. Next, run the Devise generator to create a User model with some necessary fields:

rails g devise:install
rails g devise User
rails db:migrate

Now, we need to configure the devise-jwt gem. Create a new file config/initializers/devise.rb and add the following lines:

Devise.setup do |config|
  # ...
  config.jwt do |jwt|
    jwt.secret = ENV['DEVISE_JWT_SECRET_KEY']
    jwt.expiration_time = 1.day.to_i
    jwt.dispatch_requests = [
      ['POST', %r{^/users/sign_in$}]
    ]
  end
end

This configuration sets the JWT secret key, the expiration time for the token, and the endpoint for the sign-in action.

Next, let's create a sessions_controller.rb file to handle the sign-in action:

class SessionsController < Devise::SessionsController
  respond_to :json

  def create
    user = User.find_for_authentication(email: params[:user][:email])
    if user&.valid_password?(params[:user][:password])
      token = JWT.encode({ user_id: user.id }, ENV['DEVISE_JWT_SECRET_KEY'])
      response.set_cookie('jwt', { value: token, httponly: true, secure: Rails.env.production? })
      render json: { success: true, user: { email: user.email } }
    else
      render json: { success: false, errors: ['Invalid email or password'] }
    end
  end
end

This controller action finds the user by email and validates the password. If the credentials are correct, it generates a JWT and sets it as a cookie in the response.

Now, let's create a posts_controller.rb file to handle the API requests:

class PostsController < ApplicationController
  before_action :authenticate_user!

  def index
    render json: { posts: Post.all }
  end
end

This controller action uses the authenticate_user! method to ensure that only authenticated users can access it.

Finally, let's create a routes.rb file to define the API routes:

Rails.application.routes.draw do
  devise_for :users, controllers: { sessions: 'sessions' }

  resources :posts, only: [:index]
end

This configuration sets up the Devise routes for user authentication and the API route for the posts#index action.

Setting up the React application

Now that we have the Rails API set up, let's move on to the React application. We will use the axios library to make API requests and the js-cookie library to handle cookies.

First, let's install the axios library:

npm install axios

Then, let's create a api.js file to define the API endpoints:

import axios from "axios";

const API_BASE_URL = "http://localhost:3000";

export const signIn = async (email, password) => {
  try {
    const response = await axios.post(`${API_BASE_URL}/users/sign_in`, {
      user: { email, password },
    });
    return response.data;
  } catch (error) {
    throw error.response.data.errors[0];
  }
};

export const getPosts = async jwt => {
  try {
    const response = await axios.get(`${API_BASE_URL}/posts`, {
      headers: { Authorization: `Bearer ${jwt}` },
    });
    return response.data.posts;
  } catch (error) {
    throw error;
  }
};

This file defines two functions: signIn to handle the sign-in action and getPosts to retrieve the posts from the API. The Authorization header is set with the JWT token.

Next, let's create a AuthContext.js file to manage the user authentication state:

import React, { createContext, useState } from "react";
import { signIn as apiSignIn } from "./api";
import Cookies from "js-cookie";

export const AuthContext = createContext();

const AuthProvider = ({ children }) => {
  const [user, setUser] = useState(null);

  const signIn = async (email, password) => {
    const { success, user } = await apiSignIn(email, password);
    if (success) {
      Cookies.set("jwt", user.token, { expires: 1, secure: true });
      setUser({ email: user.email });
    }
  };

  const signOut = () => {
    Cookies.remove("jwt");
    setUser(null);
  };

  return (
    <AuthContext.Provider value={{ user, signIn, signOut }}>
      {children}
    </AuthContext.Provider>
  );
};

export default AuthProvider;

This file defines the AuthContext and the AuthProvider components to manage the user authentication state. The signIn function sends the email and password to the API and sets the JWT as a cookie in the browser. The signOut function removes the cookie and sets the user state to null.

Finally, let's create a Posts.js file to display the posts:

import React, { useContext, useEffect, useState } from "react";
import { AuthContext } from "./AuthContext";
import { getPosts as apiGetPosts } from "./api";

const Posts = () => {
  const { user } = useContext(AuthContext);
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    const getPosts = async () => {
      try {
        const jwt = Cookies.get("jwt");
        const posts = await apiGetPosts(jwt);
        setPosts(posts);
      } catch (error) {
        console.error(error);
      }
    };

    if (user) {
      getPosts();
    }
  }, [user]);

  return (
    <div>
      {posts.map(post => (
        <div key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.content}</p>
        </div>
      ))}
    </div>
  );
};

export default Posts;

This file defines the Posts component to display the posts retrieved from the API. The getPosts function retrieves the JWT from the cookie and sends it with the API request.

Conclusion

In this blog post, we discussed how to secure a Ruby on Rails API with authentication using JWT and how to consume it in a React application. We used the devise-jwt gem to implement JWT authentication in the Rails API and the jwt library to generate and verify JWT tokens. We also used the axios library to make HTTP requests to the API and the js-cookie library to manage cookies in the browser.

We created a Rails API with a User model and implemented authentication using JWT with devise-jwt. We also created an authentication controller to handle the sign-in and sign-out actions.

In the React application, we created an api.js file to define the API endpoints and used the Authorization header to send the JWT token with the requests. We also created an AuthContext.js file to manage the user authentication state and used the js-cookie library to store the JWT token in the browser. Finally, we created a Posts.js file to display the posts retrieved from the API.

Securing a web application with authentication is a crucial aspect of web development. By using JWT, we can create secure and scalable APIs that can be consumed by different clients. The setup of a web application with a separate frontend and backend requires careful consideration of security aspects and best practices to ensure the protection of user data and prevent attacks. With the tools and techniques presented in this blog post, developers can implement secure and robust authentication systems in their Ruby on Rails and React applications.

Are you looking to take the next step as a developer? Whether you're a new developer looking to break into the tech industry, or just looking to move up in terms of seniority, book a coaching session with me and I will help you achieve your goals or check out my available courses.