About

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.

One key : N browsers. The server keeps no list.credentialsowner_key = Kone key for the whole appsigned cookie valueSHA256(owner-mode:K)same value everywherePhone - ownerLaptop - ownerTablet - ownerRotate the key: every browser is out at onceowner_key = K2cookie still holdsSHA256(owner-mode:K)All three: no matchCut just one: not possiblePutting the key itself in the cookie would leak the key. Putting true would survive rotation.

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

MethodMappingServer stateCut one deviceCost
Secret URL + signed cookie1 key : N browsersNoneNo (all or nothing)Almost none
HTTP Basic1 password : NNoneNoNone. Popups, no logout
Devise login1 account : N sessionsusers tableDepends on setupTables, screens, mail
Magic link1 link : 1 browserStored tokensYesSending mail, expiry
Per-device token1 token : 1 deviceDevice listYesSomewhere to keep the list
Passkey1 key : 1 devicePublic key listYesHeaviest to build
Tailscale / Cloudflare AccessOutside the appNone in the appYesZero 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

1

One visit to /owner?owner_key=… runs secure_compare against credentials. Wrong key returns 404

2

On match, store SHA256(owner-mode:key) in a permanent, httponly signed cookie

3

Guard write actions with before_action :require_owner (403 otherwise)

4

Pass the key as a query param ending in _key, not a path segment, so logs show [FILTERED]

5

Switch to a POST form before deploy, since the URL stays in browser history

Use Cases

Write protection for a tracker or notes app only you use Internal tools before they need real login
← Why API keys are shown only once, and why we skipped the "show again" button A GitHub repo instead of a database: the real condition is an append-only event log, not "single user" →