Three weeks ago, a reader pointed out that RamenHire's RLS policies treated "signed in" as a proxy for "admin" — safe only because no real user login existed yet. I logged it as a deliberately deferred finding: real, but not exploitable until the day real accounts existed. This week, that day arrived, and so did the moment to find out whether the deferral was actually safe.
What shipped
Real per-company accounts. Email/password auth, a proper session, a settings page, and — the actual point of all of it — self-service control over your own listings: create, edit, delete, no email to me required. This is the direct answer to the thing that started this whole thread of work: a company that asked me to take their listing down, because they had no way to do it themselves.
Underneath that: an is_admin flag, real Row Level Security scoping every table by ownership instead of a blanket "any signed-in user," a soft-delete and purge system with a real grace period, and a security audit that re-tested the RLS closure adversarially rather than trusting the sprint's own step-by-step checks — including trying to reassign a listing's ownership via a raw, forged request directly against the database, bypassing the app entirely. Rejected, correctly, at the database layer.
The part that actually scared me
A few days after the sprint "finished," a different reader published a post with a title that stopped me cold: this endpoint has auth, the tests are green, it still lets a user hand themselves admin. The pattern: a route checks that you're logged in, then writes your request body into the database without checking which fields it's writing — so a client can slip in a privileged field alongside a legitimate one, and it just gets accepted.
Here's why that pattern is uniquely dangerous in this codebase specifically: every write route I have goes through a service-role client, by design, for reasons that made sense at the time. But a service-role client bypasses Row Level Security entirely. All the RLS work from the sprint above — meaningless, for these routes, if any of them forwarded the request body instead of picking specific fields out of it.
So before anything shipped, I checked every route this sprint touched, the same way the post described: crafted a request with legitimate fields sitting next to is_admin: true, status: "approved", someone else's company_id. Sent it for real, against a real database, and checked what actually landed — not whether the request succeeded, but what the row looked like afterward.
Every route held. Every privileged field was silently dropped; only the legitimate fields ever changed. Not because I got lucky — because every route was written to explicitly pick the fields it expects, not to trust the shape of whatever arrived.
I don't think I would have thought to test this specific thing on my own. I've been building this in the open long enough now that I've stopped being surprised when the most useful pressure-testing comes from someone else's post, not my own checklist.
What's still open, on purpose
Not everything gets fixed just because it got found. A couple of things from this pass are logged as deliberately accepted for now rather than fixed: login rate limiting is IP-based (an email-keyed backstop got added since, closing the gap a distributed attacker could otherwise use); there's no persistent audit trail of what the automatic purge job actually deletes, just Vercel's own function logs. Neither blocks anything today. Both are written down, dated, so they don't quietly become "we forgot" later.
The honest number
Real registered companies, after all of this: still building toward the first one. This sprint didn't produce a growth result — it produced the thing that has to exist before a growth result means anything. A company controlling their own listing isn't a nice-to-have; it's the baseline a real company should expect before trusting a platform with anything at all.
Top comments (9)
Does the create path check company_id against the caller's own company, or is that left to the RLS side? Asking because a legitimate foreign key is the one field explicit picking can't help with, company_id has to be on the allowed list since create needs it, so a real id belonging to someone else still passes the pick.
Good question, and it's actually handled two different ways depending on which path you mean, both already tested against exactly this scenario.
Create: company_id was never on an "allowed list" pulled from the client body at all — it's resolved server-side from the caller's own session, independent of whatever the request contains. Tested by sending a real foreign company_id on create; the row landed with my own company's id regardless, because the server never reads that field from input in the first place.
Update: different mechanism — RLS's WITH CHECK is the backstop there, and I tested it by trying to PATCH company_id directly against the database, bypassing the app entirely. Rejected with a real row-level-security violation, id confirmed unchanged afterward.
So create never trusts the field; update trusts the database to reject it even if the app somehow did. Appreciate you asking specifically instead of me just asserting "it's fine" — this is exactly the kind of field that's easy to get wrong by only picking, not resolving.
Good, and going straight at the database is the right way to test WITH CHECK. What I can't line up is the app's own update path: you mentioned earlier that your writes run through a service-role client, which RLS never sees. Is that still true for update, or has that route moved to a user-scoped client?
Good question to ask separately — you're right that the database-level test didn't actually prove anything about the app's own route. Checked it directly: the update route resolves the caller's company server-side from the session, never from input, and compares it against the listing's owner before touching anything. Tried editing another company's listing through the real endpoint — 404, not 403, specifically so a non-owner can't even tell whether the listing exists. Then edited the same listing as its actual owner to confirm the route itself works, and the 404 wasn't just a broken endpoint.
So: not resting on RLS alone here; there's an explicit app-level check in front of it. Appreciate you not letting "the database rejects it" pass as an answer to a question about the app.
a reader catching a bug before it shipped is the dream and the scare at once. i keep hoping someone will spot the thing i can't see in my own project, still nervous about the day they do.
That's exactly it — I don't think the nervousness ever really goes away; I've just started treating it as a sign the project's actually being looked at rather than something to dread. The day someone spots the thing you missed is a much better day than the one where nobody ever does.
Something you can do without waiting on anyone: open your own site with the network tab on and read what the browser is sending. Does yours talk to the database straight from the browser, or is there a server in between? Those two shapes fail in different places, so which one you have decides where it's even worth looking.
went and looked before answering, which is already more than i usually do. writes all go through a server first, but some public reads come straight from the browser. i knew the first part and had to check the second, so your point stands. the network tab as a mirror for my own app is going in my toolbox.
Public reads straight from the browser are the half I got wrong in mine. A read policy filters rows, and I took that to mean it filtered data, so my profile table kept the display name and the signup email sitting in the same row. Anything allowed to read the row got both. Since the network tab is in the toolbox now, the response bodies on those reads are worth reading as slowly as the requests. What does one of those come back with on yours?