Owner mode without login: secret-URL auth is one key to N browsers, not 1:1
Put a digest in the cookie and "log everyone out" comes free. Plus the day the key showed up in the log
This is a way to tell "is this the owner?" in a Rails app you use alone, with no sign-up. By the end you can wire up a secret URL plus a signed cookie, see why it ends up one key to many browsers, and judge when that is enough and when you need real login.
I didn't want login
My relocation countdown (app151) is a checklist only I use. Its state lives as JSONL in a git repo. Strangers shouldn't be able to write to it, so I need to know whether a request comes from me. Sign-up, login and password reset felt like way too much.
So: visit /owner?owner_key=<secret> once. The server runs secure_compare against the key in credentials. Wrong key, 404, so the route doesn't even admit it exists. Right key, it sets a signed cookie, and later write requests pass if that cookie checks out. Otherwise 403.
It is not 1:1
At first glance this looks like issuing a token per device and binding them 1:1. It isn't. There is one key for the whole app. Phone or laptop, you come in with the same key. The server does not record who got a cookie. There is no session table.
The cookie holds only SHA256("owner-mode:" + key), so every browser has the exact same cookie value.
The properties fall right out of that. Change the key and every cookie becomes "the digest of the old key", so everyone is logged out at once. You can't log out just the phone, because neither the server nor the cookie has anything that tells devices apart. You also can't tell who changed what. Everyone is the same "owner".
One digest line is the whole design
Put the key itself in the cookie and the cookie is the key, so a leaked cookie leaks the key. Put a fixed true and old cookies survive a key change, so "log everyone out" no longer works. The digest avoids both. Because the cookie is signed, a value typed into the browser won't pass either.
The key was in my log
My first version took the key as a path segment, /owner/:key. Then I opened log/development.log and saw Started GET "/owner/_rPX... with the whole key in it. In production that would have flowed into the log collector.
The fix was fun. Take it as a query parameter, and name it owner_key. The log line became Started GET "/owner?owner_key=[FILTERED]".
Rails prints request.filtered_path on the Started line. It filters the query string but leaves the path alone. In the actionpack 8.0.4 source, path stays as is and only the query goes through filtered_query_string. And filter_parameters matches partially, so the default :_key catches owner_key. I didn't add any config. Had I added :key, it would also hide monkey and keyword.
It still ends up in browser history and address-bar autocomplete. So I keep this approach for local, single-user use, and switch to a form (POST) before deploying.
Compared with the alternatives
HTTP Basic auth also has no server state and costs nothing, but you get a popup every session and no logout, which is painful on a phone. Devise can handle per-device sessions, but brings a users table, screens and mail. If you want to cut off one device, a device list has to live somewhere. Magic links, per-device tokens, passkeys: all of them need one. 1:N is the price of zero server state.
So the conditions are: you use it alone, and it stays local or gets a form before deploy. Once a second person uses it, move to login. A comparison table and the code are in the details below.
If you are deciding how much auth an internal or personal tool needs, feel free to describe your setup to negabaro.
Ways to tell the owner apart
| Method | Mapping | Server state | Cut one device | Cost |
|---|---|---|---|---|
| Secret URL + signed cookie | 1 key : N browsers | None | No (all or nothing) | Almost none |
| HTTP Basic | 1 password : N | None | No | None. Popups, no logout |
| Devise login | 1 account : N sessions | users table | Depends on setup | Tables, screens, mail |
| Magic link | 1 link : 1 browser | Stored tokens | Yes | Sending mail, expiry |
| Per-device token | 1 token : 1 device | Device list | Yes | Somewhere to keep the list |
| Passkey | 1 key : 1 device | Public key list | Yes | Heaviest to build |
| Tailscale / Cloudflare Access | Outside the app | None in the app | Yes | Zero app code, depends on infra |
For a local tool only you use, the first row is enough. Once two people use it or it goes on a public server, move down the table.
Concern and controller
module OwnerMode
COOKIE = :owner_mode
def self.digest(key) = Digest::SHA256.hexdigest("owner-mode:" + key)
private
def owner?
key = OwnerMode.key
key.present? &&
ActiveSupport::SecurityUtils.secure_compare(cookies.signed[COOKIE].to_s, OwnerMode.digest(key))
end
def require_owner
head :forbidden unless owner?
end
end
class OwnerSessionsController < ActionController::Base
include OwnerMode
def show # GET /owner?owner_key=...
return head(:not_found) unless OwnerMode.valid_key?(params[:owner_key])
cookies.signed.permanent[COOKIE] = { value: OwnerMode.digest(OwnerMode.key), httponly: true }
redirect_to "/"
end
end
How the log changed
# key as a path segment
Started GET "/owner/_rPX...(the whole key)"
# key as a query param named owner_key
Started GET "/owner?owner_key=[FILTERED]"
Parameters: {"owner_key"=>"[FILTERED]"}
Key Points
One visit to /owner?owner_key=… runs secure_compare against credentials. Wrong key returns 404
On match, store SHA256(owner-mode:key) in a permanent, httponly signed cookie
Guard write actions with before_action :require_owner (403 otherwise)
Pass the key as a query param ending in _key, not a path segment, so logs show [FILTERED]
Switch to a POST form before deploy, since the URL stays in browser history