Extending the Rails 8 Auth Generator for Mobile Clients
Before Rails 8
When I first started with Rails, almost every search I made about authentication eventually led me to Devise. It worked like magic for traditional web applications. But as soon as I wanted to support mobile clients, things became much more complicated. At the time, I wasn't familiar enough with Rails or Devise to understand the conventions or confidently customize the authentication flow.
A few years have passed. While the documentation around mobile authentication in Rails has certainly improved, I still feel there is a gap for developers who want to understand how to build a backend that serves both web and mobile clients. Hopefully, this article helps bridge that gap.
We are going to extend the new Rails 8 authentication generator. Out of the box, it provides a simple, well-structured authentication system directly in your application. Rather than replacing it, we will build on top of it to support mobile clients while keeping the implementation small, explicit, and easy to understand.
The Rails 8 Auth Generator
Rails 8 ships with a built-in authentication generator. Run it once and you get
a working, database-backed authentication system. It generates a User model
with has_secure_password, a Session model that persists login data, an
Authentication concern, controllers for sessions and password resets, and all
the necessary routes.
rails generate authentication
For a traditional web application, this is an excellent foundation. Sessions live in the database, meaning they can be easily revoked. Authentication tokens rotate automatically. Password reset tokens expire.
The only assumption the generator makes is that the client is a web browser. Authentication is built entirely around HTTP cookies, which the browser automatically sends with every request.
The Mobile Problem
With the generated setup, Rails creates a session once a user signs in through the browser. It stores that session token in a signed, HTTP-only cookie. From that point on, the browser automatically includes the cookie with every request. This allows Rails to identify the user without the application having to do anything else.
A mobile application works differently. While it can store and send cookies, that is generally not how mobile APIs are designed. The standard approach is for the server to return a token after login. The mobile client stores this token securely (like in the iOS Keychain or Android Keystore) and sends it with every authenticated request as a header:
Authorization: Bearer <token>
Because the generated Rails code only looks for a session token inside a cookie, a mobile client sending a Bearer token will be rejected.
The Approach: JWT Wrapping a Database Session
At this point, it might seem like we need to completely replace the Rails session implementation with JSON Web Tokens (JWTs). We do not.
The Rails generator already gives us something incredibly valuable: a persistent
Session model. Because every authenticated login is stored in the database, it
can be revoked at any time. Features like logging out, listing active devices,
or signing out everywhere all come naturally.
A purely stateless JWT system gives up those capabilities. Once a token is issued, the server has no record of it. That makes revocation much harder without introducing a blocklist or another persistence layer. For many applications that is an acceptable tradeoff, but in our case, we would be throwing away functionality we already have.
Instead, we will keep the database session exactly as Rails designed it. The only thing we are going to change is how the client references it.
Rather than sending the session identifier in a cookie, we will encode it into a
signed JWT. The mobile client stores that JWT and sends it in the
Authorization header. The server verifies the token, extracts the session ID,
and looks up the corresponding Session record in the database.
The new flow looks like this:
- The user signs in.
- Rails creates a
Sessionrecord. - The server encodes the session ID into a signed JWT.
- The mobile client stores the JWT securely.
- Each authenticated request includes
Authorization: Bearer <jwt>. - The server validates the JWT, extracts the session ID, and loads the
corresponding
Session. - If the session exists, the request proceeds. Otherwise, authentication fails.
The result is a familiar Bearer token flow for mobile clients that preserves all the advantages of the native Rails architecture.
Implementation
1. Add the JWT gem
First, we need the jwt gem to sign and verify the tokens sent by our mobile
clients. We will not put user information or permissions inside the payload. It
simply carries the ID of the Session record. The database remains the source
of truth, making the JWT just a secure transport mechanism.
Add the gem to your Gemfile:
# Gemfile
gem "jwt"
Then install it:
bundle install
2. Create a JsonWebToken Class
Next, we will create a small wrapper around the jwt gem. This is a brand new
file we are adding to the application. Its only job is to encode and decode our
tokens, keeping the logic in one place.
Before looking at the code, it is worth understanding what a JWT actually contains. It consists of three parts: a header, a payload, and a signature. The payload is a JSON object where you can store any information your application needs. These are known as claims.
One crucial thing to remember is that JWTs are signed, not encrypted. Anyone holding the token can inspect its payload, but they cannot modify it without invalidating the signature. Because of that, you should never store secrets or sensitive information inside it.
For our implementation, we will keep the payload intentionally small. Since the
session lives in the database, the JWT only needs to carry the session_id.
# app/lib/json_web_token.rb
class JsonWebToken
SECRET_KEY = Rails.application.credentials.jwt_secret_key!
def self.encode(payload)
payload[:exp] = 30.days.from_now.to_i
JWT.encode(payload, SECRET_KEY)
end
def self.decode(token)
decoded = JWT.decode(token, SECRET_KEY)[0]
HashWithIndifferentAccess.new(decoded)
end
end
Generate a secure secret key to sign these tokens:
rails secret
Then store it in your Rails credentials:
rails credentials:edit
jwt_secret_key: <paste-the-generated-secret>
Notice that using credentials.jwt_secret_key! ensures your application will
fail fast if the key is missing, rather than accidentally signing tokens with an
empty secret.
3. Extend the Authentication Concern
When you ran the generator earlier, Rails created
app/controllers/concerns/authenticates.rb. This file assumes every
authenticated request comes from a browser. To support mobile clients, we are
going to open that generated file and make three specific changes:
- Read the JWT from the
Authorizationheader for JSON requests. - Continue using cookies for HTML requests, preserving the existing browser behavior.
- Return the encoded JWT whenever a new session is created so our controllers can include it in their JSON responses.
One thing worth noting: this implementation stores the JWT itself inside the signed cookie for web requests, replacing the vanilla generator's native session token format. Both web and mobile now go through the same JWT decode path, which keeps the authentication logic unified at the cost of diverging slightly from the out-of-the-box generated code.
Here is the complete updated concern. We are replacing the original
resume_session and start_new_session_for methods, and adding a few helper
methods to parse the headers.
# app/controllers/concerns/authenticates.rb
module Authenticates
extend ActiveSupport::Concern
included do
skip_before_action :verify_authenticity_token, if: :json_request?
before_action :require_authentication
helper_method :authenticated?
end
class_methods do
def allow_unauthenticated_access(**options)
skip_before_action :require_authentication, **options
end
def disallow_authenticated_access(**options)
before_action :redirect_authenticated_users, **options
end
end
private
def authenticated?
resume_session
end
def require_authentication
resume_session || request_authentication
end
def resume_session
Current.session ||= find_session
end
def find_session
decoded_jwt = JsonWebToken.decode(request_encoded_jwt)
Session.find_by(id: decoded_jwt[:session_id])
rescue JWT::DecodeError
nil
end
def request_encoded_jwt
json_request? ? encoded_jwt_from_auth_header : encoded_jwt_from_cookie
end
def encoded_jwt_from_cookie
cookies.signed[:jwt]
end
def encoded_jwt_from_auth_header
auth_header = request.headers["Authorization"]
auth_header&.delete_prefix("Bearer ")&.presence
end
def request_authentication
session[:return_to_after_authenticating] = request.url
respond_to do |format|
format.html { redirect_to new_session_url }
format.json { render json: { error: "unauthorized" }, status: :unauthorized }
end
end
def after_authentication_url
session.delete(:return_to_after_authenticating) || root_url
end
def redirect_authenticated_users
redirect_to after_authentication_url if html_request? && authenticated?
end
def start_new_session_for(user)
Current.session = user.sessions.create!(
user_agent: request.user_agent,
ip_address: request.remote_ip
)
encoded_jwt = JsonWebToken.encode({ session_id: Current.session.id })
if html_request?
cookies.signed.permanent[:jwt] = {
value: encoded_jwt,
httponly: true,
same_site: :lax
}
end
encoded_jwt
end
def terminate_session
Current.session.destroy
cookies.delete(:jwt)
end
def json_request?
request.format.json?
end
def html_request?
request.format.html?
end
end
Although the code looks long, the authentication flow is still entirely based on
the Session model Rails generated for us. Everything else, like loading the
user, setting Current.session, and protecting routes, continues to work
exactly as it did before.
4. Update the SessionsController
Rails also generated a SessionsController to handle user logins. We need to
open it and update the create action to return the JWT so mobile clients can
actually store it. We will also update the destroy action to return a success
status for API requests.
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
allow_unauthenticated_access only: %i[new create]
disallow_authenticated_access only: %i[new create]
rate_limit to: 10, within: 3.minutes, only: :create, with: -> {
respond_to do |format|
format.html { redirect_to new_session_url, alert: "Try again later." }
format.json { render json: { error: "Too many requests" }, status: :too_many_requests }
end
}
def new; end
def create
@user = User.authenticate_by(session_params)
if @user
@jwt = start_new_session_for(@user)
respond_to do |format|
format.html { redirect_to after_authentication_url, notice: "Welcome back!" }
format.json { render json: { token: @jwt, user: @user }, status: :created }
end
else
respond_to do |format|
format.html { redirect_to new_session_url, alert: "Invalid email or password", status: :unprocessable_entity }
format.json { render json: { error: "unauthorized" }, status: :unauthorized }
end
end
end
def destroy
terminate_session
respond_to do |format|
format.html { redirect_to new_session_url, notice: "See you soon!" }
format.json { render json: {}, status: :ok }
end
end
private
def session_params
params.require(:session).permit(:email, :password)
end
end
For web requests, nothing changes. Rails continues setting cookies under the hood. But for JSON requests, the controller now explicitly returns the encoded JWT in the payload after a successful login.
One caution: render json: { token: @jwt, user: @user } will serialize all
attributes on the User model, including password_digest. In production you
should scope the user representation, for example
user: @user.as_json(only: [:id, :email]), or use a dedicated serializer.
5. Create the RegistrationsController
The Rails 8 generator gives you session management, but it leaves user registration up to you. When you build your signup controller, it follows the exact same pattern as login.
We will create a new User, and then immediately start a session for them using
the same start_new_session_for method we updated earlier.
# app/controllers/registrations_controller.rb
class RegistrationsController < ApplicationController
allow_unauthenticated_access only: %i[new create]
disallow_authenticated_access only: %i[new create]
def new; end
def create
@user = User.new(registration_params)
if @user.save
@jwt = start_new_session_for(@user)
respond_to do |format|
format.html { redirect_to after_authentication_url, notice: "Account created!" }
format.json { render json: { token: @jwt, user: @user }, status: :created }
end
else
respond_to do |format|
format.html { render :new, status: :unprocessable_entity }
format.json { render json: { errors: @user.errors.full_messages }, status: :unprocessable_entity }
end
end
end
private
def registration_params
params.require(:registration).permit(:email, :password)
end
end
From the authentication system's perspective, there is no difference between
signing up and logging in. Both result in a new Session record and a JWT
returned to the client.
6. Routes
No new route infrastructure is needed. The existing resource :session route
handles both web and mobile clients since they share the same controller. Add
the registration route alongside it:
# config/routes.rb
resource :session, only: %i[new create destroy]
resource :registration, only: %i[new create]
How a Mobile Client Uses This
To log in, the mobile client makes a standard JSON request:
POST /session
Content-Type: application/json
{ "session": { "email": "user@example.com", "password": "secret" } }
The server responds with the JWT:
201 Created
{ "token": "eyJhbGci...", "user": { "id": 1, "email": "user@example.com" } }
The client securely stores this token. From that point on, it attaches the token to every authenticated request:
GET /workouts
Authorization: Bearer eyJhbGci...
When the user logs out, a delete request is sent to invalidate the session in the database:
DELETE /session
Authorization: Bearer eyJhbGci...
200 OK
Once the session is destroyed in the database, the token is no longer valid.
Even though the JWT is still structurally correct, find_session returns nil
and the request gets rejected.
What You Get
Revocable sessions: Logout actually works. You can easily extend this to "log out all devices" by deleting all database sessions associated with a specific user.
A single authentication system: Mobile and web share the exact same
Session model, the same Current.session object, and the same authorization
logic.
Standard token flow: Mobile clients integrate in a conventional way without cookie workarounds or needing a parallel auth system.
Existing Rails features remain intact: Password resets and related flows work exactly as generated because they rely on token generation rather than session transport.
Token expiration: The JWT is issued with a 30-day expiry. When it expires, the client will receive a 401 and must re-authenticate to receive a new token.
The core idea here is simple. Rails 8 already gives you a complete session system out of the box. Instead of tearing it out to build a mobile API, we only changed how the session identifier travels. Web browsers get cookies, mobile apps get Bearer tokens, and the backend stays clean and unified.