Shipping

An auth gate you do not resent

A gate asked too often gets automated away, and one asked once never expires. Charge full authentication only for transitions that change the threat model.

MobileByteSensei 10 min read
A time lock whose full ceremony is required only when the wheel has run down
secretstoolingworkflow

Every read from the secrets vault passes through an authentication gate. The interesting problem with that gate was never whether it was strong enough. It was whether anyone would leave it switched on.

The case this post is about

One vault, and two states it can be approached from:

cold — no session, or expired, or a different machine
       → full vault authentication
warm — a valid session on this machine, within the window
       → a local presence check: the fingerprint reader

Before that split there was one question asked one way. Asking for the full credential on every operation meant it was asked dozens of times a day, which is how a password ends up in a shell function somebody wrote to stop being asked.

Asking once and trusting the session forever is the other failure: the session outlives the laptop being closed, lent, or carried somewhere else.

This post follows those two rows, because the fix is not a better interval — it is that the two states deserve different questions, and a third case has no human to ask at all.

The symptom

A gate that asks too often does not get stronger. It gets routed around.

The first form is automation of the answer. Prompt for a master password on every operation and within a week somebody has it in a shell variable, or a keychain entry read by a wrapper script, or a startup command that unlocks everything at login. Each of those is a rational response to a bad prompt-to-work ratio, and each one converts a control into a decoration — the credential is now sitting in exactly the place the vault existed to avoid.

The second form is the opposite failure and it is quieter. Ask once, keep the session forever, and the gate is strong on the day you install it and meaningless a month later. There is no moment where it breaks. The session file just outlives every assumption it was created under: the laptop goes to a café, the machine gets re-imaged, somebody copies a dotfile directory to a new computer, and the session comes along.

A barrel wound once, running on long after anyone remembers winding it

The third is that a scheme designed around a human breaks completely where there is no human. A fingerprint prompt in continuous integration is not a stricter control, it is a hang — so somebody adds an environment variable to skip it, and that variable spreads, because it is the only thing that makes the pipeline work. Within a month the bypass is the normal path and the gate applies to exactly one of the two places secrets are read.

The fourth is about when the cost is paid. An authentication prompt never arrives at a convenient moment; it arrives in the middle of whatever you were doing, because that is when the secret was needed. A gate that interrupts deep work four times an afternoon will be disabled by a reasonable person who fully understands why it exists.

Why the obvious fix failed

Lengthen the timeout. The first adjustment anybody makes and it does not change the shape of the problem, it slides a point along it. Too short and people automate; too long and the session outlives the context it was created in. There is no value that is right, because the thing being traded is not really time — it is "how much has changed since I last checked", and a clock is a poor proxy for that.

Remember this machine forever. Honest about the fact that re-authenticating on your own laptop is mostly theatre, and it removes the gate rather than tuning it. It also treats "this machine" as a stable fact when it is a file on disk that can be copied, which means the thing being remembered is weaker than it sounds.

Prompt on every secret read. The maximally strict option, and it is the one that actively trains the behaviour you least want. Typing a master password dozens of times a day makes entering it a reflex rather than a decision — and a reflex is precisely what a convincing fake prompt is hoping to find. A gate can be strict enough to erode the attention it depends on.

Unlock everything at login. The pragmatic compromise, and it fails in a way worth spelling out because it looks like the others and is not. The first three all trade security for convenience along one axis. This one changes the unit: instead of authenticating to perform an operation, you authenticate to enter a period, and during that period every secret is readable by anything running as you. The gate is no longer attached to the thing it protects — it has become a property of the session, and a session is shared by every process in it, including the ones you did not start.

The actual mechanism

Stop treating this as one question with a frequency, and treat it as a state machine where different transitions cost different amounts.

Two states, two questions:

cold — no session, or expired, or a different machine
       → full vault authentication
warm — a valid session on this machine, within the window
       → a local presence check: the fingerprint reader

The reason this works is not that the warm check is weaker. It is that the two checks answer genuinely different questions. Full authentication proves you hold the vault credential. A fingerprint proves a person is physically at this machine right now — which is the only thing that has actually come into doubt since the last time you proved the first one.

Two mechanisms, one for winding and one for every stroke in between

Three properties make it hold together.

The window slides on use. A fixed expiry interrupts whoever is working hardest, because sustained work is exactly what runs into a fixed deadline. Refreshing the window on each successful check means active sessions stay alive and idle ones die, which is the behaviour you actually wanted from a timeout and never get from a fixed one.

The session is bound to the machine. Without this, the whole scheme is a file that grants access wherever it is copied. With a machine identity recorded in the session and compared on every use, a copied session is a cold start on the new machine, which is the correct outcome and requires no detection logic.

A movement cut to seat in one case and no other

Continuous integration is a separate branch, not an exception. There is no person in a pipeline, so a presence check is meaningless there — and the right response is a different full-strength credential every run, not a bypass. The distinction matters: an exception weakens the control for whoever can claim it, while a branch holds a different population to a standard appropriate to it. The test is whether the branch would be acceptable if it were the only path. Re-authenticating every run would be.

There is a sub-case that is easy to get subtly wrong. The sliding window must refresh on a successful check only. Refresh on attempt and a failing loop keeps the session alive indefinitely, which inverts the mechanism — the window is supposed to measure time since presence was last proven, not time since someone last tried.

The fix

session file, 0600, holding: machine id, last-verified time
on every vault operation:
    no session | expired | machine mismatch  →  full authentication
    otherwise                                 →  presence check, then refresh the window
    no human present (CI)                     →  full credential, every run

The machine-id binding is the clause that stops a copied session file being a working credential somewhere else:

SESSION="$HOME/.config/secrets-auth/session.json"   # <!-- preflight-ignore --> a path, not a value
WINDOW=86400                                  # 24h sliding

auth_gate() {
  # CI has no human to present a finger. Full credential, every run, no session.
  if [ -n "${CI:-}" ] || [ ! -t 0 ]; then
    require_full_credential || return 1
    return 0
  fi

  if [ -f "$SESSION" ]; then
    age=$(( $(epoch) - $(json_get "$SESSION" verified_at) ))
    same_machine=$([ "$(json_get "$SESSION" machine_id)" = "$(machine_id)" ] && echo yes || echo no)

    # A session copied to another machine is NOT a session. Binding to the machine
    # id is what makes the file useless to anyone who takes it.
    if [ "$age" -lt "$WINDOW" ] && [ "$same_machine" = yes ]; then
      presence_check || return 1              # the cheap path: Touch ID
      json_set "$SESSION" verified_at "$(epoch)"   # slide the window
      return 0
    fi
  fi

  require_full_credential || return 1
  umask 077
  printf '{"machine_id":"%s","verified_at":%s}\n' "$(machine_id)" "$(epoch)" > "$SESSION"
}

The three paths, in the order they are hit:

$ secrets get acme-firebase-sa      # cold start
  no session — full authentication required
  [unlocked] session opened, 24h window

$ secrets get acme-supabase-url     # warm
  presence check … ok               # one touch, window slid

$ CI=1 secrets get acme-firebase-sa # no human
  CI detected — full credential required, no session

Sliding the window on each use rather than from first unlock is what keeps a working day to one full authentication without extending the window for a machine left idle. every outcome, pass or fail, appended to an audit log


The audit log is the part that looks optional and is not. A gate with no record cannot answer the one
question you will eventually have — *when was this last unlocked, and from where* — and that question
arrives on the day you are trying to work out whether something happened. It costs one appended line.

There is also an emergency skip, because a control with no override gets replaced wholesale by
somebody under pressure at the wrong moment. The thing that keeps it honest is that using it is
logged. An override you can take silently is a hole; an override that leaves a record is a decision,
and a decision somebody can see afterwards is one people make carefully.

## How we proved it

**What is verifiable here is the mechanism, not the satisfaction.** The session file carries the
machine identity and is written 0600. The window refreshes on success. A machine mismatch produces a
cold start. CI authenticates with its own credential on every run. Each of those is a property you
can check by reading the state and exercising the transitions, and I have.

**What I cannot claim is the thing the post is actually about.** The premise is that a gate people do
not resent stays switched on, and I have not measured resentment, disablement rates, or how often the
emergency skip is used. I have one operator and a design that is pleasant to use, which is an opinion
wearing the clothes of a result. The honest statement is: this scheme removed the specific pressure
that was previously making me want to cache the master password, and that is a sample of one.

**What I have not solved:** the audit log is local. It records what happened on this machine, to a
file on this machine, which means it is useful for reconstructing your own history and worth very
little against anyone who has already got far enough to be tampering with it. A log that matters for
security has to leave the machine, and mine does not.

![Winding, running, and the point at which the ceremony is required again](/posts/touch-id-for-a-vault/anim-window.gif)

**The window also trusts the clock.** Expiry is computed from a timestamp against the machine's own
time, and whoever holds the session can move that time. In the threat model this is built for — a
laptop that might be lost, a session file that might be copied — that does not matter much, because
an attacker in a position to set the clock is already past the part this defends. It is worth stating
because "the session expires after a day" sounds like a guarantee and is really an agreement with a
clock that the holder controls.

**And a boundary worth naming:** a presence check proves a person is at the keyboard, not *which*
person, and not that they intended this particular operation. An unlocked laptop with a cooperative
owner nearby defeats it entirely. It is a meaningfully better signal than an unexpired timer and it
is not identity, and I would rather state that than let "biometric" do rhetorical work it has not
earned.

## What this generalizes to

A control that people resent is a control that will eventually be removed — and it will be removed by
someone who understands exactly why it exists, which is why "explain the risk better" never fixes it.

So when a check feels too expensive, the useful question is not how often to ask. It is **what has
actually changed since the last time it was answered**. Most repeated authentication is re-proving a
fact that has not come into doubt, and the cost of that re-proof is paid in attention, which is the
budget that funds every other control you have.

Find the transitions that genuinely change the picture — a new machine, a long gap, a different
network, a privilege escalation — and spend the expensive check there. Everything in between gets the
cheap check that confirms the one thing that *has* become uncertain. The gate ends up stronger at the
moments that matter and invisible the rest of the time, which is the only version anybody leaves
switched on.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS