Here’s the scenario: a mobile app ships a “report user” feature. The client sends a POST with a user ID, a reason code, and a session token. The backend checks that the token is valid, writes the report to a moderation table, and returns 200. Nobody asks the second question: valid for what? That gap — a token that proves who you are but not what you’re allowed to touch — is where we found the issue in Kompete’s early backend, and it’s the same gap we’ve now seen in three other client codebases during audits. It’s worth walking through in detail, because it doesn’t look like a bug when you’re writing it. It looks like a normal endpoint.

The setup: two clients, one API, one assumption

Kompete’s backend served two consumers: the player-facing Flutter app and an admin console (a separate web app) used by moderators to review flagged content, adjust match results, and manage user standing. Both talked to the same Node backend, same route handlers, same auth middleware. The middleware did one job well: verify the JWT, attach the decoded user to the request. It did not do a second job at all: check whether the attached user’s role permitted the action the route performed.

This is an easy trap because role-gating feels like it’s handled when you have auth middleware running on every route. It isn’t. Authentication answers “is this a real session.” Authorization answers “can this session do this specific thing.” Conflating the two means every route that doesn’t explicitly check a role is implicitly open to any authenticated user — including a regular player account.

Concretely, the admin console called endpoints like:

  • POST /matches/:id/override-result — force a match outcome, used to resolve disputes
  • PATCH /users/:id/standing — adjust a user’s competitive ranking or ban status
  • GET /reports/:id/evidence — pull uploaded screenshots tied to a moderation report

All three existed on the same route table as the mobile client’s endpoints, protected by the same “is this a valid token” check. There was no middleware layer asking whether the token belonged to a moderator. A player’s session token, extracted from their own device (trivial with a rooted phone or a proxy like mitmproxy pointed at the app’s traffic), could hit PATCH /users/:id/standing directly. The admin console’s UI never exposed this to a normal user — but the UI was never the security boundary. The API was, and the API didn’t know the difference.

Why this slips past normal testing

The reason this kind of gap survives QA is that functional testing is client-driven. You test the admin console by clicking through it as an admin, and you test the mobile app by using it as a player. Nobody’s test plan says “authenticate as a player, then manually construct a request to an admin-only route and see what happens.” That’s not a UI flow, it’s an adversarial one, and it only shows up under either a deliberate penetration pass or a code audit that reads route definitions against role requirements rather than clicking through screens.

It’s also the kind of thing that looks fine in code review if the reviewer is reading top to bottom for logic errors rather than asking “what’s the trust boundary here.” The handler for override-result was maybe 15 lines: parse the match ID, validate the payload shape, write the update, return the new match state. Clean, readable, well-typed. Wrong, because it answered “is this request well-formed” and never answered “is this requester allowed to do this.”

The fix: a policy layer, not a patch

The tempting fix is to sprinkle if (user.role !== 'admin') return res.status(403) at the top of each sensitive handler. We didn’t do that, for the same reason you don’t fix a null-pointer bug by adding a null check at every call site — it treats the symptom per-route and guarantees the next new route repeats the mistake, because the default state of “no check” is still silently permissive.

Instead we pulled authorization into a declarative layer that sits between the auth middleware and the handler: every route declares the permission it requires as route metadata, and a single piece of middleware resolves the authenticated user’s role against that declaration before the handler ever runs. In practice this looks like:

  • A route definition carries a requires: 'moderator' (or 'self', for routes where a user should only be able to act on their own resource) tag alongside its path and handler.
  • A single authorization middleware reads that tag, checks it against the decoded token’s role claim, and rejects with 403 before any handler logic executes — no handler can accidentally skip the check because the check isn’t inside the handler.
  • Routes with no explicit tag fail closed, not open — an unlabeled route is treated as admin-only until someone deliberately marks it otherwise. This inverts the default from “permissive unless coded otherwise” to “restricted unless coded otherwise,” which is the actual property you want.

The “self” case matters as much as the “moderator” case — a lot of these bugs aren’t “player accessing admin routes” but “player A accessing player B’s data” through an endpoint like GET /users/:id/matches that checks authentication but never checks that :id matches the token’s subject. Same root cause, same fix shape: the check belongs in a layer that runs before the handler, not in the handler’s discretion.

We also added a small internal audit script that walks the route table and flags any route missing a requires tag, run in CI. That’s the part that actually prevents recurrence — the policy layer only helps if something forces every new route through it.

What we look for now

This is the shape of thing we check for first on any code audit: not “is there authentication,” which almost every codebase has, but “is authorization enforced at a layer the handler can’t opt out of, and does it fail closed.” It’s a fast check — usually under an hour to walk a route table and cross-reference role requirements — and it’s caught real, exploitable gaps in more than one client’s backend since Kompete.

If you’re staring down a similar architecture — one API, multiple client surfaces with different privilege levels — and you’re not fully sure where that line is enforced, that’s the kind of question we like getting asked before it becomes an incident report. It’s a good chunk of what we do at orithLabs, alongside the mobile and backend build work.