The billing endpoint is where IDOR stops being theoretical
Most teams understand IDOR in the abstract. They've read the OWASP page. They have a middleware. And yet, three out of four small SaaS we audit still leak invoices between tenants. Here's why that keeps happening.
The annoying thing about IDOR — really, BOLA, but I'll keep saying IDOR because the founder I'm explaining it to has heard that word before — is that the bug is almost never where the team expects it. They've put care into /users/:id. They've put care into /teams/:id. The bug is in /billing/invoice?team_id=…, which was written one Friday afternoon two years ago by the founder, and has not been touched since.
This post is about why billing routes specifically. And what to look at on Monday.
Why billing routes are weird
Most of your authenticated routes go through some version of:
// "current team" is resolved from the session, not the URL
const team = await req.session.currentTeam();
const data = await db.invoices.findMany({ where: { teamId: team.id } });
The query is bound to the session's team, so even if a user passes a different team_id in the URL, it gets ignored. Beautiful. Almost no IDOR is possible here.
Billing routes don't look like this, because billing routes were written before "current team" was a concept. They were written when "team" was just a JSON column on users. So they tend to look like this:
// /api/billing/invoice?team_id=42
app.get('/api/billing/invoice', authed, async (req, res) => {
const teamId = Number(req.query.team_id);
const invoice = await db.invoice.findFirst({ where: { teamId } });
res.json(invoice);
});
Spot the missing line. The route is authenticated — yes. The user is logged in — yes. But the query does not check that req.session.userId has any relationship with teamId. It just trusts the URL.
Change the 42 to a 43 and you get someone else's invoice. The middleware was happy because the user had a session. The database was happy because the row existed. Nobody checked the connection between the two.
Three patterns we keep finding
Across small-SaaS audits we've run over the last two years, the same three flavors of billing IDOR keep coming back.
1. The "team_id from query" classic
Exactly the example above. The fix is one line, and the line is in the wrong file — most teams add the check in the controller, when it belongs in a middleware that runs before any billing route.
// before any billing handler
app.use('/api/billing', authed, async (req, res, next) => {
const teamId = Number(req.query.team_id ?? req.body.team_id);
if (!teamId) return res.status(400).end();
const ok = await db.membership.findFirst({
where: { userId: req.session.userId, teamId },
});
if (!ok) return res.status(404).end();
req.team = { id: teamId };
next();
});
Notice the 404, not the 403. We argued about this with a client last quarter and lost; they wanted 403 for "auditability." Fine. But 404 leaks less. You decide.
2. The "invoice_id is a guessable integer"
This one is a corollary. The route doesn't accept team_id at all. It accepts invoice_id. The team thinks they've avoided IDOR because there's no team identifier in the URL. But invoice_id is a sequential integer, and the lookup is SELECT * FROM invoices WHERE id = ? with no tenancy check.
The fix is the same: every lookup of a tenanted object must include the tenant in the WHERE clause. Not in the application code afterward. In the query.
// good
db.invoice.findFirst({ where: { id, team: { members: { some: { userId } } } } });
The "after the query" version looks safe:
// bad — the row is already loaded; the leak already happened on the wire
const inv = await db.invoice.findFirst({ where: { id } });
if (inv.teamId !== req.team.id) return res.status(404).end();
Yes, the user gets a 404. But the row was fetched. In a different shape of the same bug — say, the response had logging middleware that emitted the invoice on the way out — you've already leaked it. Push the tenancy into the query.
3. The "admin pretends to be a user" leak
This one is more subtle and was the bug that paid for one of the more memorable audits we've done. The product had an internal admin tool that could "impersonate" a user. The impersonation handler did this:
// inside admin impersonation
req.session.userId = targetUserId;
req.session.currentTeamId = targetTeamId;
Looks fine. Except: when the admin stopped impersonating, the code reset userId but forgot currentTeamId. So an admin who impersonated a user in Team B, then "stopped," was now their own user (Team A) with Team B's team context. Every "current team" lookup happily returned Team B's data — including invoices.
This wasn't found by a scanner. It was found by sitting with the data model on day one (the same Monday-morning move described in the pillar post) and asking "what's the relationship between session and team here?" The answer was "two independent variables that we hope are consistent," which is never an answer that survives a real adversary.
What we actually do on the audit
For a small product, the auth-boundary part of the week is short and brutal. We:
- Create three accounts: two in different orgs (A1, B1), one a second member of org A (A2).
- Walk every authenticated route as A1, record every identifier in every query string, path, or body.
- Replay each request with B1's session and A1's identifiers. Anything that returns data rather than 403/404 is a finding.
- Then do the same with A2 — same org, different user. This is where role-based bugs hide, separate from cross-tenant ones.
- Then look at every place in the code where a row is fetched by ID without joining through the tenant table. Every one.
It's tedious. It works.
We instrument this with a small replay tool — captures requests as A1, swaps cookies, replays. Anything that comes back with a 200 and a payload is bubbled to the top. Builds itself in an afternoon; saves the week.
The thing the founder always asks
"How is the attacker supposed to know my invoice IDs are sequential?"
They don't have to. They start their own account, see their invoice ID is 4,712, and try 4,711 and 4,713. One of them will be someone else's. That's the whole "attack."
The reason we put this finding near the top of the report is that it's the one that ends up in a hacker news comment, a public X thread, or a regulator's letter if it goes wrong. Cross-tenant billing data is the kind of leak that makes customers churn before you finish writing the postmortem.
What to ship this week
If you've read this far and you want to move once, here's the smallest change with the largest impact:
- Find every route under
/billing,/invoice,/subscription,/payment,/usage,/export. - For each, confirm that the database query — not the post-query check, the query itself — includes the current user's membership in its
WHEREclause. - If not, add the membership join. One PR per route family. Each PR adds a test that replays the request with a stranger's session and asserts 404.
That's the whole fix. The reason audits cost what they do isn't that this is hard. It's that almost nobody does it.
Read next
- Your first security audit, if you ship a small web product — the pillar piece.
- Signed URLs aren't a fence — the IDOR sibling for file routes.
- The webhook URL field nobody validates — for when the bug is on the way out, not in.
Want us to look at your billing routes?
The free heads-up includes a focused pass on the auth boundary of your top three sensitive routes. If we find something, we'll write it up. If we don't, we'll say so.
Request a free check