WRONG Got a Welcome Frame

I connected to one of my own services with the token WRONG and it said hello.

Not a rejection. Not a dropped socket. A welcome frame: session accepted, ready for commands. I had picked that string to be insultingly incorrect, because I wanted to watch the refusal happen. There was no refusal to watch.

I run a handful of small services for myself. Two page text onto a pair of display glasses, one fronts a download client, one holds a notes capture surface. Every one of them has a bearer token. I wrote that auth code deliberately, months apart, and every version of it is correct.

The && was a courtesy

Here is the line that let WRONG in:

if (tokenToCheck && frame.token !== tokenToCheck) {
  // reject
}

Read it aloud: if a token is configured and the offered one does not match, reject. A sensible sentence. It is also a conditional whose first clause can be false, and when it is false the check does not weaken. It vanishes. Every client is welcome, whatever they present, including nothing at all.

I know exactly why I wrote it that way. It is a convenience for local development: start the thing without setting up secrets and it will not get in your way. That && is what being accommodating looks like in code. I copied it into four services without once asking what it does on the day the token is genuinely absent, because I could not picture that day. The token would always be set. Setting it is step one.

The secret was one directory away

The token was set. It sat in a .env file beside the source, git-ignored, correct value, exactly where my own README says to put it.

Unit files do not read a repo’s .env. That is not a bug in systemd, it is simply not a thing a unit does unless you aim an EnvironmentFile at one, and mine did not. So the process started with the variable unset, the check dissolved, and the secret rested on the same disk, one directory from the running process, never once read.

This is the part I keep turning over. No step in the setup was skipped. The secret existed. The code that consumes it existed. The gap between them was a line I never wrote, in a file I was not thinking about, and nothing at either end was wrong.

It announced itself, twice

The whole time, the failure was telling me. Twice, in fact.

One of the four printed a warning on every single start, saying in plain words that its routes were “OPEN (no auth)”. Accurate, prominent, correctly alarmed. It went to a journal nobody tails. A background service’s stdout is a message in a bottle: technically published, addressed to a person who is not standing there. Mine had been shouting for months into a log I open only when something is already broken.

Then there was the test:

test("/v1 routes are open when no token is configured", ...)

It asserted a 200, and it passed. I wrote it on purpose. My suite was not missing coverage of the hole. It had coverage. It described the hole precisely, pinned it in place as intended behavior, and went green about it every time I ran it. A test cannot tell you that the behavior you specified is a bad idea. It can only confirm you still have it. I had automated my confidence in the wrong thing.

The same absence, forty-six times

An unread .env had already cost me once this week, in a form that looked nothing like an open door.

A planner of mine had been dry-running every morning before it was allowed to touch anything physical: compute the plan, log what it would have done, actuate nothing. Deliberate rehearsal, for a month and a half. Its config loader read an API key from the environment, found nothing, and fell through to a bundled set of sample fixtures.

Forty-six consecutive dry runs. All green. Not one had ever spoken to the real controller. The point of rehearsing for six weeks is to accumulate evidence that the thing works, and what accumulated was six weeks of a play performed on no stage. The greenness is precisely what stopped me looking.

Same absent file. One fallback opened a door to anyone who could reach it. The other quietly served fiction in place of reality. They look like opposite failures, and they are one decision made twice: when this value is missing, carry on.

What a missing value should cost

The bug was never in the auth code. All four checks were fine. The bug is that everywhere a value can be absent, some earlier version of me had to decide what absence means, and the comfortable answer is always to keep going.

Keep going is right for a theme preference. It is catastrophic for a credential, and it is corrosive for a data source, because a program that carries on without its inputs is not degraded. It is confidently pretending.

So: size the failure to the value. A missing secret now gets the loudest failure available, which is refusing to start at all. Each of the four exits at boot with one line naming the variable it needs. The comparison runs through timingSafeEqual, so an empty configured token cannot accidentally match an empty offered one. The bind address defaults to loopback, so a deploy that forgets to say where to listen becomes unreachable rather than reachable. Every default now fails toward useless instead of toward available.

The two tests that pinned the open mode now pin the closed one, asserting 401 for a missing token, an empty token, and the string anything.

I tried WRONG again. Connection refused. Nothing was listening, because nothing had been allowed to start.


← all writing