Adminaway is a small Rails engine that reduces automated login scanning and credential-stuffing noise. It puts an existing login page behind a seed-authenticated URL, Rails rate limiting, and a short-lived signed cookie.
Adminaway is defense in depth, not authentication. Keep the application's real authentication, authorization, MFA, TLS, monitoring, and edge controls.
- Ruby
>= 4.0and< 5.0 - Rails
>= 8.1.2and< 9.0 - Rack
>= 3.2and< 4.0
CI covers Ruby 4.0 with Rails 8.1, plus an experimental Rails edge lane.
Add the gem to the Rails application's Gemfile:
gem "adminaway"Then install it and run the generator:
bundle install
bundle exec rails generate adminaway:install --login-path /session/new/session/new is the sign-in page generated by Rails 8 authentication. Pass
the application's real login page instead. The generator creates a secret-free
initializer and adds this declaration to config/routes.rb:
adminaway login_path: "/session/new"Use --skip-route if the route is already configured manually.
Generate a deployment seed and store it as ADMINAWAY_SEED in the deployment
environment or secret manager:
bundle exec adminaway seedThe command emits 32 cryptographically random bytes as 64 hexadecimal characters. Adminaway rejects missing seeds and values shorter than 32 bytes. Never commit the seed.
Generate a valid gate URL:
bundle exec adminaway generateOpen the printed URL. A successful request sets the gate ticket and redirects to the configured login page. These are the observable outcomes:
- valid gate URL:
302tologin_pathwith a short-lived signed cookie; - invalid gate URL:
404; - direct login-page request without a valid ticket:
404; - excessive gate requests: the same
404response as an invalid gate URL.
The login page still performs the real password, passkey, MFA, session, and authorization work.
GET /admin/<s1-token>
|
v
Rails route constraint + Adminaway::GatesController
|
+-- rate limit exceeded --------------------> 404
|
+-- wrong gate / invalid token ------------> 404
|
+-- valid token
|
+-- issue scoped, signed ticket cookie
+-- emit accepted.adminaway
+-- 302 /session/new
|
v
Adminaway::LoginPathGuard
|
+-- missing/invalid ticket -> 404
+-- valid ticket ----------> login controller
Stateless s1 tokens use a purpose-specific HMAC key derived from the
deployment seed and are bound to their gate name. Ticket signing uses a
different derived key. Each seed and gate has exactly ten deterministic tokens,
and verification compares all ten expected values in constant time. Rejection
and redirect responses receive no-store, no-cache, no-referrer, and no-index
headers. Throttling still increments its counter and emits
throttled.adminaway, but the response does not reveal that the route was
rate-limited.
The cookie is HTTP-only, SameSite=Lax, scoped to the login path, and expires
after ticket_ttl. With the default secure_cookie = :auto, it is marked
Secure when Rails sees an HTTPS request. Fractional ticket lifetimes are
rounded up to whole seconds.
The gate is path concealment, not authentication. Its purpose is to make the login route impractical to discover through online guessing while the built-in limiter and edge controls reduce how quickly guesses can be submitted. The login page must still enforce the application's normal authentication, authorization, MFA, account-lockout, and session policies.
An s1 token is 14 characters: the s1 version prefix followed by a
12-character Base64url encoding of a 72-bit truncated HMAC tag. Since any of ten
gate-bound variants is accepted, the effective online search space is
approximately 2^72 / 10, or 2^68.7. The ten variants are not represented by
an index in the URL. This estimate assumes one accepted seed; every temporary
previous seed adds another set of ten accepted URLs, so keep rotation grace
periods short.
Rate limiting does not add entropy to the token. It only reduces online request
throughput under the configured key and cache store. The default per-IP key is
not sufficient against a distributed attacker. In multi-process production,
use a shared rate_limit_store, and complement it with a CDN, WAF, load
balancer, or other edge/global limiter.
All ten URLs remain valid until their seed is removed. They cannot be revoked individually: rotate the seed to replace the complete set. Treat URLs as secrets and keep them out of logs, analytics, support tickets, chat history, and browser synchronization where practical. If a workflow requires individual expiry or consumption, use an application-owned, database-backed one-time link rather than a reusable Adminaway gate URL.
Always confirm the exact page with bin/rails routes; login_path is a path,
not a route-helper name or absolute URL.
Rails 8's authentication generator creates the sign-in page at
/session/new by default:
adminaway login_path: "/session/new"The common user scope uses /users/sign_in:
devise_for :users
adminaway login_path: "/users/sign_in"For an admin-specific Devise scope, use that scope's actual new_session
path. Keep Devise's lockable, timeout, MFA, and other authentication controls.
ActiveAdmin commonly uses a Devise-backed page such as /admin/login:
adminaway login_path: "/admin/login", gate_prefix: "backoffice"RailsAdmin delegates authentication to the host application. Gate the host application's login page, not the mounted RailsAdmin dashboard path:
adminaway login_path: "/admins/sign_in", gate_prefix: "backoffice"
mount RailsAdmin::Engine => "/admin", as: "rails_admin"Administrate does not choose the application's authentication system. Point Adminaway at the login page used by the host application's admin controller:
adminaway login_path: "/admin/sign_in"
namespace :admin do
# existing authenticated admin routes
endAdminaway guards the configured login-page GET path. It does not replace or rewrite the authentication system's session-create, password-reset, OAuth, or callback endpoints.
The default declaration is named default. Additional names isolate tokens,
tickets, rate-limit counters, routes, and login targets:
adminaway login_path: "/session/new"
adminaway name: :staff,
login_path: "/staff/sign_in",
gate_prefix: "staff-door"
adminaway name: :support,
login_path: "/support/sign_in",
gate_prefix: "support-door",
host: "admin.example.com"Generate a URL with the matching namespace and prefix:
bundle exec adminaway generate --gate staff --prefix staff-doorNamed gates also define standard Rails helpers such as
adminaway_staff_gate_path(token: token). Choose gate names that do not collide
with route helpers already defined by the host application.
Use either host: for an exact host or subdomain: for Rails' complete
subdomain value, never both. Subdomain matching respects
config.action_dispatch.tld_length. An unscoped gate cannot overlap a scoped
gate at the same gate or login path, and gate prefixes cannot shadow one
another. Gate names must start with a letter and contain only letters, numbers,
and underscores.
Use Adminaway.configure in config/initializers/adminaway.rb:
Adminaway.configure do |config|
config.seed_provider = -> { Rails.application.credentials.adminaway_seed }
config.ticket_ttl = 15.minutes
config.secure_cookie = :auto
endEnvironment configuration can instead set the same values early with
config.adminaway.<setting> = value.
| Setting | Default | Contract |
|---|---|---|
seed_provider |
-> { ENV["ADMINAWAY_SEED"] } |
String or callable returning at least 32 bytes |
previous_seed_providers |
[] |
Strings/callables accepted temporarily during rotation |
cookie_name |
"_adminaway_gate" |
Rack-compatible ASCII cookie name |
ticket_ttl |
15.minutes |
Finite positive duration |
secure_cookie |
:auto |
:auto, true, or false |
rate_limit_enabled |
true |
Disable only when another limiter owns this gate |
rate_limit_to |
30 |
Positive integer request count |
rate_limit_within |
1.minute |
Finite positive window |
rate_limit_by |
:remote_ip |
Request method symbol or callable |
rate_limit_store |
Rails controller cache | Rails-compatible cache store with atomic increment |
To key rate limits by a trusted client identifier:
Adminaway.configure do |config|
config.rate_limit_by = ->(request) { request.get_header("HTTP_X_CLIENT_ID") }
endIf Rack::Attack or an edge gateway exclusively owns throttling for this route:
Adminaway.configure do |config|
config.rate_limit_enabled = false
endDo not disable both layers.
bundle exec adminaway seed
bundle exec adminaway generate [--prefix PREFIX] [--gate NAME]
bundle exec adminaway backup [--prefix PREFIX] [--gate NAME]
bundle exec adminaway verify CANDIDATE [--prefix PREFIX] [--gate NAME]
bundle exec adminaway doctor
bundle exec adminaway versiongenerate selects and prints one of the gate's ten valid URLs. backup prints
the complete deterministic set of ten. Repeating backup with the same seed
and gate prints the same paths. Every token is exactly 14 characters.
From a Rails application root, token commands load config/environment.rb and
use the configured seed provider. A configured gate's prefix is inferred from
--gate; --prefix remains available as an explicit override. Outside a Rails
application, the commands use ADMINAWAY_SEED and the admin prefix by default.
Verification example:
GATE_PATH="$(bundle exec adminaway generate)"
bundle exec adminaway verify "$GATE_PATH"Equivalent Rake tasks:
bundle exec rake adminaway:seed:generate
bundle exec rake adminaway:token:generate
bundle exec rake adminaway:token:backup
CANDIDATE="$GATE_PATH" bundle exec rake adminaway:token:verify
bundle exec rake adminaway:doctorThe token tasks accept PREFIX and GATE; verification also uses CANDIDATE.
Tokens use the s1 format, and signed tickets carry version 1 payloads. Both
formats are bound to their gate name.
Rotate without immediately breaking existing URLs or tickets:
Adminaway.configure do |config|
config.seed_provider = -> { ENV["ADMINAWAY_SEED"] }
config.previous_seed_providers = [
-> { ENV["ADMINAWAY_PREVIOUS_SEED"] }
]
endDeploy the new primary seed and retain the old seed only for the intended grace period. New tokens and tickets use the primary seed; verification tries the primary and then previous seeds. Rotating the primary replaces all ten URLs for each gate. Because old URLs remain valid while their seed is configured as previous, remove that seed only after distributing replacement URLs and ending the intended grace period.
Clear the gate ticket from the host application's logout action when the login page should require a fresh gate visit:
Adminaway.clear_gate_cookie(cookies: cookies, request: request)
# Named gate
Adminaway.clear_gate_cookie(cookies: cookies, request: request, gate: :staff)Run logout over HTTPS whenever the ticket was issued as a Secure cookie. A
browser will not remove that cookie from a plain HTTP response, even if the
response sends a non-Secure deletion cookie with the same name and path. With
secure_cookie = true, attempting to clear the ticket over HTTP raises
Adminaway::ConfigurationError. With :auto, HTTP logout can clear tickets
issued over HTTP, but HTTPS-issued tickets still require HTTPS logout.
Test helpers are opt-in:
require "adminaway/test_helpers"
class AdminFlowTest < ActionDispatch::IntegrationTest
include Adminaway::TestHelpers
test "opens the protected login page" do
open_adminaway_gate
follow_redirect!
assert_response :success
end
endAvailable helpers are adminaway_gate_path, adminaway_gate_ticket,
adminaway_gate_cookie_header, and open_adminaway_gate. Each accepts a named
gate.
Adminaway publishes fixed Active Support notification names with secret-free payloads:
| Event | Payload |
|---|---|
accepted.adminaway |
gate |
token_rejected.adminaway |
gate, reason |
ticket_rejected.adminaway |
gate, reason |
throttled.adminaway |
gate, reason |
Tokens, gate URLs, tickets, cookies, and seeds are never included. Subscribers must preserve that rule when enriching logs or metrics.
ActiveSupport::Notifications.subscribe("throttled.adminaway") do |event|
Rails.logger.warn("Adminaway throttled gate=#{event.payload.fetch(:gate)}")
end- Gate URLs are reusable and remain valid until their signing seed is removed. Adminaway does not provide individual expiry, revocation, or consumption.
- Treat every generated URL as a secret. It may appear in browser history, Rails logs, reverse-proxy logs, CDN logs, or monitoring tools.
- The guard matches Rack
PATH_INFO, including the canonical path and its trailing-slash form, across every HTTP method. Query strings do not bypass it. - A relative URL root (
SCRIPT_NAME) is supported; configurelogin_pathrelative to the Rails application. Redirect and cookie paths include the mount prefix from the current request. remote_ipandsecure_cookie = :autodepend on correct trusted-proxy and forwarded-HTTPS configuration.- In multi-process deployments, rate limiting needs a shared cache store for global behavior. Per-IP limits still need an edge/global complement.
- API-only Rails applications are supported; the engine inserts cookie middleware before the login guard.
- Adminaway hides one login page. It is not a WAF, bot detector, account lock, user authenticator, or authorization layer.
Run diagnostics in the deployed Rails environment:
bundle exec adminaway doctordoctor checks all seed providers, registered gates, route wiring, middleware,
the rate-limit cache store, and HTTPS cookie policy. It never prints a seed or
gate URL. Warnings do not make the command fail; failed checks produce a nonzero
exit status. Run it from the Rails application root so it can load
config/environment.rb.
- Verify the process uses the same primary/previous seed as the generator.
- Pass the matching
--gateand--prefixfor a named gate. - Check
host:orsubdomain:constraints. - Check whether the built-in or edge limiter has throttled the request.
- Run
bundle exec adminaway doctorand inspectbin/rails routes.
- Confirm the browser accepted the cookie and the redirect path exactly matches
login_path. - Use HTTPS. Rails intentionally does not emit a forced
Securecookie on a plain HTTP request outside development. - Check proxy HTTPS headers and host clock skew.
- Inspect
rate_limit_to,rate_limit_within, andrate_limit_by. - Clear the test cache between examples.
- Confirm trusted proxies produce the intended
request.remote_ip. - If another limiter owns the route, disable Adminaway's limiter explicitly.
- Subscribe to
throttled.adminawayfor secret-free confirmation; the HTTP response intentionally remains404and omits rate-limit headers.
- Keep the real authentication, authorization, MFA, and account lockouts.
- Store the primary and any temporary previous seed outside source control.
- Serve the application over HTTPS and verify forwarded-proto handling.
- Use a shared cache store for distributed rate limits.
- Add an edge/global limiter to complement the built-in per-client limiter.
- Configure trusted proxies before relying on
remote_ip. - Restrict access to application, proxy, CDN, and observability logs.
- Subscribe to rejection/throttle events without recording secrets.
- Run
bundle exec adminaway doctorin the deployed environment. - Exercise a valid URL, direct-login rejection, throttling, seed rotation, and logout cookie clearing in staging.
- Keep edge throttling and monitoring enabled.
| Tool | Primary job | Relationship to Adminaway |
|---|---|---|
| Rails 8 authentication | Application-owned identity and sessions | Required underneath Adminaway |
| Devise | Full authentication framework | Alternative authentication underneath Adminaway |
| Rack::Attack | Broad Rack blocking and throttling | Complement; can own gate throttling |
| Lockup | Codeword gate for an entire staging site | Different scope; review its compatibility and maintenance before adoption |
Adminaway is appropriate when the application already has authentication and you specifically want to reduce public exposure of one or more login pages.
The supported application-facing surface for 1.x is:
adminaway(...)in the Rails route DSL;Adminaway.configure, documented configuration accessors, andAdminaway.clear_gate_cookie;Adminaway::Tokengeneration and verification;Adminaway::TestHelpers;- the documented CLI, Rake tasks, and notification names.
Digesters, key derivation, ticket payloads, controller/middleware collaborators, registry objects, and wire-format internals are private implementation details.
The token methods are:
Adminaway::Token.generate(gate: :default)selects one of the gate's ten tokens;Adminaway::Token.variants(gate: :default)returns the complete deterministic set;Adminaway::Token.valid?(candidate, gate: :default)returns a boolean.
Gate names are case-insensitive, while token strings are case-sensitive.
Generation raises the documented seed errors when the primary seed is unusable.
Verification returns false for malformed tokens and missing or weak seeds.
The test helpers accept gate: throughout, and open_adminaway_gate forwards
keyword request options to the integration-test get call.
See DEVELOPMENT.md for architecture, test-matrix, package, and benchmark details. Report vulnerabilities privately as described in SECURITY.md.
Contributions are welcome; see CONTRIBUTING.md and CODE_OF_CONDUCT.md.
Adminaway is available under the MIT License.