DEV Community

jbk
jbk

Posted on • Edited on

Rails 8 authentication, via a React frontend.

Keen to try Rails 8's authentication library after using Devise for years, I now find myself needing to build auth into a new React + Rails API project. I put this post together to broadly explain how Rails 8 auth works and to document my implementation, in case it helps others also moving to Rails 8 auth.

(Rails 8.0.1 built as an API app, React 19.1)
(Details of password reset functionality not discussed here, will be in another post)



📜 Rails 8 Auth - General Paradigms

(Rails auth guide here)

Authentication Concern

Most of the Rails 8 auth logic resides in the Authentication module concern. This module handles:

  • creating and loading the session.
  • authenticating the user.
  • setting the Current context.

It runs require_authentication as a before_action for all controller actions. Except an action from this authentication by passing it to an allow_unauthenticated_access call. The concern also provides the helper method authenticated?, made available in views via helper_method. This enables easy conditional rendering based on authentication state — effectively replacing the older if current_user pattern for checking whether a user is signed in. If you need access to the actual user object in views (e.g. Current.user), you’ll need to expose it manually via a helper_method in your controller.

DB-Backed Sessions

Sensitive data (e.g. user_id) is now stored in a Session model on the server. The client only stores a session_id cookie holding the id of the Session db record (this cookie is set in the Authentication module's #start_new_session_for(user) method).

  • More secure – user data stays server-side.
  • Scalable – supports multiple devices & multiple sessions. Stores useful info like user agent & IP address in the Session db record.

Request context via Current

Rails 8 authentication creates a Current model that inherits from ActiveSupport::CurrentAttributes. This acts as a thread-safe, request-scoped singleton class for storing context, i.e. authenticated session & user, throughout the lifecycle of a request. Since Current is isolated per request, its values won’t leak between users, even in a multithreaded server. The Authentication concern module sets the user and session attributes of Current, making them accessible from anywhere in your app (except views), without needing to pass them around explicitly.

  • Current.session – gets the current DB-backed session object.
  • Current.user – gets the current authenticated user (Current model declares; delegate :user, to: :session, so Current.session.user === Current.user).

(e.g.'s of Current object being set can be seen in the Authentication module's; #start_new_session_for(user), resume_session & terminate_session methods)



đź”§ Implementation

Rails logo

Rails - Back End


1) Generate

Run:

bin/rails generate authentication
bin/rails db:migrate
Enter fullscreen mode Exit fullscreen mode

Read your logs output to see what was generated for you, but in summary:

  • Users model - has_many :sessions. Declares has_secure_password which uses gem 'bcrypt' to encrypt/decrypt password to a password_digest column, & provides ActiveSupport's Tokens module for password reset functionality use.
  • Session model & sessions table - belongs_to :user.
  • Current model - in memory, no table.
  • Sessions & Passwords controllers.
  • Authentication concern.
  • Passwords new and edit view and reset mailer & reset mailer html.

The generator provides all logic, controllers, actions, migrations for user authentication, and the mailers and logic for password reset. However it does not provide templates for user or session creation, editing or deleting – we need to write these ourselves.

A read of the generated Authentication module concern will greatly help your understanding of how the User, Session & Current models work together to produce the authentication logic.


2) Configure

We need to configure our Rails app to allow requests from a different origin (i.e. our React app). Add gem "rack-cors" to gemfile, run bundle install then add a cors.rb initializer file:

config/initializers/cors.rb:

Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins Rails.credentials.trusted_origins
    resource "*",
      headers: :any,
      methods: %i[get post put patch delete options head],
      credentials: true
  end
end
Enter fullscreen mode Exit fullscreen mode

Note:

  • set your trusted_origins credential for dev & prod (bin/rails credentials:edit --environment development).
  • credentials: true enables cookies to be passed with each request.

Read the Authentication concern's #start_new_session_for(user) method to see how the session cookie is created, note:

  • httponly: true ensures JS cannot access the cookie.
  • same_site: :lax ensures that a different origin can make requests from the server.
  • secure: true ensures cookies are only sent over https in production.

Now we need to set Rails' CSRF settings to allow requests from the React server Origin, add this to app/controllers/application_controller.rb

class ApplicationController < ActionController::Base
  protect_from_forgery with: :exception

  private
  def verified_request?
    origins = Rails.application.credentials.trusted_origins || []
    valid = super || origins.include?(request.origin)
    Rails.logger.warn("Blocked CSRF request from #{request.origin}") unless valid
    valid
  end
end
Enter fullscreen mode Exit fullscreen mode

This ensures that an exception is thrown if a valid authenticity token is not found, and allows Rails to accept requests from the Origins we've explicitly set in credentials.trusted_origins.


3) Control

Now to write the controllers for our React app to call, and to namespace them with 'API' for clarity. A new Api::UsersController to handle User sign up/registration – auth generator never gave us a UsersController because it doesn't write user sign up for us. And a new Api::SessionsController to handle session actions – auth generator did give us a SessionsController, so we essentially replicate it's logic, but adapting it to only deal with JSON requests and responses:

controllers/api/users_controller.rb

class Api::UsersController < ApplicationController
  allow_unauthenticated_access only: %i[ create ]

  def create
    @user = User.new(user_params)
    if @user.save
      render json: { user: @user, notice: "User created successfully" }
    else
      render json: { errors: @user.errors.full_messages, status: :unprocessable_entity }
    end
  end

  private
  def user_params
    params.require(:user).permit(:email_address, :password, :password_confirmation)
  end
end
Enter fullscreen mode Exit fullscreen mode

controllers/api/sessions_controller.rb

class Api::SessionsController < ApplicationController
  allow_unauthenticated_access only: %i[ create ]

  def create
    if user = User.authenticate_by(params.permit(:email_address, :password))
      start_new_session_for user
      render json: { user: user, authenticated: true, notice: "Successfully logged in" }
    else
      render json: { error: "Invalid email or password" }, status: :unauthorized
    end
  end

  def show
    if Current.session.user
      render json: { authenticated: true, user: Current.session.user }
    else
      render json: { authenticated: false, error: "Not logged in" }, status: :unauthorized
    end
  end

  def destroy
    begin
      terminate_session
      render json: { notice: 'successfully logged out' }
    rescue => e
      render json: { error: "Failed to log out: #{e.message}" }, status: :internal_server_error
    end
  end

end

Enter fullscreen mode Exit fullscreen mode

Points to note:

  • Sessions#show - for authentication of existing session route.
  • The #create action of each controller needs unauthenticated access.

4) Route
Now create the routes:
config/routes.rb

 namespace :api do
    resource :session, only: [:create, :show, :destroy]
    resource :user, only: [:create]
  end
Enter fullscreen mode Exit fullscreen mode

And that's the Rails app's code all written for 1st stage implementation of Rails 8 auth served to a React app.



React Logo

React - Front End


1) Configure
In order for relative paths in your fetch requests to route to the correct host, i.e. your rails server, add server proxy to either vite.config.js – if you built with vite, or to your package.json – if you built with Create React App:
vite.config.js:

export default {
  server: {
    proxy: {
      '/api': 'http://localhost:3000'
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

package.json:

{
  ...
  "proxy": "http://localhost:3000"
  ...
}
Enter fullscreen mode Exit fullscreen mode

2) Fetch
Now to build the various fetches to call the Rails API's endpoints that we created. I built the sign up and sign in fetch requests into an AuthForm.jsx component. A 'Sign in' or 'Sign up' conditional link at the foot of the form sets a default false signUp state in the component. A password conformation input is rendered if signUp is true. On form submission, if(signUp) then a POST request is made to the Rails app's /api/user endpoint (users#create), or if(!signUp) a POST request is made to the /api/session endpoint (sessions#create) for sign in.

The AuthForm.jsx component rendered in both signUp states:
AuthForm.jsx component

The fetch code:

sign up, routes to Api::UsersController#create:

...
fetch("/api/user", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          user: {
            email_address: e.target.email_address.value,
            password: e.target.password.value,
            password_confirmation: e.target.password_confirmation.value,
          },
        }),
      })
      .then(async (res) => {
        const data = await res.json();
        if (res.ok) {
          setIsSignUp(false);
          showAlert(data.notice);
        } else {
          showAlert(
            data.errors
              ? data.errors.join(", ")
              : "Sign up failed, fetch response was not ok, and no JSON response errors object"
          );
        }
      })
      .catch((err) => {
        showAlert(err.message || "Sign up failed, fetch threw an error, and there was no err.message object");
      });
...
Enter fullscreen mode Exit fullscreen mode

sign in, routes to Api::SessionsController#create

...
      fetch("/api/session", {
        method: "POST",
        credentials: "include",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          email_address: e.target.email_address.value,
          password: e.target.password.value,
        }),
      })
      .then(async (res) {
        const data = await res.json();
        if(res.ok) {
          handleSignIn(data);
          showAlert(data.notice);
        } else {
          showAlert(data.error || "Sign in failed, fetch response not ok, and was no JSON response errors object");
        }
      })
      .catch((err) => {
        showAlert(err.message || "Sign in failed, fetch threw an error, and there was no err.message object");
      });
...
Enter fullscreen mode Exit fullscreen mode

Every time React app App.jsx component mounts, i.e. on browser reload, a useEffect sends a fetch GET request to /api/session, which routes to Api:SessionsController#show, uses the browser's session_id cookie to authenticate and then sets local loggedIn and user state.

authenticate current session, routes to sessions#show

...
 fetch('/api/session', { credentials: 'include' })
    .then(async res => {
      const data = await res.json();
      if(res.ok) {
        setAuthChecked(true);
        setUser(data.user);
        setLoggedIn(data.authenticated);  
      } else {
        setLoggedIn(data.authenticated);  
        setAuthChecked(true);
        showAlert(data.error || "Authentication failed, fetch response not ok, and was no JSON response errors object");  
      }
    })
    .catch(() => setAuthChecked(true));
...
Enter fullscreen mode Exit fullscreen mode

Sign out sends a DELETE request to Api:SessionsController#destroy which calls Rails' Authentication concern module's #terminate_sesion method which deletes the Current object and deletes the session_id cookie.

sign out, routes to sessions#destroy

...
fetch('/api/session', {
      method: 'DELETE'
    })
    .then(async res => {
      const data = await res.json();
      if (res.ok) {
        showAlert(data.notice)
        setUser(null)
        setLoggedIn(false)
      } else {
        showAlert(data.errors
          ? data.errors.join(', ')
          : "Log out failed, and no errors object in JSON response"
        )
      }
    })
...
Enter fullscreen mode Exit fullscreen mode

points of note:

  • for brevity React state and render code omitted.
  • sign up, sign in and sign out fetches are triggered by browser event callbacks (i.e. from sign up/in form submission, or log out button click).
  • the authentication fetch is triggered called in an App.jsx useEffect on component mount / page reload.
  • For the React app visitor to be served anything other than the AuthForm.jsx loggedIn state must be set, which is done by the sign in, sign out or authenticate current session fetches.
  • Note that the credentials: 'include' fetch option ensures the browser will send cookies with the request and set any cookies from the response.

Now that's all of our React app code up and running for an initial implementation of user and session authentication delivered by Rails 8 API.



Production considerations:

  • Serve both Rails and React over HTTPS.
  • Set your 'trusted_origins' values for dev & prod.
  • If using subdomains, set the cookie domain accordingly.

Evolving this implementation for me will be:

  • building in password reset UI and wiring it up to the given Rails auth password reset functionality (I'll publish details of this in a separate post)
  • abstracting the React authentication fetches out of components and into a separate hook.
  • drying out of auth fetch code.
  • Creation of RegistrationsController in the Rails app
  • Implementing sign up mailers (password reset mailers already provided by Rails 8 auth)

Thanks for reading, any code suggestions/improvements/corrections please do let me know.

Top comments (1)

Collapse
 
jbk2 profile image
jbk • Edited

Here's the follow on article which discusses Rails 8 auth's password reset code.

Some comments may only be visible to logged-in visitors. Sign in to view all comments. Some comments have been hidden by the post's author - find out more