Rails Authentication and Authorization Internals

Devise sitting on top of Warden, custom strategies that survive contact with production, and why I prefer ActionPolicy over Pundit at Aurora scale.

Late evening at the creator economy platform I worked at. Past midnight UTC, around 6 p.m. Pacific. We were shipping a schema change to users, a table with hundreds of millions of rows. The migration looked fine in review. add_column ... null: false, default: false wrapped in strong_migrations’s safer helper. I’d ack’d it that morning. Forty minutes after the deploy, login error rate was at 100%.

The migration had taken an ACCESS EXCLUSIVE lock on users to apply the backfill. On Aurora at that row count, that meant 87 seconds of blocked writes. Login, sign-up, password reset, every webhook tied to user creation. Half the senior engineers in California woke up to PagerDuty.

That night taught me something I now bring to every authentication review. Devise feels boring. Warden feels boring. They are not. They sit on the hottest table in the application, behind every request, and they pull from the same Aurora reader pool that everything else does. The day you stop treating them like infrastructure, you find out they were.

Devise sits on Warden

The first thing worth unpacking is what Devise actually does. Most Rails engineers think of it as “the gem that gives you current_user”. It isn’t. Warden is. Devise is a Rails-flavored configuration layer that mounts Warden as a Rack middleware and gives you a set of pre-built strategies, controllers, and views.

If you read the Rails middleware stack on any Devise app, you’ll find Warden::Manager near the top. Every request passes through it. It looks for a session, and if there’s one, it deserializes the user from the session payload by calling whatever your serialize_from_session block returns. On a stock Devise setup that block is User.find(id).

That User.find(id) is the thing I want you to remember. Every authenticated request, every controller action behind authenticate_user!, hits Aurora. One query. Per request. For the duration of the session.

# config/initializers/devise.rb (excerpt)
Devise.setup do |config|
  config.warden do |manager|
    manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
    manager.failure_app = CustomFailureApp
  end

  config.secret_key = Rails.application.credentials.devise_secret_key
end

The warden block is where Devise hands you Warden’s actual config. Unshift a strategy, swap the failure app, scope strategies per role. Anything Warden can do, Devise can do, because Devise is Warden underneath.

Custom strategies are not exotic

The moment you need anything beyond email-and-password, you’re writing a Warden strategy. It’s a class with two methods. valid? says whether this strategy should even try. authenticate! does the work and calls success!, fail!, or pass.

Here’s one I’ve shipped variants of more than once. API tokens with a token_version column so revocation is instant without invalidating every session.

# lib/warden/strategies/api_token.rb
require "warden"

module Warden
  module Strategies
    class ApiToken < Warden::Strategies::Base
      def valid?
        request.headers["Authorization"].to_s.start_with?("Bearer ")
      end

      def authenticate!
        token = request.headers["Authorization"].to_s.delete_prefix("Bearer ").strip
        return fail!("missing token") if token.blank?

        record = ApiToken.find_by(token_digest: Digest::SHA256.hexdigest(token))
        return fail!("invalid token") unless record
        return fail!("revoked token") if record.revoked_at.present?
        return fail!("user disabled") unless record.user.active?
        return fail!("version mismatch") if record.token_version != record.user.token_version

        success!(record.user)
      rescue => e
        Rails.error.report(e, context: { strategy: "api_token" })
        fail!("auth error")
      end
    end
  end
end

Warden::Strategies.add(:api_token, Warden::Strategies::ApiToken)

A few things are intentional. Tokens are stored as SHA256 digests, never plaintext. token_version lets you bump a single integer column to revoke every issued token for a user atomically. Errors are reported but never leaked back as failure reasons. And valid? is cheap. It only inspects a header. Warden runs every registered strategy’s valid? on every request until one matches, so making it slow is the same as making your whole app slow.

Wire it up in Devise:

# config/initializers/devise.rb
Devise.setup do |config|
  config.warden do |manager|
    manager.strategies.add(:api_token, Warden::Strategies::ApiToken)
    manager.default_strategies(scope: :user).unshift(:api_token)
  end
end

Unshift, not push. Order matters. Warden short-circuits on the first strategy that authenticates. Putting API token before the database authenticatable means a request with a Bearer header never touches the session store.

Pundit and ActionPolicy

This is the part where I have a strong opinion.

Pundit is a great learning tool. Policy class per resource, a method per action, authorize @post, done. It teaches the mental model cleanly. I shipped Pundit on the Rails monolith at the creator platform I was at for a long time. Across many repositories and many squads, it held up.

But Pundit has a hole that opens up at scale. The hole is N+1 in policy scopes.

# app/policies/community_post_policy.rb
class CommunityPostPolicy < ApplicationPolicy
  def show?
    return true if record.community.public?
    record.community.member?(user)
  end

  class Scope < Scope
    def resolve
      scope.where(community_id: user.community_ids)
    end
  end
end

Innocent. record.community.member?(user) runs a query per record. On a Community feed rendering a page of posts, that’s a query per post. Pundit doesn’t help you batch it. You can preload community, but the membership check is still a per-record call unless you write the batching yourself.

ActionPolicy hands you the batching primitive. Pre-checks, namespaced rules, and most importantly, a built-in cache scoped to the request.

# app/policies/community_post_policy.rb
class CommunityPostPolicy < ApplicationPolicy
  cache :show?, with: :user_communities_cache

  pre_check :allow_admin

  def show?
    return true if record.community.public?
    user_communities_cache.include?(record.community_id)
  end

  relation_scope do |scope|
    next scope if user.admin?
    scope.where(community_id: user_communities_cache)
  end

  private

  def allow_admin
    allow! if user.admin?
  end

  def user_communities_cache
    cache(:user_communities) do
      user.community_memberships.where(active: true).pluck(:community_id).to_set
    end
  end
end

One query per request for community memberships, cached by ActionPolicy’s per-request cache, used both by the scope and by per-record show?. Authorization checks against a Set lookup, not a database call. On a feed page that previously did N policy queries plus the scope query, the policy footprint drops to a single query.

I prefer ActionPolicy over Pundit on any app that hits a real Aurora cluster. Pundit’s simplicity is a feature for small apps. At scale, that simplicity becomes a footgun because it doesn’t make caching obvious, so people don’t do it, so policy checks become a quiet contributor to read load.

Takeaways

  • Devise is a config layer. Warden is the actual middleware. Read your middleware stack.
  • Custom Warden strategies are short, cheap, and the right place to do anything beyond email-and-password.
  • Keep valid? fast. It runs on every request.
  • Tokens go in the database as SHA256 digests. Use a token_version column for instant revocation.
  • Prefer ActionPolicy over Pundit at scale. The request-scoped cache is the whole reason.
  • Authorization queries ride the same Aurora reader pool as everything else. Cache them like they matter, because they do.

Thanks for reading. If you’ve got thoughts, send them my way.

© 2026 Akin Gundogdu. All Rights Reserved.