Common Login Portal
Fixing IDOR properly
Ashish Bhagat · 13 Aug 2026
NeoSec, an external firm, ran a penetration test against Common Login Portal and came back with five real findings — three Critical, two High. Two of the Critical findings were IDOR-class: any authenticated user could delete or lock any other user's account, and any authenticated user could enumerate every user and organization in the system. A separate finding was a registration bypass using a fabricated Civil ID. None of this is a story about a system that was carelessly built. It's a story about what "authenticated" doesn't mean, and what fixing it properly looks like once you've found it.
Three critical, two high
CLP has 19 controllers and 83 REST endpoints, most of them operating on some specific resource named by an ID in the path or the body — a user, an organization, an account. Every one of those is a candidate for exactly this class of bug, because the pattern that produces an IDOR isn't a single mistake in a single place, it's a category of mistake that any endpoint touching a specific resource is equally capable of making, independently, no matter how carefully the endpoint next to it was written.
The IDOR findings shared a pattern that anyone who's read an OWASP top ten list will recognize instantly and anyone under deadline pressure can still ship anyway: an endpoint took a user ID or organization ID from the request, and acted on it, having confirmed only that the caller was logged in — not that the caller had any relationship to the specific ID being acted on. Delete this user. Lock this account. Both worked for any ID you handed them, as long as you were signed in as somebody. The enumeration finding was the same shape from the read side: nothing stopped a caller from walking the ID space and getting back every user and organization CLP knew about.
The Civil ID bypass was a different category — a fabricated identity number that shouldn't have passed the verification step in the registration flow but did.
Logged in isn't the same as authorized
Finding this kind of thing in a pentest isn't a signal that something was built badly. It's closer to a signal that a real, professional pentest was run — these are exactly the classes of bugs a serious external test exists to surface, and pretending a system that's never been tested this way is somehow cleaner is the wrong lesson to take from it. The finding that actually matters is what an IDOR represents structurally: an endpoint that checks who you are and stops there, never asking whether you're allowed to touch this specific thing. Authentication answers the first question. It says nothing about the second. Every one of these findings was an endpoint that had authentication and no object-level authorization behind it.
The fix, and why it's the boring part
There's a reason findings like this survive into a working, otherwise-functional system rather than getting caught earlier: the endpoint works correctly for the happy path every legitimate test exercises. A functional test signed in as the resource's actual owner never notices the missing check, because the check would have passed anyway. It takes someone deliberately trying an ID that isn't theirs to notice the check was never really there.
The fix for all five findings was the same shape, applied per endpoint: check that the authenticated caller actually owns or administers the resource named in the request, before doing anything to it. Not "are you logged in as an admin," but "are you the admin of this specific organization, or the owner of this specific account." It's not a clever fix. It's the fix everyone who's ever read about IDOR already knows. The interesting part isn't the fix — it's making sure it stays fixed.
It's also worth being precise about what "logged in as an admin" quietly assumes, because it's the exact assumption that produces this class of bug. In a single-tenant system, a role check and an ownership check can look interchangeable — if there's only one organization, "is an admin" and "is the admin of the thing you're touching" mean the same thing. CLP is not single-tenant. It has many organizations, each with its own users and its own admins, and a role check that only asks "is this caller an admin" says nothing about which organization they administer. That's precisely the gap an IDOR lives in: a check that's true in a demo with one tenant and false in exactly the way that matters once there's more than one.
The regression suite that replays the pentest
Each of the five findings came from NeoSec with a proof of concept — the exact request that demonstrated the vulnerability. Instead of treating those PoCs as a one-time checklist to clear before the fix shipped, they became a dedicated automated regression suite: every proof-of-concept attack, replayed against the codebase, on every build. If any future change reopens one of these five holes — a refactor that drops an authorization check, a new endpoint that copies an old pattern without the check attached — the build fails immediately, with the exact request that used to be exploitable now failing loudly instead of silently working again.
That's the part of this worth remembering longer than the specific bugs: a pentest report is a snapshot, and a fix without a test that enforces it is just a promise that today's code is correct. Wiring the actual attack, not a paraphrase of it, into the build is what turns "we fixed it" into something that stays true six months and forty pull requests later.
Where the check actually lives
CLP's backend separates every feature's writes and reads into their own command and query classes — a paired XxxCommand/XxxCommandImpl and XxxQuery/XxxQueryImpl for each one, sitting in the application layer above a domain that has no framework dependency of its own. Object-level authorization fits there naturally: the command or query for "delete this user" or "lock this account" is exactly the place that already knows both who's calling and which specific resource is being named, before anything reaches a repository or a database row. It's not a filter bolted onto a controller as an afterthought — it's a precondition the use case itself is responsible for, in the same layer that already orchestrates everything else about that operation.
What I'd do differently
Object-level authorization is the kind of check that's cheap to get right from the first endpoint and expensive to retrofit across nineteen controllers and eighty-three REST endpoints after the fact — which is exactly what happened here. If I were starting CLP over, I'd build the "does this caller own this resource" check into the same layer that already handles authentication, as a default every new endpoint has to opt out of rather than opt into, instead of something a pentest had to go find five separate examples of missing.
More from this series:
Hexagonal architecture you can verify with grep · Angular 18 to 21: 48 vulnerabilities down to 2