4.6 · Auth and Multi-Tenancy

4.6 · 30 min

Auth and Multi-Tenancy

An agent builds you a settings page. It reads the current user in a client component, checks if (user.role !== "admin") return null, and hides the member-management UI from everyone else. You click through it as an admin, as a member, as a client; the right things show and hide. It looks completely done. It is also completely unprotected, because the check runs in the browser, which is to say on the attacker's computer, and the data behind it is one fetch to your API away from anyone with the developer console open. This is the single most common way agent-built SaaS ships a hole, and this lesson is about building auth so that the hole cannot exist, then proving it with a test that opens the console for you.

Where a session can be trusted

Supabase's SSR guidance for the App Router is precise about one thing, and the whole security model hangs on it: the server gets the session from cookies, and cookies can be spoofed, so server code must verify the token rather than believe it. The mechanism is supabase.auth.getClaims(), which validates the JWT's signature against the project's published keys every time. Its two siblings are traps if misused: getSession() reads the token from storage without re-validating it, so its user object must never drive an authorization decision in server code; getUser() makes a network call for a fresh record when you need one. The rule to memorize: protect pages and route handlers with getClaims(); never authorize off getSession().

The plumbing is three small client factories plus a proxy that refreshes tokens, straight from the current Supabase Next.js guide. The install is @supabase/supabase-js and @supabase/ssr, and the env vars are the project URL and the publishable key:

briefcase/lib/supabase/server.tsts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

// Server Component / Route Handler client. Reads cookies for the
// session; the proxy is what refreshes them.
export async function createClient() {
const cookieStore = await cookies()
return createServerClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
  {
    cookies: {
      getAll() {
        return cookieStore.getAll()
      },
      setAll(cookiesToSet) {
        try {
          cookiesToSet.forEach(({ name, value, options }) =>
            cookieStore.set(name, value, options))
        } catch {
          // Called from a Server Component: the proxy handles the
          // refresh, so this can be safely ignored.
        }
      },
    },
  },
)
}

The proxy (Next.js middleware) calls getClaims() on every matched request to keep tokens fresh and hand them to server components; the Supabase guide's matcher excludes static assets. That is refresh plumbing, not your authorization layer. The authorization layer is next, and it is the part you design.

Membership as one layer

The failure that scatters auth across a codebase is treating every page as its own security question. Twenty pages, twenty slightly different role checks, and the nineteenth is where the bug lives. The fix is one function that answers "who is this user, in this org, with what role, and is this org's subscription live", called everywhere, defined once. Because RLS (4.5) already enforces data isolation at the database, this app-layer function is not the security boundary; it is the layer that decides what to render and which actions to offer, backed by the boundary underneath. Defense in depth: the app layer is the polite front door, RLS is the wall.

briefcase/lib/auth.tsts
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'

export type OrgContext = {
userId: string
orgId: string
role: 'owner' | 'admin' | 'member' | 'client'
billingActive: boolean
}

// The single authorization entry point. Verifies identity with
// getClaims (never getSession), loads membership under RLS, and
// redirects unauthenticated or non-member users. Every protected
// page and route handler calls this.
export async function requireOrg(orgId: string): Promise<OrgContext> {
const supabase = await createClient()

const { data: claims } = await supabase.auth.getClaims()
const userId = claims?.claims.sub
if (!userId) redirect('/login')

// This read is itself RLS-protected: a non-member gets no row.
const { data: membership } = await supabase
  .from('org_members')
  .select('role')
  .eq('org_id', orgId)
  .single()
if (!membership) redirect('/dashboard')

const { data: sub } = await supabase
  .from('subscriptions')
  .select('status, current_period_end')
  .eq('org_id', orgId)
  .single()
const billingActive =
  sub?.status === 'active' || sub?.status === 'trialing'

return {
  userId,
  orgId,
  role: membership.role,
  billingActive,
}
}

// Guard for staff-only actions. Throwing (not hiding) is the point.
export function assertStaff(ctx: OrgContext) {
if (ctx.role === 'client') {
  throw new Response('Forbidden', { status: 403 })
}
}

Two properties make this the layer instead of a helper. It verifies with getClaims, so the identity it returns is server-confirmed, not cookie-asserted. And the membership read runs under RLS, so even this function cannot be tricked into returning a membership for an org the user does not belong to: the database returns no row, and the function redirects. The app check and the database wall agree because they are the same fact enforced twice.

A protected page becomes three honest lines:

briefcase/app/settings/members/page.tsxts
import { requireOrg, assertStaff } from '@/lib/auth'

export default async function MembersPage(
{ params }: { params: Promise<{ orgId: string }> },
) {
const { orgId } = await params
const ctx = await requireOrg(orgId)   // redirects if not a member
assertStaff(ctx)                       // 403 if a client
// ... render member management, safe because the checks are
// server-side and the data reads are RLS-bounded anyway.
}

The attack, walked

Here is the client-side-only version an agent writes when you under-specify, and exactly how it falls. Suppose the member list is fetched from GET /api/orgs/[orgId]/members, and the route handler looks like this:

the vulnerable version (do not ship)ts
// BAD: no server-side identity check. "Protection" is the client
// component that decides whether to call this route.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ orgId: string }> },
) {
const { orgId } = await params
const supabase = await createClient()
const { data } = await supabase
  .from('org_members')
  .select('user_id, role')
  .eq('org_id', orgId)
return Response.json(data)
}

The developer believes this is safe because the members page hides its link from clients. The attack needs no tools beyond the address bar and a session: a client user, logged in, requests /api/orgs/<their-own-org>/members directly. Now, does this leak? It depends entirely on whether RLS is doing its job, which is the whole reason 4.5 came first. With the members_select policy from 4.5 (a user reads member rows only of orgs they belong to), the client sees the member list of their own org, which the product may or may not intend, but crucially sees nothing of any other org. Without RLS, or with a route using the service-role key, this returns every membership row it is asked for, across all tenants. The lesson inside the lesson: client-side-only checks are not a leak by themselves; they are a leak amplifier that turns any missing database boundary into a full breach. The defense is both layers: server-verified identity in the handler, RLS underneath.

The correct handler asserts identity and role server-side, so the address-bar request from a client is refused before RLS is even consulted:

briefcase/app/api/orgs/[orgId]/members/route.tsts
import { requireOrg, assertStaff } from '@/lib/auth'

export async function GET(
_req: Request,
{ params }: { params: Promise<{ orgId: string }> },
) {
const { orgId } = await params
const ctx = await requireOrg(orgId)  // 401/redirect if not a member
assertStaff(ctx)                      // 403 if a client
const supabase = await createClient()
const { data } = await supabase
  .from('org_members')
  .select('user_id, role')
  .eq('org_id', orgId)
return Response.json(data)
}

The test that catches it

The gate for this dispatch is an unauthorized-access suite: for every protected route, a request with the wrong (or no) identity must be refused, asserted as a test, not clicked through as a human. This is 3.8's threat model as a merge blocker. The shape, using the framework you wired in 4.9 (here expressed as intent so it reads cleanly):

briefcase/tests/auth/unauthorized-access.test.tsts
import { test, expect } from '@playwright/test'

// Each protected surface gets a negative test. These are the tests
// that would have caught the client-side-only handler: they never
// open the UI, they hit the route the way an attacker does.

test('anonymous request to a protected page redirects to login', async ({
request,
}) => {
const res = await request.get('/settings/members', {
  maxRedirects: 0,
})
expect(res.status()).toBe(307) // Next redirect to /login
})

test('client role is forbidden from the members API', async ({
browser,
}) => {
const ctx = await browser.newContext({
  storageState: 'tests/.auth/client.json', // a logged-in client
})
const res = await ctx.request.get('/api/orgs/ORG_A/members')
expect(res.status()).toBe(403) // assertStaff throws, not hides
await ctx.close()
})

test('member of org A cannot read org B members', async ({
browser,
}) => {
const ctx = await browser.newContext({
  storageState: 'tests/.auth/a-admin.json',
})
const res = await ctx.request.get('/api/orgs/ORG_B/members')
// requireOrg redirects (no membership row under RLS); the API
// returns no org-B data regardless.
expect([307, 403]).toContain(res.status())
await ctx.close()
})

The three tests map to the three ways this breaks: no identity, wrong role, wrong tenant. Each drives the route directly, the way the attacker does, never through the UI that hides the button. A suite that only clicks the UI as different roles tests your CSS, not your security.

The dispatch

PromptReference dispatch: auth + membership (lab 4-6)

DISPATCH: briefcase auth, membership, and protected routes

GOAL: Supabase auth works in the App Router with server-verified sessions; requireOrg is the single authorization layer; every protected page and route handler uses it; an unauthorized-access suite proves no-identity, wrong-role, and wrong-tenant requests are refused.

IN SCOPE: lib/supabase/ client.ts, server.ts, and the proxy (middleware) per the Supabase guide; lib/auth.ts (requireOrg, assertStaff); app/login and app/signup pages; the protected pages and route handlers named in TECH-SPEC, each calling requireOrg; tests/auth/unauthorized-access.test.ts; STATUS.md.

OUT OF SCOPE: Stripe / billing UI (4.7); the invite email delivery (stub the invite route, real email in 4.8); styling beyond functional; changing RLS policies (4.5 owns them).

DON'T TOUCH: supabase/migrations/* (schema is fixed; if auth needs a schema change, STOP and raise it); the env hook; docs/*.

VERIFICATION:

  1. npm run typecheck && npm run lint && npm run build green.
  2. Every server-side check uses getClaims, never getSession for authorization. I will grep for getSession and read each hit.
  3. The unauthorized-access suite is green AND contains the three negative tests (no identity, client role, wrong tenant), each hitting a route directly, not the UI.
  4. I will manually hit a protected API route in the browser as a client user and confirm a 403 / redirect, not data.

Read STATUS.md, TECH-SPEC (roles, routes), and lib/auth.ts intent first. If a page needs an authorization rule not in the permissions matrix, STOP and report it.

Where it breaks

The defining failure is the one in the cold open: authorization that lives only in the client. The tell is a role check inside a .tsx component that renders UI, with no matching check in the route handler or server component that serves the data. The countermeasure is structural and already built: requireOrg/assertStaff in the handler, and the negative test that hits the handler directly. If a protected route has no negative test, treat it as unprotected until proven otherwise.

The second is authorizing off getSession(). It is an easy mistake because getSession() returns a user object that looks authoritative, and in a client component it is fine for display. In server code making an access decision, it is a spoofable input. Grep for getSession in any file that decides access, and replace with getClaims.

The third is the layer that quietly becomes many layers again: a new page copies a role check inline "just this once" instead of calling requireOrg, and now the authorization logic has two homes that will drift. The rule is that no page or handler makes an authorization decision except through the one layer. Enforce it in review: an inline role === outside lib/auth.ts is a finding.

Lablab-4-6Capstone dispatch: auth, membership, and the attack test

Goal: Wire Supabase auth, build the single authorization layer, and prove client-side-only auth cannot ship by writing the attack test first and watching it catch the vulnerable version.

Prereqs: the repo through 4.5 (schema + RLS live), Supabase local stack running, @supabase/supabase-js and @supabase/ssr available to install.

  1. Before the real dispatch, reproduce the vulnerability once, deliberately, so the test has something to catch. On a throwaway branch, have Claude build the members API route with NO server-side check (the BAD version in this lesson). Log in as a client user and hit /api/orgs/<your-org>/members from the browser address bar. Observe what returns: with 4.5's RLS in place you see your own org's members (not a cross-tenant leak, but more than a client should get via an unprotected route); note that without RLS this would be every tenant's data. Delete the branch.
  2. Write the unauthorized-access test FIRST, from this lesson, before the correct handler exists. Run it against the vulnerable version if you still have it; watch the client-role and no-identity tests fail (the route returns data or 200). This is test-first security: the attack is the spec.
  3. Run the real dispatch in a fresh session. Review the plan: confirm it builds requireOrg as the single layer and routes every protected surface through it.
  4. Run VERIFICATION. The grep for getSession matters: every hit must be display-only in a client component, never an authorization decision. Run the negative suite; all three must pass now.
  5. Manually confirm one route in the browser as a client: 403 or redirect, not data.
  6. Close the loop: STATUS.md, CI green (the auth suite now runs in it), commit, /clear.

Verify

  • The three negative tests (no identity, client role, wrong tenant) exist and pass against the correct build, and at least the client-role test demonstrably failed against the vulnerable version you built in step 1-2.
  • No getSession() call anywhere makes an authorization decision (grep clean or every hit justified as display-only).
  • Every protected page and route handler calls requireOrg; there is no inline role === check outside lib/auth.ts.
  • Hitting a protected API as a client in the browser returns 403 or a redirect, not JSON data.
>Troubleshooting
  • getClaims() returns null for a user you know is logged in: the proxy (middleware) is not running on that route, so the token is not being refreshed. Check the middleware matcher includes the path; the Supabase guide's matcher excludes static assets but should cover app routes.
  • The wrong-tenant test is flaky between 307 and 403: that is fine and expected. requireOrg redirects a non-member (307) before role is even checked, so a wrong-tenant request never reaches assertStaff. Assert membership of the set, as the test does, or split into two routes with distinct expectations.
  • The negative test passes even against the vulnerable handler: you are testing through the UI, which hides the button, instead of hitting the route directly. Use the request fixture (or a raw fetch with the session cookie) so the test bypasses the client component exactly as an attacker would.

Knowledge check

Knowledge check

Q1An agent protects the admin settings page by checking user.role === 'admin' inside the React component and returning null for everyone else. Clicking through as each role shows the right thing. Why is this not protection?
Q2Why does this lesson forbid authorizing off supabase.auth.getSession() in server code, and require getClaims() instead?
Q3The unauthorized-access suite hits protected routes directly with wrong or missing identity. Another suite logs in as each role and clicks through the UI, confirming the right buttons show. Which is the security test, and why?
Q4requireOrg reads the user's membership row with a normal RLS-protected query. Why is it safe to build the app-layer authorization on a query that could, in principle, be wrong, and what makes the two layers agree?

Sources