← All articles

Proving Authorization Works: Executable Role and Scope Proof Suites

By Shuvo Haldar5 min read
SecurityAuthorizationTestingMulti-TenantNode.js
Proving Authorization Works: Executable Role and Scope Proof Suites

"Does a client ever see another client's invoices?" is not a question you want to answer with "I read the code and I'm pretty sure no." On Agency Portal — an operations SaaS I built where four roles (owner, manager, member, client) share one backend — a data leak between clients isn't a bug, it's a breach. So I stopped trusting my reading of the code and made authorization something I could prove by running it.

The mechanism is what I call executable proof suites: tests that boot the real server, sign in as a real actor, hit real HTTP routes, and assert against the actual database — including deliberately trying to cross boundaries they shouldn't be able to.

Animated diagram: Client A, signed in, sends a GET request for another client's invoice id; the request bounces off the scope boundary and a 404 Not Found stamp appears (not 403, to keep existence secret), while the target invoice still exists in the database unmutated.
The suite signs in as the actor who should be denied, fires the request at the wrong client's id, and asserts it bounces with a 404.

Reading code is not proof

Access control in the app is centralized: a session resolves to an actor with a scope, and every route filters its data through that scope. That's the right design. But a centralized can() layer only helps if every route actually goes through it, and "every route" is exactly the kind of claim that quietly becomes false as the app grows. One handler forgets to apply the scope filter, and now a client can enumerate another client's projects by changing an ID in the URL.

You cannot catch that by reading. You catch it by trying it and asserting it fails.

Prove the boundary by attacking it

The core move in every proof suite: sign in as an actor who should be denied, attempt the action, and assert on the denial. A test that only checks the happy path — "the owner can see everything" — proves nothing about isolation. The valuable test is the one that confirms the wrong actor is stopped.

// Client A must never reach Client B's invoice. Drive the real server and prove it.
test("client cannot read another client's invoice", async () => {
  const a = await signInAsClient(clientA);
  const res = await request(app)
    .get(`/api/invoices/${clientB_invoiceId}`)   // deliberately the wrong client's id
    .set("Cookie", a.sessionCookie);

  expect(res.status).toBe(404);                  // not 403 — don't even admit it exists
  const stillThere = await db.invoice.findById(clientB_invoiceId);
  expect(stillThere).not.toBeNull();             // and we didn't mutate anything trying
});

I return 404, not 403, on cross-tenant access on purpose — a 403 confirms the resource exists, which itself leaks information. The test encodes that decision, so if someone "helpfully" changes it to a 403 later, the suite tells them they just made the system chattier to an attacker.

What the suites actually cover

There's a proof suite per boundary, and each one drives the live server over real HTTP:

Coverage map of the proof suites for Agency Portal's four roles: role/scope, client-portal isolation and field redaction, task-view enforcement, onboarding idempotency against replayed Stripe webhooks, and billing MRR verified offline with the genuine Stripe signature check and console outbox adapters.
Each boundary — roles, portal isolation, task views, onboarding, billing — gets its own suite driving the live server.
  • Role/scope — each of the four roles can do exactly what it should and nothing more.
  • Client-portal isolation and field redaction — a client sees only their own data, and internal fields are stripped from the projection, not just hidden in the UI.
  • Task-view enforcement — a member sees their queue, not everyone's.
  • Onboarding idempotency — a replayed Stripe webhook never double-provisions an account.
  • Billing — the subscription lifecycle drives real MRR correctly.

The client-portal one matters most to me, because redaction is where teams cheat. It's easy to hide a field in the front end and leave it in the API response. The proof suite reads the raw HTTP body and asserts the sensitive field simply isn't there — which is the only version of redaction that's real.

Testing money and messaging without live accounts

A fair objection: how do you prove billing and notifications without hammering live Stripe and WhatsApp? You verify the real Stripe webhook signature locally — that's the actual verification code path, just without a network call — and route email and WhatsApp through console adapters that write to dev outbox files. So the entire money-and-messaging path is exercised and asserted offline, using the genuine verification logic, not a mock that pretends. A mock of your auth check proves your mock works. Driving the real check proves your system works.

Why I'd never ship multi-tenant auth without this

My strong opinion: for anything with data isolation between tenants or roles, authorization tests aren't optional and they aren't ordinary unit tests. They're the artifact that lets you tell a customer — or a security reviewer — "here is the suite that proves a client cannot reach another client's data, and it runs on every commit." That sentence is worth more than any amount of "we're careful." Careful isn't demonstrable. A passing proof suite is.

It also changes how you add features. A new endpoint isn't done when it works; it's done when its boundary has a suite that tries to break it and can't.

Takeaways

  • Reading code isn't proof. Drive the real server over real HTTP and assert against the real database.
  • Test the denied path — the wrong actor being stopped — not just the happy path.
  • Return 404, not 403, on cross-tenant access, and encode that in the test.
  • Prove redaction by reading the raw response body, not by checking the UI.
  • Exercise Stripe/webhook paths with real local signature verification so money flows are provable offline.

Authorization you can run and demonstrate is a different thing from authorization you believe. For multi-tenant systems, only the first one counts.


I build secure, multi-tenant SaaS platforms end-to-end. If you're working on something similar, let's talk.

Building something similar?

I'm a senior full-stack & DevOps engineer available for full-time roles and projects worldwide.