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:

  1. The user signs in.
  2. Rails creates a Session record.
  3. The server encodes the session ID into a signed JWT.
  4. The mobile client stores the JWT securely.
  5. Each authenticated request includes Authorization: Bearer <jwt>.
  6. The server validates the JWT, extracts the session ID, and loads the corresponding Session.
  7. 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 Authorization header 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.