One shared password in front of a whole Rack app — for staging, demo and preview environments that should not be public but don't need real accounts.
- Visitors see a minimal, self-contained password form (no asset pipeline).
- The right password sets a cookie carrying an HMAC of the password, keyed by your secret — rotating the password invalidates every cookie at once.
- Fail-closed: no configured password means nobody gets in.
- Health check (
/up) stays reachable;return_toonly accepts same-origin paths. - Shareable links:
https://staging.example/anything?gate_password=…sets the cookie and redirects to the same URL without the parameter — the secret doesn't linger in the address bar, history or screenshots. - Link previews work: a messenger's crawler fetching that link is served
the page directly — it presented the password but can't keep a cookie — so
the card shows the real title, description and image. A crawler is a known
agent token (WhatsApp, Slack, iMessage's Facebot, Twitterbot, Discord…) or
a request with no
Accept-Languageheader (Telegram and Viber wear a plain Chrome agent; browsers always send the header)./og.pngis open by default for the picture. Crawlers without the password see the form like anyone else.
# Gemfile
gem "environment_gate"
# config/environments/staging.rb — and demo.rb, preview.rb, …
config.environment_gate.enabled = trueThe middleware is appended to the stack (after ActionDispatch::Static, so
fingerprinted assets stay reachable). The password comes from
ENVIRONMENT_GATE_PASSWORD, else Rails.application.credentials.environment_gate_password
— one password per environment, or a single string shared by all of them:
# bin/rails credentials:edit
environment_gate_password:
staging: …
demo: …The cookie key is secret_key_base. Password and key are read per request.
The form's heading defaults to the environment name ("Staging environment").
Any option below can be set the same way, e.g. config.environment_gate.title = "Preview".
bin/rails environment_gate:link # this environment
bin/rails environment_gate:link ENV=demo # another environment's password
bin/rails environment_gate:link ENV=demo HOST=https://demo.example
prints https://host/?gate_password=… from that environment's credential
and its host: HOST= if given, else the Kamal destination's proxy.host
(config/deploy.<env>.yml — the one place the public name of another
environment is written down), else the running environment's
default_url_options.
use EnvironmentGate::Middleware, password: ENV["ENVIRONMENT_GATE_PASSWORD"],
secret: ENV["SECRET_KEY_BASE"]password and secret may be strings or callables (resolved on every request).
| Option | Default | |
|---|---|---|
cookie_name |
"environment_gate" |
|
form_path |
"/environment-gate" |
where the form posts |
param_name |
"gate_password" |
query parameter that unlocks on GET; nil disables |
open_paths |
["/up"] |
exact paths that bypass the gate |
preview_paths |
["/og.png"] |
open paths for link-card images |
preview_agents |
WhatsApp, Telegram, Slack, Twitterbot, facebookexternalhit, Discordbot, LinkedInBot, Applebot, iMessage, Signal, Skype, Viber, Mastodon, Bluesky… | User-Agent substrings served the page directly when the URL carries the right password; [] disables |
title |
"Restricted environment" |
form heading (Rails: the environment name) |
ruby -Ilib -Itest test/middleware_test.rb
Formerly staging_gate (config.staging_gate, STAGING_GATE_PASSWORD,
credentials.staging_gate_password, cookie staging_gate, form
/staging-gate). Renamed when a second gated environment arrived and the
password became per-environment.