The hotel that forgot to lock its doors

A
Akram
20th Jan 20227 min read

I want to tell you about a bug we just fixed, because it's a good reminder of how a genuinely well built app can still have a hole in it that's almost embarrassing once you see it.

Here's the setup. Think of the app as a hotel. There are two very different security questions a hotel has to answer, and it's easy to only answer one of them.

The first question is "are you a guest here at all." That's the front desk. Show ID, get a key, you're in the building. In software terms, that's authentication. Our front desk was solid. Every unauthenticated request got turned away, no exceptions.

The second question is "is this actually your room." That one gets checked at every single door, every single time, regardless of who's asking. This is authorization, and the specific flavor of it here has a name: IDOR, or insecure direct object reference. Which is a fittingly bureaucratic name for a simple problem: the server took a room number and just... opened the door. Nobody checked whose name was on the reservation.

So that was us. Anyone who logged in could read anyone else's tasks, edit them, delete them. Comment threads on private tasks, fully visible. Notifications leaking the contents of tasks you had no business seeing. Not because anyone was malicious, mostly, but because the app never actually asked the question.

The part that stung a little

Here's the thing that made this fun to explain and slightly less fun to discover. We already had a privacy rule. Tasks were supposed to be private by default, visible only to the author, the assignee, collaborators, and admins. That rule existed. It was even correctly written.

It just lived entirely in the browser.

Which, in hotel terms, is like the front desk handing every guest a lovely little map with only their own room circled on it. Thoughtful. Helpful. Not a lock. A guest who ignores the map and just walks down the hallway trying doors finds out very quickly that every single one opens, because nothing downstream of that map was ever checked. The rule was real. The enforcement was not.

This is, I think, one of those lessons that's obvious in hindsight and invisible in the moment: anything running in the browser is a suggestion, not a rule. The user's device is also the attacker's device. They can inspect it, rewrite it, skip it entirely. Only the server can actually say no, because it's the one part of the system nobody else gets to edit.

Security Architecture Vector

Authentication vs. Authorization

The difference between checking in at the front desk and locking individual room doors.

Vulnerable (The Flaw)

Client-Side Map (Suggestion)

Door unlocked on the server

1
Front Desk (Authentication)Guest logs in. Verified ID establishes a valid session token.Status: 200 OK (Auth Valid)
2
Browser Client (The "Map")UI hides Room 102 from navigation. But attacker opens DevTools / sends direct API call:GET /api/tasks/102
3
Server Endpoint (No Door Lock)Server asks "Is caller logged in? Yes." Blindly opens Room 102 without checking reservation owner.
IDOR Vulnerability: Data LeakedBrowser rule bypassed; unauthorized payload served.
Enforced (The Fix)

Server-Side Authorization

Lock installed on every room door

1
Front Desk (Authentication)Guest logs in. Verified session identifies user:session.userId = "guest_101"
2
Direct Request to Target RoomCaller attempts direct access to private resource:GET /api/tasks/102
3
Server-Side Authorization LockServer verifies ownership:
task.authorId !== session.userId
404 Not Found (Zero-Leak Protection)Door stays locked. Gives away nothing about room existence.
!

Rule of Thumb: Browser logic is a suggestion for honest users; server authorization is the actual lock. Always return a 404 rather than a 403 to conceal whether private data even exists.

Figure 1: Comparison between client-side UI filtering and server-side 404 authorization enforcement.

What we actually did about it

We moved the check to where it belonged, server side, on every read and every write. Nothing philosophical about it, just put the lock on the correct side of the door.

One detail I genuinely liked: when someone tries to access a task they're not allowed to see, the server now returns a 404, not a 403. A 403 says "you can't come in here," which quietly confirms the room exists. A 404 says "there's no such room," full stop. For private data, sometimes the fact that a thing exists is itself the secret, so we made sure the response gives away nothing at all.

We tested it the boring, satisfying way. Fixture users with different access levels, checking not just that the wrong requests got rejected, but that the underlying data was untouched afterward, byte for byte. Rejection isn't enough. Nothing should have happened at all.

What we left alone, on purpose

Two things, and I want to be honest about them because "we fixed everything" is rarely true and usually suspicious.

"Clear all notifications" still clears everyone's notifications, for everyone. The notifications table has no concept of a recipient, it's one shared list filtered by visibility on the way out. Fixing that properly means changing the shape of the database, not patching a line of code, and that's a product decision, not an engineering shortcut. So it's flagged, not fixed. Yet.

And task creation still trusts whoever the client claims the author is, which means authorship could technically be spoofed. Worth slowing down on this one, because it's a good little case study in why "obvious" fixes aren't always free.

The other unlocked door, sort of

When you create a task, the request includes an authorId. The server was supposed to be asking "who is this, according to the session I already verified." Instead it was asking the client "hey, who are you?" and writing down whatever it was told. Same instinct as the check-in desk that just copies whatever name you wrote on the form instead of glancing at your ID.

This matters more than it sounds like it should, because the access rule we just fixed grants visibility partly based on being the author. So if authorship is just whatever the client claims, someone could type in another user's id and, on paper, the system now believes that person wrote the task. That's the exact door we spent this whole article locking, propped back open through a side entrance.

The tempting fix is to stop asking entirely. Always set authorId server side from the session, ignore whatever the client sends. Clean, one line, done.

Except that assumes every legitimate use of that field is "the person creating this task is always the author," and that's not obviously true. What about a manager creating a task and handing it to someone else. An admin tool creating something on someone's behalf. An import script backfilling years of old tasks under their original, actual, historical authors, none of whom are sitting there logged in right now.

The first two of those, it turns out, aren't really an authorship problem at all. Most apps already have a separate field for exactly this, an assignee. Author answers "who made this exist." Assignee answers "who is this for." If you want to hand someone a task, you assign it to them, you don't need to lie about who wrote it. So locking authorId to the session user costs nothing there. It was never the right field for that job anyway.

The import script is the genuinely different case, and it's the one that trips people up. Setting the original author as the assignee doesn't fix it, it just relabels the problem. Assignee means "for you, right now." A three year old task somebody actually wrote is not "for" them today, it's a historical fact being restored. Wrong shape of claim, even with good intentions behind it. And there's a deeper wrinkle: an import job usually isn't a logged in user at all, so "just use the session" doesn't even apply to it.

Which is why the real fix isn't one rule, it's two. Everyday users get authorId set from their session, full stop, no exceptions. A narrow, explicitly privileged path, an admin role or a properly authenticated import job, gets permission to specify authorId directly, and that permission is checked the same way every other permission in the app is checked, tied to who the caller provably is, not to a field the caller can just include in the request and hope nobody looks closely. A client sending an adminToken alongside its claim proves nothing. A server checking the caller's actual, already verified role proves everything. Same shape of mistake as the original bug, just wearing a different disguise.

The takeaway, if there is one

Having a privacy rule and enforcing a privacy rule are two completely different achievements, and only one of them counts. If the logic lives where the user can see it, you don't have a lock. You have a very polite suggestion, and eventually someone is going to test whether it's optional. The same lesson shows up one layer up the stack in multi-tenant SaaS isolation: a rule that only lives in application code isn't a boundary either.

Turns out it was.

Written by Akram

Technical Specialist

All Blogs