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 - Back End
1) Generate
Run:
bin/rails generate authentication
bin/rails db:migrate
Read your logs output to see what was generated for you, but in summary:
- Users model -
has_many :sessions
. Declareshas_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
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
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
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
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
And that's the Rails app's code all written for 1st stage implementation of Rails 8 auth served to a React app.
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'
}
}
}
package.json:
{
...
"proxy": "http://localhost:3000"
...
}
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:
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");
});
...
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");
});
...
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));
...
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"
)
}
})
...
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)
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