LaunchKit

Gating a Rails admin panel with HTTP Basic Auth

September 17, 2026

A back office needs a lock on it before it needs a second screen. The cheapest lock in Rails is nine lines and no migration, and it is worth understanding exactly what those nine lines buy before deciding they are enough.

The admin hub covers what the console contains. This page is about the gate in front of it.

Why the gate is not the application's login

Rails admin authentication through the app's own session is the reflex answer, and it couples two things that fail at different times. The console exists to fix the application. Putting it behind the application's session, email confirmation and onboarding gate means that a bug in any of them takes the repair tool down with the thing being repaired.

There is a second cost, quieter. Once User has an admin boolean, every query that loads a user is one forgotten scope away from privilege escalation, and every new gate in the app has to remember the exception. Keeping the console on its own credential pair means User never learns what an admin is.

class BaseController < ApplicationController
  layout "admin"
  allow_unauthenticated_access
  allow_unonboarded_access
  before_action :authenticate_admin
end

Both of the application's gates are switched off explicitly, and one gate is installed in their place. Nothing about the console's access depends on the users table.

The nine lines

def authenticate_admin
  if AppConfig.admin_username.blank? || AppConfig.admin_password.blank?
    return if Rails.env.local?
    return head :not_found
  end

  authenticate_or_request_with_http_basic("Admin") do |username, password|
    secure = ->(given, expected) { ActiveSupport::SecurityUtils.secure_compare(given.to_s, expected.to_s) }
    secure.call(username, AppConfig.admin_username) & secure.call(password, AppConfig.admin_password)
  end
end

authenticate_or_request_with_http_basic does the protocol work. With no credentials on the request it answers 401 with a WWW-Authenticate: Basic realm="Admin" header, which is what makes the browser show its own password box. With credentials present it yields them to the block, and a truthy return lets the action run.

The realm string is the only thing the visitor sees of your naming. It appears in the browser dialog, so "Admin" is a better answer than the application name.

secure_compare, and the ampersand that is not a typo

== on two strings returns as soon as it finds a difference. Comparing a secret that way means the time taken depends on how many leading characters the guess got right, and that difference is measurable across enough requests. ActiveSupport::SecurityUtils.secure_compare hashes both sides first and compares the digests, so the work is the same whatever the inputs.

Its sibling fixed_length_secure_compare skips the hashing and raises ArgumentError when the two strings differ in length, which is fine for comparing two digests and wrong for comparing a submitted password against a stored one.

Then the join:

secure.call(username, AppConfig.admin_username) & secure.call(password, AppConfig.admin_password)

&& would stop at the first false. A wrong username would return without ever comparing the password, and that request comes back measurably sooner than one where only the password was wrong. & is the non-short-circuiting operator: both comparisons run, both take the same time, and the response says nothing about which half the attacker got right.

An unconfigured console answers 404

The bootstrap problem is real: the setup wizard that writes the admin credentials is itself a screen inside the console those credentials open. The first branch resolves it in two different ways depending on where the code is running.

Locally, a blank credential pair returns early and the console opens. A fresh clone is usable immediately and the founder reaches the wizard without editing an encrypted file by hand first.

In production, a blank pair answers head :not_found. This is the part worth copying. The alternative, sending the 401 challenge anyway, tells a stranger that there is a console at that path and that nobody has finished setting it up, which is the most interesting thing you could possibly tell them. A 404 is what every unused path in the application already returns.

What Basic Auth does not give you

The credentials travel base64 encoded in a header on every single request. Base64 is encoding, not encryption, so anyone who can read the traffic can read the password. That makes TLS a hard requirement rather than a recommendation, and it makes this a poor choice for anything a customer logs into.

There is no sign-out. The browser holds the credentials and resends them until it is closed, and there is no server-side session to destroy. On a shared machine that is a real exposure, and it is usually the thing that pushes a team off this design first.

There is no audit trail either. One credential pair means the logs record that the admin deleted the comment, never which person that was.

When this stops being the right answer

The moment a second person needs their own access. That is the honest threshold, and it arrives before you expect it: the first contractor, the first support hire, the first time someone asks who approved a refund.

What this page does not cover is that migration, or the shape of the admin screens themselves. It covers one decision: what the console authenticates against, and what the nine lines in front of it are actually doing.

More on A Rails admin panel

← All A Rails admin panel articles