How to structure multi-tenant authorization in a Next.js SaaS
A practical architecture for workspaces, memberships, roles, billing entitlements, and tenant-scoped Convex queries without trusting the client.
Multi-tenant SaaS architecture is easy to underestimate. Add an
organizationId column, put a workspace switcher in the sidebar, and the app
looks multi-tenant. But the switcher is not the security boundary. The boundary
is every query, mutation, action, and webhook that can touch tenant-owned data.
A durable authorization model separates four questions:
| Question | What should answer it? | Example |
|---|---|---|
| Who is making the request? | Authentication and session validation | This is user usr_123 |
| Which tenant owns the data? | Workspace membership and data scoping | The record belongs to Acme |
| May this member take the action? | Roles and app permissions | An admin may invite members |
| Has the workspace unlocked the feature? | Billing capabilities | Acme may use team invitations |
Treat those as separate layers, then combine them into one server-side decision before touching data. That is the core pattern.
Model membership as a relationship
Do not put one permanent organizationId or role on the user and call the job
done. A user may own one workspace, administer another, and have read-only
access to a third. The role belongs to the membership, not the person.
The minimum useful model is:
users
id
activeOrganizationId
organizations
id
name
status
memberships
userId
organizationId
role
activeOrganizationId is a selection for the current session. It helps the UI
know which workspace to show, but it does not prove membership. The backend
must resolve the authenticated user, load the membership for the active
workspace, and reject the request if that relationship does not exist.
This model also keeps personal and team products on the same foundation. A personal account can have one implicit workspace today without forcing you to redesign every table when collaboration arrives later.
Put the tenant key on every tenant-owned record
Every document owned by a workspace needs an explicit tenant key. In the
Leave Localhost starter, the UI calls the tenant a workspace while the backend
uses organizationId to match Better Auth's organization model.
A tenant-owned Convex table can start this simply:
workspace_records: defineTable({
organizationId: v.string(),
title: v.string(),
status: v.union(
v.literal("open"),
v.literal("in_progress"),
v.literal("done"),
),
createdByUserId: v.id("users"),
}).index("by_organizationId", ["organizationId"]),
The important part is not only storing organizationId. It is making the
authorized organization the starting point of each query:
const actor = await requireAppPermission(ctx, {
permission: "workspace.records.read",
});
return await ctx.db
.query("workspace_records")
.withIndex("by_organizationId", (query) =>
query.eq("organizationId", actor.organizationId),
)
.collect();
Notice where the organization ID comes from: the resolved actor, not an untrusted value accepted from the browser.
Updates and deletes need the same discipline. Fetch the record, confirm its
organizationId matches the authorized workspace, and only then mutate it. If
the record belongs to another tenant, return the same not-found result you
would return for a missing record. That avoids turning IDs into a tenant-data
enumeration tool.
Centralize product permissions
Scattering checks such as role === "admin" across components and server
functions creates two problems. The rules drift, and nobody can answer a basic
question like “what may an admin actually do?” without searching the entire
codebase.
Use stable product permission keys instead:
await requireAppPermission(ctx, {
permission: "member.invite",
});
Then keep one policy catalog that maps each product action to its rules:
"member.invite": {
roles: ["owner", "admin"],
capabilities: ["workspace.members.invite"],
resourcePolicy: "memberLimitNotExceeded",
}
The server guard should resolve the actor and evaluate the entire rule before
returning a safe context such as organizationId, appUserId, role, and
capabilities. Once that guard succeeds, the function has what it needs without
reassembling authorization by hand.
Route guards still matter for a smooth experience, but they are not enough. The Next.js authentication guide describes Proxy checks as optimistic and recommends doing secure authorization close to the data source. Hiding a page or button is useful interface behavior; it does not protect the function behind it.
Keep roles separate from billing entitlements
Roles answer:
Who in this workspace may take this action?
Billing capabilities answer:
Has this workspace bought or been granted access to this feature?
Those are different questions. An owner may be allowed to manage billing but still belong to a free workspace that has not unlocked exports. A member may belong to a paid workspace but lack permission to change the plan.
Avoid coupling product authorization directly to provider state:
// Fragile: product logic now understands one billing provider.
if (subscription.provider === "stripe" && subscription.status === "active") {
// allow export
}
Instead, let verified billing events create provider-neutral capability grants:
Verified webhook
→ internal plan key
→ capability grants
→ app permission check
Your product can then ask whether the workspace has report.export or
workspace.members.invite. Stripe, Polar, Lemon Squeezy, a lifetime purchase,
or a manual support grant can all produce the same internal answer.
This is also where webhook idempotency matters. Providers retry events, and events may arrive late. Track event IDs and timestamps, ignore duplicates and stale updates, and make grant synchronization safe to run more than once.
Add resource policies for the rules roles cannot express
Role matrices are good at broad rules. They are not good at facts that depend on the current resource or workspace state.
Consider these actions:
- An admin can change roles, but cannot demote the workspace owner.
- An owner can remove members, but cannot remove the last owner.
- A paid workspace can invite members, but only until it reaches its plan limit.
- A workspace admin can edit settings, but not after the workspace has been suspended.
These are resource policies. Keep them named and testable, then attach them to the relevant permission in the same catalog. That prevents important business rules from becoming one-off conditionals hidden inside mutations.
Require fresh proof for sensitive actions
Authentication answers whether the session belongs to the user. It does not tell you whether a six-week-old session should be allowed to delete a workspace or change an owner's role.
High-risk actions deserve a step-up check after ordinary authorization:
- Verify the user, membership, role, capability, and resource policy.
- Check for a recent, purpose-specific verification grant.
- If it is missing, ask for an accepted method such as password, email code, or TOTP.
- Create a short-lived grant tied to the user, session, action, and workspace.
- Retry the original action and consume the grant when appropriate.
This makes verification proportional to risk. Reading a dashboard should not feel like deleting an organization, and deleting an organization should not be protected by the same stale session that reads the dashboard.
Let the client explain permissions, not enforce them
The client should know enough to present the right interface:
- Hide actions that are irrelevant to the member's role.
- Show an upgrade path when the workspace lacks a capability.
- Explain when a member limit or resource policy blocks an action.
- Ask for step-up verification only when the protected action requires it.
A route-level view model can return the permission snapshot alongside the data needed for the first render. That keeps navigation, buttons, and page content consistent without turning every component into its own permission subscription.
But every server entry point must still repeat the authoritative check. Treat the UI permission state as presentation data, never as proof.
A practical multi-tenant authorization checklist
Before calling the architecture complete, verify all of these:
- Membership is a relationship between a user and a workspace.
- The active workspace is validated on the server for every request.
- Every tenant-owned table includes an organization or workspace key.
- Tenant indexes make scoped queries the default path.
- Cross-tenant record IDs produce a not-found result.
- Product actions use named permission keys from one policy catalog.
- Every public server function performs its own authorization check.
- Roles and paid capabilities remain separate concepts.
- Billing webhooks map provider events to internal capability grants.
- Context-dependent rules live in named resource policies.
- Destructive actions require purpose-specific step-up verification.
- Tests cover users with different roles in different workspaces.
Where Convex helps
Convex does not design your authorization model for you. What it gives you is a tight place to enforce it: typed server functions, runtime validators, tenant-scoped indexes, transactional mutations, and reactive queries that update the interface after an authorized write.
The real benefit is that the rules live beside the data operations they protect. You do not need to keep an ORM model, an API layer, a client cache, and a separate real-time authorization path in agreement. There are fewer seams where a tenant check can disappear.
That is the standard a SaaS foundation should meet. Not “does it have a team switcher?” but “can every protected operation explain why this user may perform this action in this workspace, on this plan, right now?”
Leave Localhost ships that model as working code: Better Auth workspaces, tenant-scoped Convex data, a role and permission catalog, billing capability grants, resource policies, audit events, and sensitive-action verification. See what is included, review the buying questions, or get the starter when you would rather begin from the connected system than rebuild each layer separately.