User Authentication in React via Rails API

A comprehensive guide to User authentication for Rails/React

John Guest
Jun 21 · 11 min read
Image for post
Image for post

So you have decided that your new project is going to use Rails API for the server-side and React for the client-side. You enter rails new and create-react-app and get to work. You decide that you want to implement user authentication. Aside from a few configuration changes to allow the use of cookies (which I will explain very soon), where to start?

In this article, I will walk through the needed steps and demonstrate how this is very straightforward and simple. I will be using Rails 5 but everything should work for Rails 6 as well.

Phase 1: Configuring Rails App for Cookies

If you did not use the --api flag in the rails new command the following steps should not be necessary. If you are starting from an existing Rails API there are a few configuration changes that I will cover in this section.

The gems you will need are bcrypt and rack-cors . The gem ‘bcrypt-ruby’ is a Ruby binding for the OpenBSD bcrypt() password hashing algorithm, allowing you to easily store a secure hash of your user passwords. The latter is middleware for handling Cross-Origin Resource Sharing (CORS), which makes cross-origin AJAX possible. It will allow you to whitelist the origin of requests to the Rails server.

Place those in Gemfile and run bundle.

#Gemfilegem 'bcrypt'gem 'rack-cors'

Let’s start with “application_controller.rb”. You will see the following.

#application_controller.rbclass ApplicationController < ActionController::API

The API Controller is a lightweight version of ActionController::Base, created for applications that don’t require all functionalities that a complete Rails controller provides, allowing you to create controllers with just the features that you need for API only applications. So to get the complete functionality of a Rails controller, we will change this line.

#application_controller.rbclass ApplicationController < ActionController::Base

Next, we will move to “config/application.rb” where we will apply middleware that will allow us to send receive cookies. Add these two lines.

#config/application.rbconfig.middleware.use ActionDispatch::Cookies    config.middleware.use ActionDispatch::Session::CookieStore

We will also change the line that statesconfig.api_only = true to config.api_only = false. It should be around line 37.

You will now need to create two files at “config/initializers/cookie_serializer.rb” and “config/initializers/session_store.rb” . This will configure how our server will handle the HTTP requests. The server needs to know the key and the domain.

#config/initializers/cookie_serailizer.rbRails.application.config.action_dispatch.cookies_serializer = :hybrid#config/initializers/session_store.rbif Rails.env === 'production' 
Rails.application.config.session_store :cookie_store, key: '_name-of-your-app', domain: 'name-of-you-app-json-api'
else
Rails.application.config.session_store :cookie_store, key: '_name-of-your-app'

By convention, the key name is your application’s name and should start with an underscore.

The next file you will need to create is also in “config/initializers”. In that folder create a file called “cors.rb” and insert the following.

#config/initializers/cors.rbRails.application.config.middleware.insert_before 0, Rack::Cors do 
allow do
origins 'http://localhost:3000'

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

By default, your React app will run on “http://localhost:3000”. The above code gives all requests from that address access to all server resources and allows them to utilize all HTTP methods.

Another important change is in “config/puma.rb”. We will change the default Rails server from 3000 to 3001 to prevent the server and client from attempting to run on the same server.

#config/puma.rb# Specifies the `port` that Puma will listen on to receive requests; default is 3000.port        ENV.fetch("PORT") { 3001 }

And that’s it for our config changes. You should be set up to start building the controllers!

Phase 2: The User Model and Sessions Controller

The easiest way to get a user model going and using bcrypt for secure passwords is with rails generate.

rails g model User username email password_digest

The careful observer will note that we are creating a password_digest column instead of “password”. This is what is required by the bcrypt gem as the column in the user table in which to store the encrypted password. Thanks to the magic of encryption, the user model will never see the actual password and our database will never store the password but it will store a “hashed” and “salted” version of the password called a password_digest.

We can now create and migrate our database.

rake db:create && rake db:migrate

After migration, it is best practice to check “db/schema.rb” to make sure that everything was set up correctly. The user’s table section of the file should look something like this.

#db/schema.rbcreate_table "users", force: :cascade do |t|
t.string "password_digest"
t.string "username"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end

To use the secure password functions of bcrypt will need to add a line in “user.rb”. This is also where we can use a little ActiveRecord help and add some validations to the model.

#app/models/user.rbclass User < ApplicationRecord
has_secure_password
validates :username, presence: true
validates :username, uniqueness: true
validates :username, length: { minimum: 4 }
end

Now we can draw start to plan out what kind of actions a user will need. Of course, a user will need to create a username and password and will need to see there own data. We can take care of those needs with just two routesshow and create. We will add a third route so we have access to an action that will correspond to a list of all users and as per convention, this route will be named index. Here is the “config/routes.rb” so far.

#config/routes.rbRails.application.routes.draw doresources :users, only: [:create, :show, :index]end

The file can also look like the following if, to quote The Dude, “you’re not into the whole brevity thing”.

#config/routes.rbRails.application.routers.draw.dopost '/users',         to: 'users#create'
get '/users/:user_id', to: 'users#show'
get '/users', to: 'users#index'

Now we can connect the user’s controller. We will build actions that correspond to each of the routes. I will be using standard “REST-ful” routing and assuming that you are familiar. You will need to create a file “app/controllers/users_controller.rb”.

#app/controllers/users_controller.rbclass UsersController < ApplicationController

def index
@users = User.all
if @users
render json: {
users: @users
}
else
render json: {
status: 500,
errors: ['no users found']
}
end
end
def show
@user = User.find(params[:id])
if @user
render json: {
user: @user
}
else
render json: {
status: 500,
errors: ['user not found']
}
end
end

def create
@user = User.new(user_params)
if @user.save
login!
render json: {
status: :created,
user: @user
}
else
render json: {
status: 500,
errors: @user.errors.full_messages
}
end
end
private

def user_params
params.require(:user).permit(:username, :password, :password_confirmation)
end
end

In the create action I am using a helper method login. We will define this method and others in the next step. The natural home for helper methods is the “parent” of the UsersController, ApplicationController.

#app/contollers/application_controller.rbclass ApplicationController < ActionController::Base
skip_before_action :verify_authenticity_token
helper_method :login!, :logged_in?, :current_user, :authorized_user?, :logout!, :set_user

def login!
session[:user_id] = @user.id
end
def logged_in?
!!session[:user_id]
end
def current_user
@current_user ||= User.find(session[:user_id]) if session[:user_id]
end
def authorized_user?
@user == current_user
end
def logout!
session.clear
end
def set_user
@user = User.find_by(id: session[:user_id])
end
end

A few things to note here. At the top we haveskip_before_action :verify_authenticity_token . This is a security token generated by Rails to prevent cross-site request forgery (CSRF). We disable this to prevent “forbidden” parameters that would cause errors in our controller actions. We also have helper_method :login!, :logged_in?, :current_user, :authorized_user?, :logout!. This allows for these methods to be passed to all other controllers in the app. We will shortly be using user data to create “sessions”. The sessions will be created and managed in the SessionsController. First, we should draw the sessions routes. We will need login and logout and we will add a GET route for /logged_in that will tell us the login status and the current logged in user.

#config/routes.rbRails.application.routes.draw dopost '/login',    to: 'sessions#create'
post '/logout', to: 'sessions#destroy'
get '/logged_in', to: 'sessions#is_logged_in?'
resources :users, only: [:create, :show, :index] do
resources :items, only: [:create, :show, :index, :destroy]
end
end

Now, for the controller.

#app/controllers/sessions_controller.rbclass SessionsController < ApplicationControllerdef create
@user = User.find_by(username: session_params[:username])

if @user && @user.authenticate(session_params[:password])
login!
render json: {
logged_in: true,
user: @user
}
else
render json: {
status: 401,
errors: ['no such user, please try again']
}
end
end
def is_logged_in?
if logged_in? && current_user
render json: {
logged_in: true,
user: current_user
}
else
render json: {
logged_in: false,
message: 'no such user'
}
end
end
def destroy
logout!
render json: {
status: 200,
logged_out: true
}
end
privatedef session_params
params.require(:user).permit(:username, :password)
end
end

We are authenticating the user data with .authenticate, a method that the user model gets from bcrypt and the has_secure_password line in the user class.

Phase 3: The React Client

Again, I am assuming that you have a functioning React app that you will be working with and basic knowledge of React. To best follow along, your React app should be placed in a directory “/client” that is in the top level of the Rails app. We can now install some dependencies that you will need. We will be using Axios for the calls to the API and react-router for client-side routing.

npm i axios && npm i react-router && npm i react-router-dom

The main container and acting router of the client app will be “App.js”. Here, we will import dependencies, convert App into a functional component into a class component as well as write a mock-up of our routes. We are using which renders component exclusively as opposed to inclusively meaning each component is rendered on its own without appearing inside of another component.

//client/src/App.jsimport React, { Component } from 'react';
import axios from 'axios'
import {BrowserRouter, Switch, Route} from 'react-router-dom'
class App extends Component {
constructor(props) {
super(props);
this.state = {
isLoggedIn: false,
user: {}
};
};
render() {
return (
<div>
<BrowserRouter>
<Switch>
<Route exact path='/' component={}/>
<Route exact path='/login' component={}/>
<Route exact path='/signup' component={}/>
</Switch>
</BrowserRouter>
</div>
);
}
};
export default App;

We will need two methods that will handle the state of the component. They will update state with the status isLoggedIn and user data that will be returned from the API. The next post on my Medium will involve the use of redux and thunk to manage and dispatch actions to a reducer that we will build to handle much of the login logic. For now, we will use the state of the App component.

//client/src/App.jshandleLogin = (data) => {
this.setState({
isLoggedIn: true,
user: data.user
})
}
handleLogout = () => {
this.setState({
isLoggedIn: false,
user: {}
})
}

These two methods will update state with the current session data so user data can be made available only to the logged-in user. How do we get the session data? Obviously, we will make aGET request to our /logged_in route that will send us the status and the user but where is the best place to do this? Well, we want it to updated every time that the App renders so naturally, we use componentDidMount(). First let’s make the request to the backend.

//client/src/App.jsloginStatus = () => {
axios.get('http://localhost:3001/logged_in',
{withCredentials: true})
.then(response => {
if (response.data.logged_in) {
this.handleLogin(response)
} else {
this.handleLogout()
}
})
.catch(error => console.log('api errors:', error))
};

Here we use Axios to send a GET request to our /logged_in route making sure to add {withCredentials: true}. With this credentials object the request will not work. You can see here one of the advantages of using Axios over fetch(). In a fetch() request, the object that is returned has to be converted to JSON using the .json() function. Axios converts the response to JSON automatically. Now we can add this function to componentDidMount().

//client/src/App.jscomponentDidMount() {
this.loginStatus()
}

Our App component should now look like this.

//client/src/App.jsimport React, { Component } from 'react';
import axios from 'axios'
import {BrowserRouter, Switch, Route} from 'react-router-dom'class App extends Component {
constructor(props) {
super(props);
this.state = {
isLoggedIn: false,
user: {}
};
}
componentDidMount() {
this.loginStatus()
}
loginStatus = () => {
axios.get('http://localhost:3001/logged_in',
{withCredentials: true})
.then(response => {
if (response.data.logged_in) {
this.handleLogin(response)
} else {
this.handleLogout()
}
})
.catch(error => console.log('api errors:', error))
}
handleLogin = (data) => {
this.setState({
isLoggedIn: true,
user: data.user
})
}
handleLogout = () => {
this.setState({
isLoggedIn: false,
user: {}
})
}

render() {
return (
<div>
<BrowserRouter>
<Switch>
<Route exact path='/' component={}/>
<Route exact path='/login' component={}/>
<Route exact path='/signup' component={}/>
</Switch>
</BrowserRouter>
</div>
);
}
}
export default App;

Phase 5: Home, Login, and Signup Components

We now have a solid backbone of a front end user authentication system. What we are missing now is a place for the user to log in and sign up. First, we will start with the Home component which will act as our landing screen. It will be stateless and functional.

//client/src/Home.jsimport React from 'react';
import {Link} from 'react-router-dom'
const Home = () => {
return (
<div>
<Link to='/login'>Log In</Link>
<br></br>
<Link to='/signup'>Sign Up</Link>
</div>
);
};
export default Home;

Home is just a place to provide links to our other components usingLink imported from react-router-dom. The Login and Signup components will be almost identical and could be made into one however, we will not go into how to combine the elements into one in the interest of staying focused on our main goal. They will be using basic controlled forms and Signup will have an added attribute of password_confirmation (using snake_case as opposed to camelCase because this key will be read by Ruby and we like consistency).

Login:

//client/src/Login.jsimport React, { Component } from 'react';
import axios from 'axios'
import {Link} from 'react-router-dom'
class Login extends Component {
constructor(props) {
super(props);
this.state = {
username: '',
email: '',
password: '',
errors: ''
};
}
handleChange = (event) => {
const {name, value} = event.target
this.setState({
[name]: value
})
};
handleSubmit = (event) => {
event.preventDefault()
};
render() {
const {username, email, password} = this.statereturn (
<div>
<h1>Log In</h1>
<form onSubmit={this.handleSubmit}>
<input
placeholder="username"
type="text"
name="username"
value={username}
onChange={this.handleChange}
/>
<input
placeholder="email"
type="text"
name="email"
value={email}
onChange={this.handleChange}
/>
<input
placeholder="password"
type="password"
name="password"
value={password}
onChange={this.handleChange}
/>
<button placeholder="submit" type="submit">
Log In
</button>
<div>
or <Link to='/signup'>sign up</Link>
</div>

</form>
</div>
);
}
}
export default Login;

Signup:

//client/src/Signup.jsimport React, { Component } from 'react';
import axios from 'axios'class Signup extends Component {
constructor(props) {
super(props);
this.state = {
username: '',
email: '',
password: '',
password_confirmation: '',
errors: ''
};
}
handleChange = (event) => {
const {name, value} = event.target
this.setState({
[name]: value
})
};
handleSubmit = (event) => {
event.preventDefault()
};
render() {
const {username, email, password, password_confirmation} = this.state
return (
<div>
<h1>Sign Up</h1>
<form onSubmit={this.handleSubmit}>
<input
placeholder="username"
type="text"
name="username"
value={username}
onChange={this.handleChange}
/>
<input
placeholder="email"
type="text"
name="email"
value={email}
onChange={this.handleChange}
/>
<input
placeholder="password"
type="password"
name="password"
value={password}
onChange={this.handleChange}
/>
<input
placeholder="password confirmation"
type="password"
name="password_confirmation"
value={password_confirmation}
onChange={this.handleChange}
/>

<button placeholder="submit" type="submit">
Sign Up
</button>

</form>
</div>
);
}
}
export default Signup;

Let’s break these components down a bit. The forms are what we call controlled forms. This means that the state of the component is updated vie onChange() so that when the form is complete all of the data in the form is in our state. We then utilize onSubmit() to call our handleSubmit() functions.

We can now use axios.post which will need an object that will be sent to the specified route. Here is handleSubmit() in Login.

//client/src/Login.jshandleSubmit = (event) => {
event.preventDefault()
const {username, email, password} = this.statelet user = {
username: username,
email: email,
password: password
}

axios.post('http://localhost:3001/login', {user}, {withCredentials: true})
.then(response => {
if (response.data.logged_in) {
this.props.handleLogin(response.data)
this.redirect()
} else {
this.setState({
errors: response.data.errors
})
}
})
.catch(error => console.log('api errors:', error))
};
redirect = () => {
this.props.history.push('/')
}
handleErrors = () => {
return (
<div>
<ul>
{this.state.errors.map(error => {
return <li key={error}>{error}</li>
})}
</ul>
</div>
)
};

We have also added handleErrors() that will render our errors from the .catch() function in our request.

//client/src/Signup.jshandleSubmit = (event) => {
event.preventDefault()
const {username, email, password, password_confirmation} = this.state
let user = {
username: username,
email: email,
password: password,
password_confirmation: password_confirmation
}
axios.post('http://localhost:3001/users', {user}, {withCredentials: true})
.then(response => {
if (response.data.status === 'created') {
this.props.handleLogin(response.data)
this.redirect()
} else {
this.setState({
errors: response.data.errors
})
}
})
.catch(error => console.log('api errors:', error))
};
redirect = () => {
this.props.history.push('/')
}
handleErrors = () => {
return (
<div>
<ul>{this.state.errors.map((error) => {
return key={error}>{error}</li>
})}
</ul>
</div>
)
};

We use our user object from state to post to the desired route remembering to include {withCredentials:true}. We have also included a method for redirecting after handleSubmit()is complete. We have access to the history props passed to us from react-router-dom when we use render=props on App.js.

We now have a user authentication system for our React client! How you utilize it is up to you. You will have access to isLoggedIn and can pass it as props wherever you need it to prevent or allow users to have access to components and data within your app.

The Startup

Medium's largest active publication, followed by +731K people. Follow to join our community.

Medium is an open platform where 170 million readers come to find insightful and dynamic thinking. Here, expert and undiscovered voices alike dive into the heart of any topic and bring new ideas to the surface. Learn more

Follow the writers, publications, and topics that matter to you, and you’ll see them on your homepage and in your inbox. Explore

If you have a story to tell, knowledge to share, or a perspective to offer — welcome home. It’s easy and free to post your thinking on any topic. Write on Medium

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store