developers

Secure Ruby on Rails RAG Applications with Auth0 FGA

Learn how to secure your Ruby on Rails RAG application using Auth0 FGA to prevent data leakage and implement fine-grained, identity-aware document retrieval.

As the development of AI applications moves forward, there are new attack threats. In RAG applications, vector and embedding vulnerabilities are at the top of security risks. Securing every stage, from generating and storing to retrieving vectors and embeddings, is crucial. In this blog post, we’ll focus on securing a Ruby on Rails RAG application with Auth0 and mitigating the risk of data leakage by ensuring only the right information is retrieved for the right user using fine grained authorization with Auth0 FGA.

Understanding the RAG Application

Before we dive into the authentication and authorization, let's take a look at the code sample. The app is a Work Companion chat that uses internal documentation for the employees of a made-up company. Users have access to different documents depending on their permissions, and the app can only use those documents to answer questions they might have.

Think about a standard company knowledge base. You’ve got sensitive docs from HR, Engineering, and Legal all living in the same vector database.

In a risky RAG setup, if an Engineer asks about "salary bands," the vector search might accidentally grab a snippet from a private HR file and feed it right to the LLM. Not great for privacy.

What we want to achieve is what the diagram below shows, we want to make the retrieval step identity-aware. Before we even touch the vector database, the user must authenticate with Auth0, otherwise the app won’t even load for them. Once they are authenticated, the app asks Auth0 FGA: "Which documents is this specific user allowed to see?" We then use those allowed IDs as a filter for our search.

Diagram of an identity-aware RAG pipeline using Ruby, Auth0 authentication, and FGA authorization.

Diagram explaining the Work Companion app flow using Auth0 and Auth0 FGA for authentication and authorization.

What is RAG in this context?

If you're new to the term, Retrieval-Augmented Generation( RAG) is basically a way to give an LLM "specialized knowledge" without retraining it by adding more context. In our Work Companion:

  • Retrieval: When a user asks a question, the app searches on a vector database to find the most relevant "chunks" of text from our internal docs.
  • Augmentation: We take those text chunks and "augment" the user's original question by adding them as extra context.
  • Generation: We send that combined package to the LLM, which generates a precise answer based only on the provided context.

The Anatomy of the Work Companion Chat App

For this code sample you’ll need the following tools:

Pre-requisites

  • Ruby 4.0.1
  • PostgreSQL 17 with pgvector extension
  • An OpenAI API key

The app is built to keep data ingestion, retrieval, and authorization separate and clean. Here’s the breakdown:

  • The Database: We have a simple schema with users, documents, and document_chunks. Users are stored in the database to showcase how to handle this in a Ruby on Rails application. The document_chunks table is the star of the show, it stores the vector[1536] embeddings and uses an HNSW index to make sure our searches are fast.
  • The Services: We use a RagQueryService to coordinate the RAG flow and an FgaService to talk to the Auth0 FGA API.
  • The Gems:
    • neighbor: Our go-to for handling pgvector inside Rails’ ActiveRecord.
    • ruby-openai: To connect with OpenAI and turn user questions into embeddings.
    • openfga: The Ruby SDK for OpenFGA that connects to Auth0 FGA via client credentials exchange.

Now that you know how the application works, let’s download and install it.

Running the code sample

The first thing you need to do is clone and install dependencies

git clone https://github.com/auth0-blog/ruby-rag-fga
cd ruby-rag-fga
bundle install

Then you’ll need to set up PostgreSQL with pgvector and create the database and seed it with data. I recommend you follow the steps from the repo’s README. You can run the server with rails s and navigate to http://localhost:3000 to see the chat view.

Chat view for the Work Companion app.

Chat view for the Work Companion app.

At this point the app doesn’t know who the user is so let’s proceed to add authentication with Auth0.

Add Authentication with Auth0

Before we can ask Auth0 FGA what a user is allowed to see, we first need to know exactly who the user is. In our Rails application, we use Auth0 to handle identity management.

Read the Rails Authentication by Example Guide to get an in depth explanation of all the details for implementing Auth0 in a Ruby on Rails application

The first step is to Create an Auth0 Application: In your Auth0 dashboard, create a new "Regular Web Application" and take note of the Domain and Client ID and Client Secret.

Install and set up the Auth0 SDK

Now you need to install the Auth0 SDK that lives in the omniauth-auth0 gem. Add the following to your Gemfile:

source "https://rubygems.org"

# ....

# New code 👇
# Auth0 Authentication
gem "omniauth-auth0", "~> 3.1"
gem "omniauth-rails_csrf_protection", "~> 1.0"

#...

Make sure to install the new gems by running bundle install in your terminal. Next, let’s add the initializer file for Auth0. Create a new file config/initializer/auth0.rb with the following content:

Rails.application.config.middleware.use OmniAuth::Builder do
  provider(
    :auth0,
    ENV.fetch("AUTH0_CLIENT_ID"),
    ENV.fetch("AUTH0_CLIENT_SECRET"),
    ENV.fetch("AUTH0_DOMAIN"),
    callback_path: "/auth/auth0/callback",
    authorize_params: {
      scope: "openid profile email"
    }
  )
end

OmniAuth.config.on_failure = Proc.new do |env|
  OmniAuth::FailureEndpoint.new(env).redirect_to_failure
end

Next you’ll need to handle the authentication flow with a rails controller. When a user successfully logs in through Auth0, they are redirected back to our application's callback action. Let’s create a new controller app/controllers/auth0_controller.rb which will handle the callback with the following content:

class Auth0Controller < ActionController::Base
  protect_from_forgery with: :exception
  skip_forgery_protection only: [:callback]

  def callback
    auth_info = request.env["omniauth.auth"]
    user = User.create_or_update_from_auth0(auth_info)
    session[:user_id] = user.id
    redirect_to "/chat"
  end

  def failure
    Rails.logger.error "Auth0 login failed: #{params[:message]}"
    render plain: "Authentication failed. Please try again.", status: :unauthorized
  end

  def logout
    session.delete(:user_id)
    reset_session

    logout_url = URI::HTTPS.build(
      host: ENV.fetch("AUTH0_DOMAIN"),
      path: "/v2/logout",
      query: {
        returnTo: root_url,
        client_id: ENV.fetch("AUTH0_CLIENT_ID")
      }.to_query
    ).to_s

    redirect_to logout_url, allow_other_host: true
  end
end

Note the method create_or_update_from_auth0 is defined in the User model and it’s not a given method from the gem.

Securing our controllers

To make sure only logged-in users can access the Work Companion, let’s implement a Rails concern Secured. This is a simple module we include in any controller that needs protection.

module Secured
  extend ActiveSupport::Concern

  included do
    before_action :logged_in_using_omniauth?
    helper_method :current_user
  end

  def current_user
    @current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
  end

  private

  def logged_in_using_omniauth?
    return if current_user

    redirect_post('/auth/auth0', params: { prompt: 'login' },
                                 options: { method: :post, authenticity_token: 'auto' })
  end
end

The logic is as follows: a before_action checks for the existence of a user ID in the session. If the session is empty, we need to send the user to the login page. Here, we are using the repost gem to allow us to perform a clean redirect-to-POST, ensuring the user hits the Auth0 login flow securely.

Next, add the new Auth0 routes in your config/routes.rb file:

# .... 
scope :auth do
  get 'failure' => 'auth0#failure'
  get 'auth0/callback' => 'auth0#callback'
  get 'logout' => 'auth0#logout', as: :logout
end

Now we need to use the Secured concern where we need it: in the chat view and the chat API. Go to app/controllers/chat_controller.rb and add the following:

class ChatController < ActionController::Base
  # New code 👇
  include Secured 

  def index
    # current_user is available here
  end
end

And the app/controllers/api/chat_controller.rb:

class Api::ChatController < ActionController::Base
  # New code 👇
  include Secured

  # Override Secured's redirect behavior for API — return 401 JSON instead
  def logged_in_using_omniauth?
    return if current_user

    render json: { error: "Unauthorized" }, status: :unauthorized
  end
#...

We are keeping the HTML controller separate from our API controller to maintain their single responsibilities and to make it clearer for this code sample. I’m also skipping the changes in the view. You should adjust it to your own implementation.

If you now navigate to http://localhost:3000, you’ll be redirected to login with Auth0, and once you do, you should see your name and email in the chat view as the app knows who you are.

Ruby on Rails application showing successful user login via Auth0.

The chat view from the Work Companion with the user authenticated.

Now let’s proceed to add authorization with Auth0 FGA.

Defining permissions with Auth0 FGA

With user identity established through Auth0, the focus shifts from who the user is to what they are permitted to see. This is where Auth0 FGA (Fine-Grained Authorization) becomes essential for out Ruby RAG architecture. It provides a centralized, externalized authorization layer that prevents your Rails controllers from becoming cluttered with complex, hard-to-maintain permission logic.

Why use Auth0 FGA?

Traditional Role-Based Access Control (RBAC) is often the first tool developers reach for, but it quickly breaks down in document-heavy RAG scenarios. While you could technically create roles like rails_guide_reader or postgres_guide_reader, this "role explosion" becomes unmanageable as your document library grows from dozens to thousands.

Auth0 FGA leverages Relationship-Based Access Control (ReBAC), which is a far more natural fit for RAG applications. Instead of checking broad roles, you define direct relationships between users and specific objects, for example, "Carla is a viewer of document:rails_guide." This approach is significantly more granular, scalable, and intuitive for managing document-level security.

To learn more about Auth0 FGA and how to use it in your applications, read the Auth0 FGA documentation.

The authorization model

You can write your authorization model using the rake task provided in the repo or by going to your Auth0 FGA Dashboard. The authorization model looks like this:

model
  schema 1.1

type user

type document
  relations
    define owner: [user]
    define viewer: [user] or owner

This model represents the following:

  • Users can be owners or viewers of documents
  • Anyone who is an owner is automatically also a viewer
  • You can also grant viewer access directly without ownership

Creating relationship tuples

Relationship tuples are the facts of your authorization model. They define what user has a relationship with what document. Because we already created five documents earlier and we know their IDs in our database, we’ll use that to identify them on Auth0 FGA, similarly we’ll use the user’s Auth0 ID to identify them. Let’s create the following tuples:

user:google-oauth2|100...5110    viewer    document    3
user:google-oauth2|100...5110    viewer    document    2

Now that we have a model and tuples, we’ll need to create an authorized client on Auth0 FGA so our application can securely talk to the API.

Go to the Auth0 FGA Dashboard and then to Store Settings. Scroll down to Authorized Clients and click on Create Client. Select the client permissions we need, which in our case are all except for Search Logs, and click on Create. Take note of all the ENV variables and add them to your .env file.

Implementing FGA in the Ruby RAG Pipeline

We are almost done with our application. We’ve added authentication but now we need to add authorization. The end to end flow of our application should end up as follows:

  1. User visits /chat → redirected to Auth0 Universal Login
  2. After authentication, user is created/updated in the database
  3. User sends a message in the chat interface
  4. FGA list_objects returns the document IDs the user can view
  5. Query is embedded via OpenAI
  6. pgvector nearest-neighbor search runs filtering authorized documents only
  7. Top-k chunks are assembled into context and sent to the LLM
  8. Response is returned with sources and processing time

We already took care of steps 1-3 but now we need to filter the documents the user has access to before we create the embeddings and send the question to OpenAI.

Install and set up the OpenFGA Ruby SDK

For this example we’ll use a community built ruby SDK: openfga. Add it to your Gemfile and then run bundle install to install it locally:

source "https://rubygems.org"

# ....

# New code 👇
gem "openfga", "~> 0.1.5"

#...

Retrieve the user’s ID

After the user signs in, we store it and use it as the current user in the session. Let’s pass the user’s ID to the RAG Pipeline by adding the following to the Api::ChatController:

class Api::ChatController < ActionController::Base
  include Secured

  #  ...

  def create
    message = params[:message]
    
    if message.blank?
      return render json: { error: "Message is required" }, status: :bad_request
    end

    begin
      # New code 👇 (pass user: current_user)

      response = RagQueryService.call(message, user: current_user)
      
      render json: {
        message: message,
        response: response[:answer],
        sources: response[:sources],
        processing_time: response[:processing_time]
      }
    rescue StandardError => e
      Rails.logger.error "Chat error: #{e.message}"
      render json: { 
        error: "Sorry, I encountered an error processing your request." 
      }, status: :internal_server_error
    end
  end
end

Now that we have the user’s information we can build the authorization service.

Build the authorization service

Next, let’s create an FgaService. Create a new file app/services/fga_service.rb and add the following code:

require "openfga"

class FgaService
  class << self
    def client
      @client ||= OpenFga::SdkClient.new(
        api_url: ENV.fetch("FGA_API_URL"),
        store_id: ENV.fetch("FGA_STORE_ID"),
        authorization_model_id: ENV.fetch("FGA_MODEL_ID", nil),
        credentials: {
          method: :client_credentials,
          api_token_issuer: ENV.fetch("FGA_API_TOKEN_ISSUER"),
          api_audience: ENV.fetch("FGA_API_AUDIENCE"),
          client_id: ENV.fetch("FGA_CLIENT_ID"),
          client_secret: ENV.fetch("FGA_CLIENT_SECRET")
        }
      )
    end
  end
end

We are creating a new instance of the SdkClient and connect it to our Auth0 FGA instance using Client Credentials Exchange.

Next, let’s add a method to see the list of accessible documents by the current user. In your FgaService add the following:

def list_accessible_documents(user)
  response = client.list_objects(
    user: "user:#{user.auth0_sub}",
    relation: "viewer",
    type: "document"
  )

  response.objects.map { |obj| obj.delete_prefix("document:").to_i }
rescue => e
  Rails.logger.error "FGA ListObjects error: #{e.message}"
  raise e
end

In the listaccessibledocuments method, we are using the list_objects method of the SDK to list all the objects that the user is related to via the viewer relation. This is our main document filtering mechanism for our RAG pipeline.

Filter the documents

Time has come to finally filter documents. Let’s go to the RagQueryService class and add the following code in the call method:

 def call
    start_time = Time.current
    
    # New code 👇
    # Step 1: Get authorized document IDs from FGA
    authorized_doc_ids = FgaService.list_accessible_documents(user)
    
    if authorized_doc_ids.empty?
      return {
        answer: "You don't have access to any documents. Please contact your administrator.",
        sources: [],
        context_chunks_count: 0,
        processing_time: (Time.current - start_time).round(2)
      }
    end
    
    # Step 2: Generate embedding for the query
    query_embedding = OpenaiService.generate_embedding(query)
    
    # New code👇 (user authorized_doc_ids) 
    # Step 3: Find similar document chunks (filtered by authorized documents)
    similar_chunks = find_similar_chunks(query_embedding, authorized_doc_ids)
    
    # Step 4: Build context from chunks
    context = build_context(similar_chunks)
    
    # Step 5: Generate response using OpenAI
    answer = OpenaiService.generate_completion(query, context)
    
    processing_time = Time.current - start_time
    
    {
      answer: answer,
      sources: format_sources(similar_chunks),
      context_chunks_count: similar_chunks.length,
      processing_time: processing_time.round(2)
    }
  end

Test the work companion

With the tuples we created in Auth0 FGA, the user has access to two documents only: a Auth0 Guide and a Postgres Guide, so let’s try to ask questions NOT about those documents to see if the filtering is working:

User asking questions about topics it does not have access to

If we now try to ask about a topic we know we have access, we’ll get the following response:

User asking questions about topics it does have access to

Securing Ruby on Rails RAG Apps

Building a RAG application is a major step toward making AI more useful for your organization, but it shouldn't come at the cost of data security. As we’ve seen, traditional RBAC often hits a wall when you need to manage permissions at the individual document level across complex hierarchies.

By integrating Auth0 FGA with your Ruby on Rails application, you’ve moved from a coarse approach to a precise, relationship-based authorization model. You’ve successfully:

  • Identified the User: Leveraged Auth0 and OmniAuth to establish a secure identity.
  • Centralized Permissions: Used a ReBAC model with Auth0 FGA to define exactly who can see what.
  • Secured the Pipeline: Implemented an "identity-aware" retrieval step that filters sensitive context before it ever reaches the LLM.

This architecture doesn’t just stop at document ownership. Because Auth0 FGA is built on top of OpenFGA, it allows you to handle millions of objects and complex sharing rules, like department-wide access or temporary viewer permissions, without ever having to rewrite your core search logic.

If you’re ready to start securing your own RAG pipelines, check out the Authorization for RAG Quickstart and if you’re using Ruby check out the OpenFGA SDK