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.
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.
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.
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.
valid? fast. It runs on every request.token_version column for instant revocation.Thanks for reading. If you’ve got thoughts, send them my way.