SecureStartKit
SecurityFeaturesPricingDocsBlogChangelog
Sign inBuy Now
Jun 1, 2026·Security·SecureStartKit Team

Supabase Storage Multi-Tenant RLS: 5 Leak Modes [2026]

Supabase Storage multi-tenant isolation: path-encoded RLS with tenant_id JWT claim, bucket-vs-path decision, and 5 cross-tenant leak modes.

Summarize with AI

On this page

  • Table of contents
  • Why does Supabase Storage need a separate tenant-isolation pattern?
  • Should you use one bucket per tenant or path-prefix isolation?
  • What is the path-encoded tenant RLS pattern?
  • What are the 5 cross-tenant leak modes in Supabase Storage?
  • Failure 1: Signed URL cross-tenant leak via long expiry
  • Failure 2: Bucket-policy vs path-policy choice trap
  • Failure 3: storage.foldername segment confusion
  • Failure 4: service_role admin client cross-tenant copy
  • Failure 5: RLS bypass via direct storage.objects SQL access
  • How do you wire tenant-scoped uploads through a Server Action?
  • How do you test the cross-tenant isolation directly?
  • Storage RLS is application security, not infrastructure

On this page

  • Table of contents
  • Why does Supabase Storage need a separate tenant-isolation pattern?
  • Should you use one bucket per tenant or path-prefix isolation?
  • What is the path-encoded tenant RLS pattern?
  • What are the 5 cross-tenant leak modes in Supabase Storage?
  • Failure 1: Signed URL cross-tenant leak via long expiry
  • Failure 2: Bucket-policy vs path-policy choice trap
  • Failure 3: storage.foldername segment confusion
  • Failure 4: service_role admin client cross-tenant copy
  • Failure 5: RLS bypass via direct storage.objects SQL access
  • How do you wire tenant-scoped uploads through a Server Action?
  • How do you test the cross-tenant isolation directly?
  • Storage RLS is application security, not infrastructure

Supabase Storage tenant isolation in a B2B SaaS comes down to one RLS pattern and one decision. The pattern is path-encoded: every object's key starts with the owning tenant's UUID, and an RLS policy on storage.objects checks (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control)::uuid = (auth.jwt() ->> 'tenant_id')::uuid. The decision is path-prefix isolation in a single bucket versus a separate bucket per tenant. For more than a handful of tenants, path-prefix wins on every operational dimension. Get the pattern wrong and a single missing policy turns the storage bucket into a cross-tenant data leak.

The Supabase multi-tenancy guide covers the column-based shared-schema model: a tenant_id column on every isolated table, the Custom Access Token Hook that injects tenant_id into the JWT, and RLS policies on regular tables that read the claim with auth.jwt() ->> 'tenant_id'. That guide deliberately treats storage in one passing sentence. The secure file uploads guide covers the user-scoped pattern ((storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control) = (select auth.uid())::text) and explicitly defers multi-tenant storage policies to a follow-up piece. This is that piece.

TL;DR:

  • Path-encode the tenant UUID as the first segment of every object key. The RLS policy checks (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control)::uuid = (auth.jwt() ->> 'tenant_id')::uuid. Supabase docs verbatim: storage.foldername(name) "Returns an array path, with all of the subfolders that a file belongs to" [2]. The first segment carries the tenant. Server-generate the key. Never let the client name it.
  • One bucket with path prefixes beats one bucket per tenant. Bucket-per-tenant means N policies to maintain, N entries in storage.buckets, and admin tooling that has to enumerate buckets. Path-prefix isolation: one bucket, one policy, scales linearly. Bucket-per-tenant only wins when per-tenant fileSizeLimit or allowedMimeTypes are non-negotiable [3].
  • service_role is the one client that ignores all of this. Verbatim from the Supabase docs: "Service keys entirely bypass RLS policies, granting you unrestricted access to all Storage APIs" [1]. A Server Action using the admin client must derive the tenant from the validated getClaims() call, never from the request payload. Otherwise the admin client is the cross-tenant primitive.
  • Signed URLs are bearer tokens with no tenant binding. createSignedUrl(path, 3600) returns a URL that anyone can fetch for an hour [4]. Issued under tenant A, leaked to tenant B, and tenant B reads tenant A's file with no auth check. Keep expirations short. Generate per-render, not per-upload.
  • Test the cross-tenant leak path directly. A passing local test means nothing if it never tried to read a sibling tenant's object. Write the negative case: log in as tenant B, fetch tenant A's path, assert 403. If your test suite doesn't do this, your RLS policy is hope, not security.

Table of contents

  • Why does Supabase Storage need a separate tenant-isolation pattern?
  • Should you use one bucket per tenant or path-prefix isolation?
  • What is the path-encoded tenant RLS pattern?
  • What are the 5 cross-tenant leak modes in Supabase Storage?
  • How do you wire tenant-scoped uploads through a Server Action?
  • How do you test the cross-tenant isolation directly?
  • Storage RLS is application security, not infrastructure

Why does Supabase Storage need a separate tenant-isolation pattern?

Supabase Storage sits on top of Postgres, and every object becomes a row in storage.objects. RLS on that table is the only thing standing between tenant A and tenant B's files. The pattern your normal tables use (a tenant_id column with tenant_id = (auth.jwt() ->> 'tenant_id')::uuid in the policy) does not transfer one-for-one, because storage.objects has no tenant_id column. The tenant has to live somewhere else: in the object's name.

The multi-tenancy guide establishes the architectural rule that every isolated resource carries the tenant ID and every policy reads the claim from the JWT, never from the request payload [7]. Storage extends that rule with one structural change: the tenant ID is encoded into the first path segment of the object's name, and the RLS policy reads it back with storage.foldername(). The mechanism is the same (JWT claim vs row attribute, checked in the database), but the row attribute is now derived from a string field.

This matters because the user-scoped storage pattern most tutorials show, (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control) = (select auth.uid())::text, looks similar but solves a different problem. User-scoped means "this user can read their own avatar." Tenant-scoped means "this user can read any file in their tenant, but never a file in another tenant." In B2B SaaS, users routinely share access to documents within an organization. The user-scoped pattern blocks the legitimate collaboration. The tenant-scoped pattern allows it without opening cross-tenant reads.

Should you use one bucket per tenant or path-prefix isolation?

Path-prefix isolation in a single bucket is the right default for nearly every B2B SaaS. Bucket-per-tenant adds operational cost that compounds with tenant count: one entry per tenant in storage.buckets, one set of policies per bucket, and admin tooling that has to enumerate buckets to do anything. Path-prefix means one bucket, one policy, one place to audit. The trade-offs:

DimensionPath-prefix (single bucket)Bucket-per-tenant
RLS policies to maintain4 (SELECT, INSERT, UPDATE, DELETE)4 × N tenants
Cross-tenant analyticsSingle SQL query over storage.objectsUNION across N buckets
Per-tenant file size or MIME limitsApplication-level (Server Action validation)Native via fileSizeLimit / allowedMimeTypes on bucket [3]
Per-tenant CDN cachingSingle cache scopePer-bucket cache scope
Per-tenant data residencySame Supabase project, same regionSame Supabase project (no per-bucket region)
Operational complexity at 10K tenantsConstantLinear

Bucket-per-tenant wins on exactly one architectural axis: you want native per-tenant fileSizeLimit or allowedMimeTypes enforced by the storage API itself [3] without writing application-side validation. That's a real win for some workflows, like a CMS where enterprise tenants get higher upload limits than starter tenants. It's not a win for the more common case where every tenant uses the same product surface with the same upload rules.

The decision rule: start with path-prefix. Move a specific class of files (large enterprise uploads, regulated PHI for one tenant) into its own bucket when a concrete per-tenant constraint requires it. Don't split eagerly. Every additional bucket is another policy you might forget to write.

What is the path-encoded tenant RLS pattern?

The pattern has four pieces: a tenant_id claim in the JWT (already established in the multi-tenancy pillar via the Custom Access Token Hook), a private bucket, an object key format that puts the tenant UUID first, and RLS policies on storage.objects that read the claim back. Here's the full setup.

First, confirm the JWT carries tenant_id. The Custom Access Token Hook injects it. Verbatim from the Supabase docs: "The custom access token hook runs before a token is issued and allows you to add additional claims based on the authentication method used" [7]. The hook's SQL form uses claims := jsonb_set(claims, '{tenant_id}', to_jsonb(tenant_uuid)) to add the claim. Once the claim is in the JWT, auth.jwt() ->> 'tenant_id' reads it from any RLS policy on any table, including storage.objects.

Second, create the bucket as private. Supabase docs verbatim on the public flag: "public: true, // default: false" [3]. Leave the default. Public buckets bypass RLS for reads entirely, which collapses the entire tenant-isolation argument.

-- Create the private tenant-scoped bucket
insert into storage.buckets (id, name, public)
values ('tenant-uploads', 'tenant-uploads', false);

Third, write the four RLS policies on storage.objects. The storage.foldername(name) function returns an array of path segments. Per the Supabase docs verbatim: "Returns an array path, with all of the subfolders that a file belongs to. For example, if your file is stored in public/subfolder/avatar.png it would return: [ 'public', 'subfolder' ]" [2]. The first segment is at index [\[1\]](https://supabase.com/docs/guides/storage/security/access-control), not [0], because Postgres arrays are 1-indexed.

-- SELECT: read any object whose first path segment matches your tenant
create policy "tenant_uploads_select"
on storage.objects for select
to authenticated
using (
  bucket_id = 'tenant-uploads'
  and (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control)::uuid
      = ((select auth.jwt()) ->> 'tenant_id')::uuid
);

-- INSERT: write objects only under your tenant's prefix
create policy "tenant_uploads_insert"
on storage.objects for insert
to authenticated
with check (
  bucket_id = 'tenant-uploads'
  and (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control)::uuid
      = ((select auth.jwt()) ->> 'tenant_id')::uuid
);

-- UPDATE: only objects in your tenant, and stays in your tenant
create policy "tenant_uploads_update"
on storage.objects for update
to authenticated
using (
  bucket_id = 'tenant-uploads'
  and (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control)::uuid
      = ((select auth.jwt()) ->> 'tenant_id')::uuid
)
with check (
  bucket_id = 'tenant-uploads'
  and (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control)::uuid
      = ((select auth.jwt()) ->> 'tenant_id')::uuid
);

-- DELETE: only objects in your tenant
create policy "tenant_uploads_delete"
on storage.objects for delete
to authenticated
using (
  bucket_id = 'tenant-uploads'
  and (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control)::uuid
      = ((select auth.jwt()) ->> 'tenant_id')::uuid
);

Four details that aren't obvious from the docs. The (select auth.jwt()) subquery wrapper triggers the same initPlan caching documented in the RLS performance debugging walkthrough: the planner evaluates the scalar subquery once per query, not once per row. Without it, listing 10,000 objects in a tenant's folder takes seconds instead of milliseconds.

The ::uuid cast on both sides matters for type safety and index usability. If you store the tenant as a string and the JWT carries it as a string, the policy works without the cast, but a text comparison cannot use a UUID-typed index. Cast both sides explicitly.

The UPDATE policy needs both USING and WITH CHECK. USING gates which rows can be selected for update. WITH CHECK gates what the updated row is allowed to look like. Without WITH CHECK, a tenant can rename an object's name field to start with another tenant's UUID and silently move the file across the boundary. This is the same pattern documented in the multi-tenancy guide's WITH CHECK section, applied to storage.

The object key format is non-negotiable. Every object must start with {tenant_uuid}/, then whatever sub-path your application wants. A common SecureStartKit-style convention is {tenant_uuid}/{user_uuid}/{document_uuid}.{ext}, which lets you scope further by user when needed without changing the policies. The first segment is what the RLS policy reads. The rest is application convention.

Building this from scratch on a new SaaS?

SecureStartKit ships every pattern in this post out of the box: backend-only data access, Zod on every Server Action, RLS deny-all, signed Stripe webhooks with idempotency dedup. One purchase, lifetime updates.

See what's included →Live demo

What are the 5 cross-tenant leak modes in Supabase Storage?

Five concrete failure modes show up across multi-tenant Supabase Storage audits. Each maps to a specific architectural mistake, and each has a one-line fix that lives in the policy or the upload code.

Failure 1: Signed URL cross-tenant leak via long expiry

Signed URLs bypass RLS for the lifetime of the URL. Per the Supabase docs verbatim: "You can sign a time-limited URL that you can share to your users by invoking the createSignedUrl" [4]. The example shown is .createSignedUrl('private-document.pdf', 3600), where the second argument is seconds. A URL issued for tenant-uploads/{tenant_a_uuid}/contract.pdf with expiresIn: 31536000 (one year) is a bearer token valid for one year that ignores tenant membership entirely. Leak it to tenant B (a forwarded email, a screenshot of the URL bar, a search-indexed page that embedded the URL), and tenant B reads tenant A's file with no further check.

The fix is mechanical. Cap signed URL lifetimes at minutes-to-hours, not days. Generate them per render, not per upload. Cache the object key ({tenant_uuid}/.../file.pdf) in your database, not the signed URL. Treat the URL as a bearer token in your threat model.

Failure 2: Bucket-policy vs path-policy choice trap

When you split into bucket-per-tenant, the RLS policies move to the bucket level. The trap: developers write a single bucket-level policy without re-checking the bucket_id claim against the JWT. The result is a policy like to authenticated using (true) for the tenant-a-uploads bucket, because the bucket itself is named after the tenant and the assumption is that the bucket name is enough. It isn't. A client with any valid Supabase session can issue an SDK call against tenant-b-uploads and the policy that says "authenticated users can read this bucket" lets them.

Even bucket-per-tenant needs the JWT-claim check. The cleanest pattern keeps the path-encoded check even when buckets are split: every policy reads bucket_id = 'tenant-uploads-{N}' AND validates the JWT claim. Otherwise the bucket name is a hint, not a security boundary.

Failure 3: storage.foldername segment confusion

The convention {tenant_uuid}/{user_uuid}/{file_uuid}.{ext} puts the tenant at [\[1\]](https://supabase.com/docs/guides/storage/security/access-control). A different convention, {user_uuid}/{tenant_uuid}/{file_uuid}.{ext} (or documents/{tenant_uuid}/{file_uuid}.{ext}), puts the tenant at [\[2\]](https://supabase.com/docs/guides/storage/schema/helper-functions). The Supabase docs example uses [\[1\]](https://supabase.com/docs/guides/storage/security/access-control) for the first segment because Postgres arrays are 1-indexed [2]. Pick one convention, write the RLS policies that match it, and never let the convention drift, because every drift is an off-by-one bug in the tenant-isolation check.

The risk is subtle and shipped routinely. A team starts with {tenant_uuid}/file.pdf, writes the policy with [\[1\]](https://supabase.com/docs/guides/storage/security/access-control), then refactors to add a documents/ namespace and forgets to update the policy. After the refactor, [\[1\]](https://supabase.com/docs/guides/storage/security/access-control) reads documents for every object, and (storage.foldername(name))[\[1\]](https://supabase.com/docs/guides/storage/security/access-control) = (auth.jwt() ->> 'tenant_id') is 'documents' = '{uuid}', which is always false. Every tenant's read silently fails. Or worse, the policy gets updated to [\[2\]](https://supabase.com/docs/guides/storage/schema/helper-functions) and a new code path forgets the namespace, so [\[2\]](https://supabase.com/docs/guides/storage/schema/helper-functions) reads the filename instead of the tenant UUID.

The fix is testing the path layout explicitly. The negative test is below, and it catches this class on every CI run.

Failure 4: service_role admin client cross-tenant copy

The service_role client is the one client that ignores all the storage RLS work. Per Supabase docs verbatim: "Service keys entirely bypass RLS policies, granting you unrestricted access to all Storage APIs" [1]. A Server Action that uses the admin client to perform a "copy this document to another folder" operation, but takes the destination path from formData, lets any authenticated user copy any file to any tenant's prefix. The RLS policies don't fire. The admin client doesn't care.

// VULNERABLE: trusts the client-supplied destination
export async function copyDocument(formData: FormData) {
  const admin = createAdminClient()
  const sourcePath = formData.get('sourcePath') as string
  const destPath = formData.get('destPath') as string  // attacker controls this

  await admin.storage.from('tenant-uploads').copy(sourcePath, destPath)
}

Two fixes layered together. First, derive the tenant from getClaims(), never from the payload. Second, construct the destination path server-side from the validated tenant UUID. The client can suggest a filename. The client cannot suggest a tenant prefix. The same architectural pattern from the backend-only data access guide applies: identity comes from the session, the path prefix comes from the validated identity, the request payload is opaque data that gets validated against a Zod schema for the parts that are not security-relevant.

// FIXED: tenant from JWT, destination prefix server-constructed
export async function copyDocument(formData: FormData) {
  const supabase = await createServerClientWithCookies()
  const { data: claimsData } = await supabase.auth.getClaims()
  const tenantId = claimsData?.claims?.tenant_id as string | undefined
  if (!tenantId) return { error: 'Not authenticated' }

  const sourceFilename = formData.get('sourceFilename') as string
  const destFilename = formData.get('destFilename') as string

  // Server constructs the prefix. The client cannot lie about which tenant.
  const sourcePath = `${tenantId}/${sourceFilename}`
  const destPath = `${tenantId}/${destFilename}`

  const admin = createAdminClient()
  await admin.storage.from('tenant-uploads').copy(sourcePath, destPath)
}

A related sub-failure: Supabase's ownership tracking does not save you here. The docs are explicit: "When using the service_key to create a resource, the owner will not be set and the resource will be owned by anyone" [5]. So owner_id checks in RLS won't catch admin-client mistakes either. The tenant prefix in the object name is the only ground truth.

Failure 5: RLS bypass via direct storage.objects SQL access

storage.objects is just a Postgres table. RLS on it works because Supabase's storage API queries it as the authenticated role. But any role that can SELECT directly on storage.objects and has been granted RLS-bypass (or runs as postgres / supabase_admin) sees everything. Database backups, replication slots, ad-hoc SQL editor queries with the admin role, and any custom function marked SECURITY DEFINER all bypass RLS.

This is not a Supabase bug; it's how Postgres RLS works. The implication for tenant isolation: anyone with database admin access has read of every tenant's storage paths. Treat the path itself as sensitive metadata in your threat model. If your filename convention includes user-PII-looking strings ({tenant_uuid}/quarterly-revenue-2026.xlsx), the filename is exposed to anyone with admin DB access. Two defenses: don't put sensitive metadata in filenames (use {tenant_uuid}/{document_uuid}.{ext} and store the human name in your application's documents table where regular RLS protects it), and audit which roles in your project have bypassrls or SECURITY DEFINER functions touching storage.objects.

This failure also applies to the cleanup of WITH CHECK policy bypass via stored procedures. A SECURITY DEFINER function that wraps a storage operation runs as the function's owner, not the calling user. If that owner is the admin role, every storage operation through the function bypasses RLS. Use SECURITY INVOKER (the default) for any function that touches storage on behalf of an authenticated user.

How do you wire tenant-scoped uploads through a Server Action?

The full upload path lives in a Server Action that validates the request, derives the tenant from getClaims(), constructs the object key server-side, and uploads via the admin client. The client never sees the destination path until the server returns it.

// actions/tenant-uploads.ts
'use server'

import { z } from 'zod'
import { randomUUID } from 'crypto'
import { createAdminClient, createServerClientWithCookies } from '@/lib/supabase/server'

const uploadSchema = z.object({
  contentType: z.enum(['image/png', 'image/jpeg', 'application/pdf']),
  byteLength: z.number().int().positive().max(10 * 1024 * 1024), // 10MB cap
})

async function requireTenantContext() {
  const supabase = await createServerClientWithCookies()
  const { data } = await supabase.auth.getClaims()
  const claims = data?.claims
  if (!claims || !claims.email) return null
  const tenantId = claims.tenant_id as string | undefined
  if (!tenantId) return null
  return { userId: claims.sub, tenantId }
}

export async function createTenantUpload(rawInput: unknown) {
  const parsed = uploadSchema.safeParse(rawInput)
  if (!parsed.success) return { error: parsed.error.flatten().fieldErrors }

  const ctx = await requireTenantContext()
  if (!ctx) return { error: 'Not authenticated' }

  // Server constructs the path. {tenant_uuid}/{user_uuid}/{document_uuid}.{ext}
  const ext = parsed.data.contentType.split('/')[\[1\]](https://supabase.com/docs/guides/storage/security/access-control) === 'jpeg' ? 'jpg' : parsed.data.contentType.split('/')[\[1\]](https://supabase.com/docs/guides/storage/security/access-control)
  const objectKey = `${ctx.tenantId}/${ctx.userId}/${randomUUID()}.${ext}`

  const admin = createAdminClient()
  const { data, error } = await admin.storage
    .from('tenant-uploads')
    .createSignedUploadUrl(objectKey)

  if (error) return { error: 'Could not create upload URL' }

  // Persist the path in your application's documents table.
  // The application table gets normal RLS; the storage row is keyed off the path.
  await admin.from('documents').insert({
    tenant_id: ctx.tenantId,
    uploaded_by: ctx.userId,
    object_key: objectKey,
    content_type: parsed.data.contentType,
    byte_length: parsed.data.byteLength,
    status: 'pending',
  })

  return { signedUrl: data.signedUrl, token: data.token, objectKey }
}

The application-level documents table carries the authorization metadata, with its own tenant_id column and standard RLS policies that match the JWT claim. The storage.objects row is the file. The documents row is the metadata. Authorization decisions ride on the application table, where they can layer role checks (only admins can delete), audit fields (who downloaded what when), and lifecycle state (pending / scanned / archived) without trying to pack them into the storage layer.

This separation also covers the case where the upload completes but the application row insert fails. The cleanup pattern is a periodic job that reads storage.objects for objects that have no corresponding documents row older than 10 minutes and deletes them. Inverse direction: a documents row whose status stayed pending for 24 hours indicates the client never finished the upload, and the row gets archived.

How do you test the cross-tenant isolation directly?

A passing test that only checks "tenant A can read its own file" is not enough. The negative test is what catches the off-by-one bug in storage.foldername, the missing WITH CHECK, and the bucket-policy that silently allows cross-bucket reads. Write the cross-tenant negative case explicitly:

// tests/storage-tenant-isolation.test.ts
import { test, expect } from 'vitest'
import { createClient } from '@supabase/supabase-js'

const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL!
const SUPABASE_ANON_KEY = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!

test('tenant B cannot read tenant A objects', async () => {
  // Login as tenant A user, upload an object.
  const tenantA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
  await tenantA.auth.signInWithPassword({ email: 'a@example.test', password: 'pw' })
  const { data: aClaims } = await tenantA.auth.getClaims()
  const tenantAId = aClaims?.claims?.tenant_id

  const path = `${tenantAId}/test-${Date.now()}.txt`
  await tenantA.storage.from('tenant-uploads').upload(path, new Blob(['secret']))

  // Switch identity: login as tenant B user.
  const tenantB = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
  await tenantB.auth.signInWithPassword({ email: 'b@example.test', password: 'pw' })

  // Attempt to read tenant A's path directly.
  const { data, error } = await tenantB.storage
    .from('tenant-uploads')
    .download(path)

  // The download should fail. Anything else is a cross-tenant leak.
  expect(data).toBeNull()
  expect(error).toBeTruthy()
})

test('tenant B cannot write into tenant A prefix', async () => {
  const tenantA = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
  await tenantA.auth.signInWithPassword({ email: 'a@example.test', password: 'pw' })
  const { data: aClaims } = await tenantA.auth.getClaims()
  const tenantAId = aClaims?.claims?.tenant_id

  const tenantB = createClient(SUPABASE_URL, SUPABASE_ANON_KEY)
  await tenantB.auth.signInWithPassword({ email: 'b@example.test', password: 'pw' })

  // Tenant B tries to write under tenant A's prefix.
  const evilPath = `${tenantAId}/planted-${Date.now()}.txt`
  const { error } = await tenantB.storage
    .from('tenant-uploads')
    .upload(evilPath, new Blob(['planted']))

  expect(error).toBeTruthy()
})

Two tests, both negative cases, both targeting the exact failure modes 2 and 3. CI runs them on every PR. A green test means the RLS policy is doing what the architecture says it does. A red test means a real cross-tenant leak that would have shipped silently. The pattern extends naturally to the UPDATE failure (tenant B renames their object to start with tenant A's UUID, expect 403) and the signed-URL failure (issue a 1-second signed URL, wait 5 seconds, expect 403 on fetch).

The full pattern integrates with the pre-launch security audit as a stop-ship check: if the tenant-isolation tests don't exist in CI for a B2B SaaS, the launch is blocked until they do.

Storage RLS is application security, not infrastructure

The architectural rule the multi-tenancy pillar establishes for tables extends one-for-one to storage: the tenant ID is a claim in the validated JWT, the policy reads it from auth.jwt() ->> 'tenant_id', and every code path that touches the data plane derives the tenant from the session, never from the request payload. Storage just needs the additional layer of encoding the tenant into the object path because storage.objects has no schema flexibility.

The five failure modes above all reduce to one structural mistake: trusting something other than the JWT claim to determine which tenant owns an object. Long-lived signed URLs replace the JWT with a bearer token. Bucket names replace the JWT with a string. Client-supplied paths replace the JWT with an attacker's input. service_role from a Server Action with payload-derived paths replaces the JWT with the request body. Every fix is the same shape: derive the tenant from the session, construct paths server-side, write RLS that reads the claim, and test the negative case in CI.

This is why SecureStartKit treats storage as application security and not infrastructure. The path layout, the RLS policies, and the Server Action upload flow ship together as one pattern, validated by a CI test that the cross-tenant read fails. The bucket setup is 4 lines of SQL. The policies are 4 more. The Server Action is 30 lines. The tests are the load-bearing piece, because they're what convert "the architecture should isolate tenants" into "the architecture isolates tenants on every PR."

Built for developers who care about security

SecureStartKit ships with these patterns out of the box.

Backend-only data access, Zod validation on every input, RLS enabled, Stripe webhooks verified. One purchase, lifetime updates.

View PricingSee the template in action

References

  1. Storage Access Control, Supabase Docs— supabase.com
  2. Storage Helper Functions, Supabase Docs— supabase.com
  3. Creating Buckets, Supabase Docs— supabase.com
  4. Serving Downloads (Signed URLs), Supabase Docs— supabase.com
  5. Storage Object Ownership, Supabase Docs— supabase.com
  6. Standard Uploads (upsert behavior), Supabase Docs— supabase.com
  7. Custom Access Token Hook, Supabase Docs— supabase.com

Related Posts

May 17, 2026·Security

Supabase Multi-Tenancy + RBAC: The Secure Pattern [2026]

Multi-tenancy and RBAC in Supabase + Next.js. Tenant scoping via JWT claims + RLS, the composite index rule, and five cross-tenant leak modes.

May 16, 2026·Security

OWASP Top 10:2025 for Next.js + Supabase Apps

OWASP Top 10:2025 mapped to Next.js + Supabase failure modes plus the architectural defenses that prevent each category. With 2026 CVEs.

Jun 2, 2026·Security

Supabase MFA Recovery: 5 Lost-Device Failure Modes [2026]

Supabase MFA recovery without recovery codes: backup TOTP factor, audited service_role reset, AAL-preserving rebinding, and 5 lost-device failure modes.