Skip to content

Repository files navigation

Adminaway

Quality MIT License

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.

Requirements

  • Ruby >= 4.0 and < 5.0
  • Rails >= 8.1.2 and < 9.0
  • Rack >= 3.2 and < 4.0

CI covers Ruby 4.0 with Rails 8.1, plus an experimental Rails edge lane.

Installation

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 seed

The command emits 32 cryptographically random bytes as 64 hexadecimal characters. Adminaway rejects missing seeds and values shorter than 32 bytes. Never commit the seed.

Quick start

Generate a valid gate URL:

bundle exec adminaway generate

Open 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: 302 to login_path with a short-lived signed cookie;
  • invalid gate URL: 404;
  • direct login-page request without a valid ticket: 404;
  • excessive gate requests: the same 404 response as an invalid gate URL.

The login page still performs the real password, passkey, MFA, session, and authorization work.

How it works

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.

Security model and token trade-off

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.

Route integrations

Always confirm the exact page with bin/rails routes; login_path is a path, not a route-helper name or absolute URL.

Rails 8 authentication

Rails 8's authentication generator creates the sign-in page at /session/new by default:

adminaway login_path: "/session/new"

Devise

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

ActiveAdmin commonly uses a Devise-backed page such as /admin/login:

adminaway login_path: "/admin/login", gate_prefix: "backoffice"

RailsAdmin

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 or a custom 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
end

Adminaway 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.

Multiple and host-scoped gates

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-door

Named 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.

Configuration reference

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
end

Environment 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") }
end

If Rack::Attack or an edge gateway exclusively owns throttling for this route:

Adminaway.configure do |config|
  config.rate_limit_enabled = false
end

Do not disable both layers.

CLI and Rake tasks

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 version

generate 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:doctor

The token tasks accept PREFIX and GATE; verification also uses CANDIDATE.

Seed rotation and token versions

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"] }
  ]
end

Deploy 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.

Logout and tests

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
end

Available helpers are adminaway_gate_path, adminaway_gate_ticket, adminaway_gate_cookie_header, and open_adminaway_gate. Each accepts a named gate.

Notifications

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

Operations and limitations

  • 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; configure login_path relative to the Rails application. Redirect and cookie paths include the mount prefix from the current request.
  • remote_ip and secure_cookie = :auto depend 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 doctor

doctor 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.

Troubleshooting

A generated URL returns 404

  • Verify the process uses the same primary/previous seed as the generator.
  • Pass the matching --gate and --prefix for a named gate.
  • Check host: or subdomain: constraints.
  • Check whether the built-in or edge limiter has throttled the request.
  • Run bundle exec adminaway doctor and inspect bin/rails routes.

Redirect succeeds, then the login page returns 404

  • Confirm the browser accepted the cookie and the redirect path exactly matches login_path.
  • Use HTTPS. Rails intentionally does not emit a forced Secure cookie on a plain HTTP request outside development.
  • Check proxy HTTPS headers and host clock skew.

A known-valid URL unexpectedly returns 404

  • Inspect rate_limit_to, rate_limit_within, and rate_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.adminaway for secret-free confirmation; the HTTP response intentionally remains 404 and omits rate-limit headers.

Production checklist

  • 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 doctor in 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.

Choosing the right layer

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.

Public API

The supported application-facing surface for 1.x is:

  • adminaway(...) in the Rails route DSL;
  • Adminaway.configure, documented configuration accessors, and Adminaway.clear_gate_cookie;
  • Adminaway::Token generation 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.

Development and security

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.

About

Rails engine that gates admin login paths behind seed-derived URLs and short-lived signed cookies.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages