Authenticated routes as an extra layer of security

Davy Cardinaal · · Updated

Rails comes with excellent built-in authentication and authorization tools, but one common security pitfall is developer error. For example forgetting to add a before_action :require_admin. This can lead to sensitive data being exposed unintentionally.

In Rails Startup, I introduced authenticated routes as a second line of defense. These routing-level constraints ensure that unauthorized users never even reach the controller.

In this post, I’ll walk you through how I implemented this, how I avoided unnecessary database calls, and how I extended the idea to support role-based access as well.

Why use route-level authentication?

Most Rails developers use a controller filter to ensure users are authenticated:

before_action :require_authentication

This works, but it depends on the developer remembering to include the filter in the controller. If it's forgotten, sensitive data can leak and we don't want that to happen.

By adding an extra check to the routing layer, we reduce the risk of developer mistakes. Unauthenticated users are denied access earlier, and unauthorized routes never even reach the controller.

Initial implementation: session-based route constraint

My first approach used a route helper and a constraint that checked if the session exists in the database:

# config/initializers/authenticated_routes.rb
module AuthenticatedRoutes
def authenticated(&block)
constraint = lambda do |request|
Session.exists?(id: request.cookie_jar.signed[:session_id])
end

constraints(constraint) do
yield
end
end
end

ActionDispatch::Routing::Mapper.include AuthenticatedRoutes

With this helper, I was able to protect routes by wrapping them in an authenticated block:

authenticated do
namespace :admin do
resources :users
end
end

This worked well at first, but I quickly discovered a problem: Rails was already trying to fetch the session in the authentication concern that was introduced in Rails 8. This meant every request triggered two database calls to the same Session. One in the router constraint, and one in the controller.

Using middleware to load the session

To avoid this redundant query, I introduced an authentication middleware that loads the session once at the start of each request:

# app/middleware/authentication_middleware.rb
class AuthenticationMiddleware
def initialize(app)
@app = app
end

def call(env)
request = ActionDispatch::Request.new(env)
session_id = request.cookie_jar.signed[:session_id]

Current.session = Session.find_by(id: session_id)

@app.call(env)
end
end

Rails.application.config.middleware.use AuthenticationMiddleware

This middleware ensures that we always have Current.session available in our controllers when the session_id is available via the cookie.

Updating the authentication concern

Because the Rails authentication concern still fetches the session when you require authentication, I had to update the implementation. Originally, this was the code that I had to change:

def authenticated?
resume_session
end

def require_authentication
resume_session || request_authentication
end

def resume_session
Current.session ||= find_session_by_cookie
end

def find_session_by_cookie
Session.find_by(id: cookies.signed[:session_id]) if cookies.signed[:session_id]
end

After introducing the middleware, I could simplify it to:

def authenticated?
Current.session
end

def require_authentication
Current.session || request_authentication
end

This eliminates redundant logic as the session is now reliably loaded at the start of each request by the AuthenticationMiddleware. 

Improving the routing constraint to use the current session

With the middleware in place, we can now rewrite our routing constraint to use the session from Current.session:

module AuthenticatedRoutes
def authenticated(&block)
constraints -> { Current.session.present? } do
yield
end
end
end

Usage in our routes.rb file stays the same:

authenticated do
resources :projects
end

This ensures the project resources are only accessible if the user is logged in.

Adding role-based access to routes

With the current implementation, a signed in user can access both their own projects and the admin namespace. But we don't want regular users to be able to access the admin interface. To solve this, let's extend the authenticated helper to support an optional role check:

module AuthenticatedRoutes
def authenticated(role = nil, &block)
constraint = lambda do
Current.session.present? &&
(role.nil? || Current.user.public_send("#{role}?"))
end

constraints(constraint) do
yield
end
end
end

We can now ensure signed in users for the projects resources but require administrator users for the admin namespace:

authenticated do
resources :projects
end

authenticated :administrator do
namespace :admin do
resources :users
end
end

Conclusion

Moving access control to the routing layer doesn’t replace controller filters, but it does strengthen them. By explicitly scoping routes to authenticated users or roles, you:

  • Reduce the risk of accidental exposure
  • Prevent unauthenticated access before the controller runs
  • Make your routes easier to reason about


This pattern is now the default in Rails Startup. It adds minimal complexity while significantly improving the application's security. If you're building a SaaS product, authenticated routes can be a powerful complement to your existing controller filters.