Every semantic search in one of my projects costs money: an embedding, a reranking call and a text synthesis. Cents per request. Multiplied by a bot in a loop, it isn't cents. The first version asked for an account for nothing, because requiring an account to search for anime felt absurd. It still does. I do it anyway.
The reason isn't marketing or the mailing list. It's that with no identity there's nothing to count.
The cost of letting people in unidentified
I built a fake-door validation harness with five landings at once to measure interest before building anything. The only thing that needed protecting was the admin panel, so I added IP banning after a few failed logins. Enough, I thought.
It wasn't. The client IP came from x-forwarded-for, and that header is a list client, proxy1, proxy2… where each proxy appends on the right but nobody cleans up what was already written. Taking the first value is taking whatever the client wants to write. Anyone sends x-forwarded-for: 1.2.3.4, dodges their own ban, and can ban someone else's IP by writing theirs.
I fixed it by counting hops instead of trusting the left:
const TRUSTED_PROXY_HOPS = Number(process.env.TRUSTED_PROXY_HOPS || 1);
const list = String(xff).split(",").map((s) => s.trim()).filter(Boolean);
const idx = list.length - TRUSTED_PROXY_HOPS;
return list[idx >= 0 ? idx : 0];
With one proxy in front, the real client is the last value: the one the proxy writes and the client can't touch. It works. But look at what I just did: I added an environment variable that depends on the deployment's network topology in order to identify someone approximately. If tomorrow I put a CDN in front, the number changes and the ban system gets it wrong silently. It doesn't fail: it gets it wrong.
Why an IP doesn't work as identity
Beyond being forgeable behind the wrong proxy, an IP is a bad key for reasons that can't be fixed. Four people in the same office share one. A person on their phone changes IP walking out the door. Mobile networks NAT over huge blocks: banning a carrier IP is banning a whole neighborhood.
What you want from an identifier is that it's stable for the same person and different for different people. An IP does neither. A user_id does both by construction.
What you can only do when you know who's who
In the search tool I ended up with three tiers and a finite limit in all three:
visitor: { semanticSearchLimit: 10, historyLimit: 0 }
registered: { semanticSearchLimit: 30, historyLimit: 10 }
premium: { semanticSearchLimit: 200, historyLimit: null }
The comment I left on the type is the whole criterion: always finite; no tier is exempt from the search quota. Premium has the highest number, not infinity. historyLimit does accept null for "unlimited". The search one doesn't, because every search is an invoice.
The visitor still exists, with a cookie giving them an anonymous key. But the renewal window differs depending on who you are: registered users renew on the calendar month in UTC, premium renews on their signup anniversary. That requires storing a premiumAnchorAt, and storing anything requires a row with an owner. With no account there's no anniversary to anchor.
The case of apps with a cost per request
When every action costs real money, the quota stops being a defense and becomes accounting. And accounting fails in boring ways.
In the search tool, two identical requests were counted twice. The fix was a reservation hashed on the content of the request, not its text: the normalized query, the limit, the explicit flag and the sorted filters. The comment on the type says why order matters — the same set chosen in another order is the same search — and why the filters belong inside: the same text with a different genre is a different request, and merging them would return the wrong results. The reservation returns reserved, duplicate, in_progress or exhausted. All four states exist because all four happened.
In a transcription API, character billing started out as transcription plus processed text divided by ten. When I measured the real cost, the line became this:
transcriptionText.length + Math.floor(processedText.length * 0.25)
From /10 to * 0.25: two and a half times more expensive. The price was off by a factor of 2.5 and for months I was paying the difference without knowing, because I had no measurement to notice it.
In an image generation SaaS, the bug was the opposite: daily credits existed but the pre-check only looked at subCredits. Users with daily credit got a 402 with credit in their account. A dailyCredits + subCredits fixed it. All of this is columns on a user row. With no row, there's no bug to fix because there's nothing to count.
When identity isn't enough either
Having an account isn't having a valid identity. In an API with keys, the key's row outlived the owner's account status: the billing record was cleared during offboarding without anyone touching the key's isActive. The key kept authenticating. The fix was to revalidate inside authenticateApiKey itself that the owner still has a billing record, instead of trusting the flag:
if (!ownerBilling) {
return { authenticated: false, error: "API key owner is no longer active" };
}
It goes there and not in every endpoint, precisely so that no endpoint has to remember.
When I do let people in without an account
When the action costs nothing and accumulates no state: reading an entry, browsing a catalog, loading a landing page. Plus a visitor tier with a short quota, so someone can try it before deciding. Ten searches before signup is honest friction; zero is an open invoice.
The question isn't whether login is annoying. It is. The question is what you're going to put in the WHERE when someone crosses the line.
