I Removed Authentication and Shipped It as a Feature

The commit is a breaking-change feature commit. It deletes the login system and drops the owner columns from every table. The README now says that anyone who can reach the port can spend my provider key and read the corpus. I think it is the most secure thing I did to that app all summer.

Half a Fence

The app is a diagnostic tool for heavy equipment. One operator. It had grown a multi-tenant skeleton along the way, the way apps do when an agent is building them and every tutorial has a users table. Sessions, access codes, a user identifier column on cases and machines and ledger rows.

Nothing enforced it. Queries did not filter by owner. The role model had one role. The access-code flow had a redemption race that could leave an orphan user row. That was fine, because the orphan got no grant and no session and nothing could reach it. But "fine because unreachable" is a sentence you should never have to say about your own login system.

Half-restored multi-tenancy that nothing enforces reads as a security control and is not one. A reader sees a sessions table and a user column and assumes the queries are scoped. They were not. The machinery itself was the hole, because it advertised a boundary that did not exist.

The Delete

So the auth came out. All of it. The migration that removed it drops three columns that were declared not-null. A database that still has one would reject every insert the new code makes. I ran the migration against a copy of a real database: 35 cases, 6 machines, 49 ledger rows survived the drop intact.

The tests now pin that a migration cannot quietly declare an owner column again. The next time an agent decides the app needs users, a test fails and says why.

One migration number is a deliberate gap. It was the billing table. Renumbering to close the hole would leave the git log unclear about which migration a commit meant. So the gap stays, with a comment.

What Stayed and Why

The origin check on state-changing requests stayed, and its reason changed. It is no longer cross-site request forgery defense, since there is no session to ride. It is a spend guard. Three routes reach a billable provider key, and a page I happen to be visiting could otherwise POST to my own localhost in the background. Same code, different threat, documented as the different threat.

The per-case token ceiling stayed, and it is now the only spend limit in the app. An interview that never ends is the one failure that can still run up a bill while nobody is watching. The README says that.

And the README says all of that up front. There is no sign-in. The dev command binds to localhost. The start command binds to all interfaces. If you can reach the port, you can spend the key. Publishing an app that spends money on inference without saying that would be the actual defect.

The Access Code, Before It Died

The access-code model that this replaced had one judgment call I want to record, because it is the kind of call that gets made silently.

The codes were described as one-time. I read one-time as the allowance, not the number of sign-ins. A code that stopped working after a single session would strand its holder the moment that session expired. There is no email in the app, so there is no reset to send and no way to prove who you were. So a code granted an allowance once and let you sign in as many times as you needed against it.

That call is gone with the rest of the auth. I still think it was right, and I still think it should have been written down at the time rather than pieced back together for a commit message.

The Reflex

The reflex when you find weak auth is to strengthen it. Add the scoping. Add the roles. Finish the fence.

The question I asked instead was whether the app needed a fence. It has one operator. That operator is me. The threat model is a page in my browser making a request I did not intend, and a runaway interview spending money while I sleep. Neither of those is solved by a users table. Both are solved by two small guards and a README that tells the truth.

A security control that does not control anything is worse than its absence. Its absence is at least honest.

-- Justin Higgins. Software Engineer, Midwest. Deleted the login and wrote down what the port can do.


Companion pieces: One Password for the Whole Family - the auth pattern this app did not need. Verify the Artifact, Not the Process - loopback is not a boundary.

Reactions, disagreements, war stories: jchigg2000.dev@gmail.com