Skip to content
AI-Assisted Development12 min read·

Multi-tenancy, billing and roles: the SaaS plumbing AI coding tools still get wrong

Claude Code and Cursor will scaffold a multi-tenant Next.js SaaS with Stripe and roles in an afternoon, and it will look right. The leaks are in the plumbing: tenant IDs read from the request, subscriptions that only know two states, role checks that live in the interface. The mistakes I find most often, why agents keep making them, and the harness that catches them before a customer does.

Claude CodeCursorNext.jsMulti-TenancyStripeAuthorization

There is a genre of post I read most weeks: someone shipped a SaaS in six days with Claude Code or Cursor. Most of them are honest. The app works, people signed up, and the demo is real. What the posts do not say, because nobody has found out yet, is what happens when the second customer opens a URL that belongs to the first.

I use coding agents on every SaaS build, and I have written about where the speedup is real. The place it is not real is the plumbing: multi-tenancy, billing and roles. Agents scaffold all three convincingly. The scaffolds compile, pass the tests generated with them, and leak. This is the list of how, with the checks that catch each one.

Why these three, and why agents miss them

Tenancy, billing and roles have something in common that most features do not: the rule does not live in any one file. A tenant boundary is a property of every query in the codebase. A subscription state is a property of every request. A role check has to exist on every entry point, including the ones nobody remembers are entry points. Agents work file by file, and that shape is exactly what they are worst at.

  • The invariant is out of view. The rule that every query filters by organisation lives in a helper the agent did not open while writing the new endpoint.
  • The training data is single-tenant. Most tutorials, and so most of what the model learned from, have one user, one database and no billing state.
  • Prompts describe the happy path. Ask for an invoice page and you get an invoice page. Nobody asked what a member of another company sees at the same URL.
  • Generated tests share the code's assumptions. A test written alongside the code checks what the code does, so it confirms the leak rather than catching it.
  • The agent copies the nearest pattern. One unscoped query in the codebase becomes the template for the next twenty.
AreaWhat the agent scaffoldsWhat leaksThe check that catches it
TenancyAn orgId column and a filter in most queriesThe queries without the filter, and everything that is not a requestA second-tenant test for every route and action
BillingStripe Checkout and an isPro flagAccess granted on the redirect, and six subscription states treated as oneEntitlements derived from verified events, with an exhaustive status map
RolesA role field and hidden buttonsEndpoints that trust the interface to hide themAuthorisation inside every server action, enforced by tests

Multi-tenancy: four ways the boundary leaks

1. The tenant comes from the request

The most common mistake, and the most damaging. The agent needs to know which organisation a new record belongs to, the form has the value, so it reads it from the form:

ts
// app/projects/actions.ts: generated, and it works in the demo
"use server";

export async function createProject(formData: FormData) {
  const session = await auth();
  if (!session) throw new Error("Unauthorized");

  return db.project.create({
    data: {
      name: String(formData.get("name")),
      orgId: String(formData.get("orgId")), // whichever org the request says
    },
  });
}

It checks that someone is logged in. It never checks that they belong to the organisation they named, so any user can create records inside any other customer's account by editing one field. The fix is to make the organisation server-side state and to check membership on every call:

ts
// lib/auth/membership.ts
export async function requireMembership(permission: Permission) {
  const session = await auth();
  if (!session) throw new AuthError(401);

  // The active org is server-side session state, never a form field or a query param.
  const membership = await db.membership.findUnique({
    where: { userId_orgId: { userId: session.userId, orgId: session.activeOrgId } },
  });
  if (!membership || !can(membership.role, permission)) throw new AuthError(403);

  return { userId: session.userId, orgId: membership.orgId, role: membership.role };
}

// app/projects/actions.ts
export async function createProject(formData: FormData) {
  const { userId, orgId } = await requireMembership("project:create");
  const name = z.string().trim().min(1).max(120).parse(formData.get("name"));

  return tenantDb(orgId).project.create({ data: { name, createdBy: userId } });
}

2. Lookups by ID alone

findUnique({ where: { id } }) is the most natural line an agent can write, and on a tenant-owned table it is a leak. IDs travel in URLs, emails and exports; a UUID is unguessable but it is not secret. Every lookup on tenant data needs the tenant in the where clause, which is why the example above goes through a tenantDb(orgId) accessor that adds it to every query rather than trusting each call site to remember. Laravel has the same trap in route model binding: /projects/{project} resolves any project in the database unless a global scope or a scoped binding says otherwise.

3. Uniqueness that ignores the tenant

Agents put @unique on a slug or an email because the prompt said they should be unique. They should be unique within an organisation. A global constraint means the second customer cannot create a project called Website, and the error message confirms to them that someone else already has one. Make it @@unique([orgId, slug]), and decide deliberately which identifiers really are global.

4. Everything that is not a request

Background jobs have no session, so the tenant has to travel with the job. Cache keys need the tenant in them or two customers share a dashboard. Search indexes need a tenant filter on every query. Uploaded files need tenant-prefixed paths and signed URLs, not a public bucket. Agents almost never get these right unprompted, because nothing in the request they were asked about mentions them. The isolation boundaries in a multi-tenant SaaS lists every one, and the Laravel version shows the fixes for queues, cache and logs.

sql
alter table projects enable row level security;
alter table projects force row level security;

create policy tenant_isolation on projects
  using (org_id = current_setting('app.current_org')::uuid)
  with check (org_id = current_setting('app.current_org')::uuid);

-- In the app, inside each transaction:
-- select set_config('app.current_org', $1, true);

Billing: four ways to give the product away

Day-one Stripe webhook mistakes are covered elsewhere: no signature check, no idempotency, trusting event order. Assume those are fixed. These are the ones that remain, and they are about what the app believes, not what Stripe sent.

  • Access granted on the success redirect. The generated flow sends the user to Checkout and sets isPro = true when they land on /success. Anyone can visit /success. Access should change only when a verified event says the subscription exists, and the success page should show a pending state until it does.
  • Two states for eight. Generated code checks status === "active". Stripe has eight subscription statuses, and treating them as active-or-not locks out paying customers in a trial and gives unpaid ones full access, depending on which way the code leans.
  • The customer belongs to a user. The agent creates the Stripe customer for whoever clicked Upgrade. When that person leaves the company, the subscription leaves with them. In a B2B product the customer is the organisation.
  • Plans checked by price ID. if (priceId === "price_1Q...") in fourteen files means a price change is a code search, and a grandfathered customer on last year's price silently loses features. Plans are not features: derive entitlements in one place and check those.
Stripe statusWhat it meansAccess I usually give
trialingIn a trialFull
activePaid and currentFull
past_dueA renewal failed and retries are runningFull, with a billing banner
incompleteFirst payment not yet confirmedNone yet; show pending
incomplete_expiredFirst payment never confirmed within 23 hoursNone
unpaidRetries exhausted, subscription kept openRead-only, export allowed
pausedTrial ended without a payment methodRead-only
canceledOverNone after the paid period ends

The right answers vary by product. What does not vary is that every status needs one. In TypeScript, make the compiler enforce it:

ts
// A Record over Stripe's status union fails the type check if a status is missing,
// including one Stripe adds in a future API version.
const ACCESS: Record<Stripe.Subscription.Status, Access> = {
  trialing: "full",
  active: "full",
  past_due: "full", // dunning is running; warn, do not lock
  incomplete: "none",
  incomplete_expired: "none",
  unpaid: "read_only",
  paused: "read_only",
  canceled: "none",
};

// Called from the webhook job for any customer.subscription.* event.
// It re-reads the subscription from Stripe rather than trusting the payload.
export async function syncEntitlements(subscriptionId: string) {
  const sub = await stripe.subscriptions.retrieve(subscriptionId);
  const orgId = sub.metadata.orgId; // set at checkout, from the server-side session

  await db.entitlement.upsert({
    where: { orgId },
    create: { orgId, access: ACCESS[sub.status], plan: planFor(sub), syncedAt: new Date() },
    update: { access: ACCESS[sub.status], plan: planFor(sub), syncedAt: new Date() },
  });
}

Roles: three places the check goes missing

1. The button is hidden; the endpoint is not

Agents implement roles where the prompt describes them, which is in the interface: members do not see the Delete button. The server action behind the button is still callable. In Next.js every server action is a public POST endpoint, whether or not any page renders the form that calls it, so every action needs its own authorisation check. Nothing about the page it came from protects it.

The same goes for checks done only in middleware. In March 2025, CVE-2025-29927 let attackers skip Next.js middleware entirely by sending a single internal header, and every app whose only authorisation lived there was open. Middleware, or proxy since Next.js 16, is a fine place to redirect logged-out users. It is not the place to decide who may delete an invoice.

2. Users who can edit their own role

db.user.update({ where: { id }, data: body }) is the shortest correct-looking profile update an agent can write, and it lets a member send {"role": "owner"} along with their new display name. The Laravel equivalent is $user->update($request->all()). Parse input against a schema that lists the fields a user may change, and treat role changes as their own action with their own permission.

3. The role is on the user

A role column on the users table works until someone belongs to two organisations, which in B2B happens with the first agency or consultant you sign up. Roles belong on the membership, as Day 15's architecture decisions argues. Close behind come the invitation bugs: invite tokens not bound to the invited email, invitations that survive the inviter being removed, and an owner who can remove themselves and leave an organisation nobody can administer.

The harness that catches them

None of these is fixed by a better prompt, because the next prompt will not mention them either. They are fixed by making the codebase refuse the mistake, which is harness engineering aimed at the three areas where a mistake costs most.

Flowchart: an agent adds a new endpoint
  1. The agent writes a new server action

  2. Does it import the raw database client?

    ↳ yes? lint fails: use tenantDb(orgId)

  3. Does it call requireMembership with a permission?

    ↳ no? the route registry test fails

  4. Can a member of another org reach its data?

    ↳ yes? the isolation suite fails, and the agent may not edit that suite

  5. Does the diff touch tenancy, billing or roles code?

    ↳ yes? code owners require a human review

  6. Merge

    The agent wrote the code; the harness checked the plumbing.

Each box is a check the agent cannot talk its way past, which is the only kind that works.

Start with the project rules, short enough that the agent actually follows them:

md
## Tenancy, billing and roles (non-negotiable)
- Never read orgId, role or plan from request input. Call requireMembership().
- Every query on tenant data goes through tenantDb(orgId). Raw client imports fail lint.
- Access comes from entitlements(orgId), never from a Stripe price ID or status.
- Every new route or server action gets a test in tests/isolation.
- Never edit tests in tests/isolation to make them pass. Stop and ask.

Then make the rules mechanical. Rules in a markdown file are guidance; lint is a wall:

js
// eslint.config.mjs
{
  files: ["src/app/**", "src/lib/**"],
  ignores: ["src/lib/db/tenant.ts"],
  rules: {
    "no-restricted-imports": ["error", {
      paths: [{ name: "@/lib/db/client", message: "Use tenantDb(orgId) so every query is scoped." }],
    }],
  },
}

And make the isolation suite cover routes automatically, so a new endpoint is tested the moment it exists rather than when somebody remembers:

ts
// tests/isolation/every-action.test.ts
for (const action of registeredActions()) {
  test(`${action.name} refuses another org's records`, async () => {
    const { orgA, orgB } = await seedTwoOrgs();
    const res = await action.callAs(orgB.member, action.fixtureFor(orgA));
    expect([403, 404]).toContain(res.status);
  });
}

For billing, replay real event sequences rather than single events. The Stripe CLI can trigger events against your local webhook, and test clocks let you run a subscription through a trial, a failed renewal and a cancellation in minutes. Assert the entitlement after each step. The negative tests for tenant isolation has the full list worth automating.

If you have already shipped

Most of this can be checked in an afternoon, and should be before the next customer signs up:

  1. 1Search for every place orgId, teamId or tenantId is read from a form, query string or request body. Each one is a candidate leak.
  2. 2Search for lookups by id alone on tenant tables, and for unique constraints that do not include the tenant.
  3. 3List every server action and API route, and check each one calls your authorisation helper. Do not trust that the page hides it.
  4. 4Grep for status === "active" and for hard-coded Stripe price IDs.
  5. 5Log in as a user in one organisation, copy a record URL, and open it as a user in another. Then try the same with the API.
  6. 6Check who the Stripe customer is: the organisation, or the person who happened to click Upgrade.
The agent is not careless about tenancy. It just has no idea tenancy exists until something in the codebase tells it, and a comment is not enough.

If you are building a SaaS with Claude Code or Cursor and want the plumbing right from the first commit, or you have shipped one and want to know what it is leaking, I build and review exactly this: the tenancy model, billing state and roles, with the harness that keeps an agent inside them. Scoped and priced up front. Get a fixed-scope build quote.

Questions people ask

Can you build a SaaS with Claude Code or Cursor?

Yes. Coding agents will scaffold a multi-tenant Next.js SaaS with Stripe and roles quickly, and the app will look right. The risk is in the plumbing: tenant IDs read from the request, lookups by ID alone, subscriptions treated as active or not, and role checks that only live in the interface. Pair the agent with project rules, lint, and isolation tests it cannot edit so the codebase refuses those mistakes.

Why do AI coding agents get multi-tenancy wrong?

Because a tenant boundary is a property of every query, and agents work file by file. The rule that every query filters by organisation sits in a helper the agent did not open, most training data is single-tenant, prompts describe only the happy path, generated tests share the code's assumptions, and one unscoped query becomes the template for the next twenty. Background jobs, cache keys, search indexes and file uploads are missed most often.

How should a SaaS handle Stripe subscription statuses?

Give every Stripe status an explicit access level. A common mapping is full access for trialing, active and past_due with a billing banner, none for incomplete, incomplete_expired and canceled after the paid period, and read-only for unpaid and paused. Derive entitlements in one place from verified events, make the Stripe customer the organisation rather than a user, and avoid checking hard-coded price IDs across the codebase.

How do I check an AI-built SaaS for tenant leaks?

Most of it takes an afternoon. Search for orgId, teamId or tenantId read from forms, query strings or request bodies. Find lookups by ID alone and unique constraints without the tenant. Confirm every server action and API route calls your authorisation helper. Grep for status === active checks and hard-coded price IDs. Then open a record URL from one organisation as a user in another. The multi-tenant negative tests cover the full list.

Building something like this?

I design and ship these systems for clients: retrieval over private data, agents that complete real tasks, and the Laravel platforms underneath them.

Keep reading