Skip to main content
友田 陽大
Databases & RLS
Supabase
RLS
PostgreSQL
セキュリティ
Next.js

Protecting Supabase Storage with RLS: designing file access control with buckets, per-user folders, and signed URLs

An implementation guide that designs Supabase Storage access control with RLS policies on storage.objects. With official-compliant real code: no policy means no upload (default deny), the 'uid folder = only your files' pattern via bucket_id and storage.foldername(name), public/private buckets and signed URLs, defense in depth with file_size_limit/MIME restrictions, and safe upload from Next.js.

Published
Reading time
9 min read
Author
友田 陽大
Share

"I added an image-upload feature and user A could guess another person's file URL and download it," "a bucket I thought was private was actually public to the whole world" — once you start handling files with Supabase Storage, you need another layer of access-control design separate from the database's RLS.

The key fact is this: a Supabase Storage file is a "row" in a table called storage.objects. So access control is also done with row-level security (RLS) on storage.objects, just like a table. And Supabase official states plainly — "with no RLS policy, Storage doesn't permit any upload to a bucket" (Supabase: Storage Access Control). It starts from the same default deny as the database.

This article explains, official-compliant, how to implement Storage access control on the four points of bucket design, per-user folders, signed URLs, and defense in depth. The subject is the real-time match-recording app (handling player photos and match data, operating RLS on all tables). The content is faithful to Supabase official and PostgreSQL RLS official (as of June 2026).

Premise: the basics of RLS (USING/WITH CHECK, roles, default deny) are covered in RLS for beginners. This article is the application of that to Storage (storage.objects). API details of signed URLs and bucket settings can change, so confirm the latest in the official docs before production.


1. Mental model: a file is a row, a folder is virtual

First, nail down the correct view of Storage.

  • Bucket = a container for files. It has whether it's public or private.
  • Object = one file. Its substance is one row in the storage.objects table, with columns like bucket_id, name (path), and owner_id (the user who uploaded).
  • Folder = not a physical hierarchy but a virtual concept of slash-delimited parts within name (the avatars and <uid> of avatars/<uid>/photo.png).

Here's what matters. That a folder is "a string within the name column" means you can write folder-level access control in RLS by making the name path a predicate. Supabase provides helper functions for that.

HelperWhat it returnsExample (name = 'avatars/uid-123/a.png')
storage.foldername(name)An array of folder names text[]{'avatars','uid-123'}
storage.filename(name)The filename'a.png'

(storage.foldername(name))[1] gets the leading folder name — this is the heart of the next chapter's standard pattern.


2. The standard pattern: the leading folder = the user's uid

The most reusable Storage RLS design is "confine each user's files to a folder named with that user's uid." Make the path the convention <uid>/filename, and in RLS permit "only rows whose leading folder name matches your own uid."

-- アップロード:自分のuidフォルダにしか置けない
create policy "Users can upload to own folder"
on storage.objects for insert to authenticated
with check (
  bucket_id = 'user-files'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

-- 参照:自分のuidフォルダのファイルしか見えない
create policy "Users can read own folder"
on storage.objects for select to authenticated
using (
  bucket_id = 'user-files'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

-- 更新(上書き):自分のフォルダ内だけ
create policy "Users can update own folder"
on storage.objects for update to authenticated
using (
  bucket_id = 'user-files'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

-- 削除:自分のフォルダ内だけ
create policy "Users can delete own folder"
on storage.objects for delete to authenticated
using (
  bucket_id = 'user-files'
  and (storage.foldername(name))[1] = (select auth.jwt()->>'sub')
);

Three points.

  1. Always include the bucket_id match. Without it, the policy takes effect on (or fails to take effect on) files in other buckets too. Since a bucket is a "namespace," bind it in all policies.
  2. auth.jwt()->>'sub' is the user's uid. auth.uid() is synonymous. Wrapping with (select ...) is the performance optimization that prevents per-row re-evaluation.
  3. Narrowing by owner_id is also an option. To express "only the person who uploaded," you can write it with the ownership column instead of the path.
-- owner_id(アップロード者)で絞る別解
create policy "Individual user access"
on storage.objects for select to authenticated
using ( (select auth.jwt()->>'sub') = owner_id );

The foldername approach is "split per folder by path convention," and the owner_id approach is "split by who uploaded." Choose by whether sharing is needed (per folder) or purely individual ownership (owner_id).


3. Public and private buckets: don't mistake the range RLS applies to

This is the biggest misconception point. Files in a public bucket are readable by anyone who knows the URL, regardless of RLS.

Bucket typeRead (GET)Where to use
publicAnyone can read with the public URL (RLS doesn't apply to reads)Logos, public avatars, etc., needing no secrecy
privateAuth + RLS, or a signed URL is neededPersonal documents, paid content, confidential images

In other words —

You must not put confidential files in a "public bucket." A public bucket isn't "safe because the URL is hard to guess." The moment the URL leaks, anyone can access it. If secrecy is needed, always make it a private bucket.

Even for a public bucket, RLS applies to writes (INSERT/UPDATE/DELETE). A public avatar of "anyone can read, but only the person can write" is realized with a public bucket + write RLS.

Distribute private files safely: signed URLs

To show a private-bucket file to the browser, issue a time-limited signed URL. A signed URL is a temporary URL that "anyone can open this specific file, only for this duration," generated on the server side.

// Server Action / Route Handler で、本人確認の後に署名URLを発行する
const supabase = await createClient();
const {
  data: { user },
} = await supabase.auth.getUser();
if (!user) throw new Error("unauthenticated");

// 60秒だけ有効なURL。期限はユースケースに合わせ最小に
const { data, error } = await supabase.storage
  .from("user-files")
  .createSignedUrl(`${user.id}/report.pdf`, 60);
if (error) throw error;
// data.signedUrl をクライアントに返す

The key point of the design: make the signed URL's expiry minimal for the use (tens of seconds for a preview, a few minutes to a few hours for a download link distributed by email). Too long an expiry is the same as "effectively public." Also, before issuing a signed URL, confirm on the server side "may this user view this file" — issuing a signed URL can bypass authorization, so the issuing logic itself becomes the authorization boundary.


4. Defense in depth: protect what RLS doesn't look at (size, MIME) with another layer

RLS looks at the path and owner, but not the file's content, size, or true MIME type. Without hardening this in another layer, you can't prevent "putting a 10GB executable in your own folder" or "uploading a malicious file faking image/png."

Bucket-level restrictions

At bucket creation, you can set a size limit and allowed MIME types. This is the first gate.

// サーバー側(service_role)でバケットを作成し、制限を構造的に課す
await supabaseAdmin.storage.createBucket("user-files", {
  public: false,
  fileSizeLimit: "5MB",
  allowedMimeTypes: ["image/png", "image/jpeg", "image/webp"],
});

Server-side validation (don't trust Content-Type)

Bucket restrictions are convenient, but the Content-Type the client declares can be spoofed. To truly harden it, route uploads through a Server Action, validate the size and the actual byte content (magic number) on the server, and then pass it to storage.

// app/upload/actions.ts
"use server";
import { createClient } from "@/lib/supabase/server";
import { z } from "zod";

const MAX_BYTES = 5 * 1024 * 1024;
const ALLOWED = new Set(["image/png", "image/jpeg", "image/webp"]);

export async function uploadAvatar(formData: FormData) {
  const supabase = await createClient();
  const {
    data: { user },
  } = await supabase.auth.getUser();
  if (!user) throw new Error("unauthenticated");

  const file = z.instanceof(File).parse(formData.get("file"));
  if (file.size > MAX_BYTES) throw new Error("file too large");
  if (!ALLOWED.has(file.type)) throw new Error("unsupported type");

  // パスは必ず uid 始まり。client由来のファイル名はサニタイズして使う
  const safeName = file.name.replace(/[^a-zA-Z0-9._-]/g, "_");
  const path = `${user.id}/${crypto.randomUUID()}-${safeName}`;

  const { error } = await supabase.storage
    .from("user-files")
    .upload(path, file, { contentType: file.type, upsert: false });
  if (error) throw error;
}

You can see three boundaries overlapping — the app-layer Zod/size/MIME validation, the bucket restriction, and finally storage.objects's RLS ((storage.foldername(name))[1] = uid) guaranteeing "can't place in another person's folder" on the DB side. Even if one layer is breached, the next stops it. This is defense in depth.

Beware path traversal: assembling name directly from client input risks writing to another folder with ../. Always assemble the path on the server with ${user.id}/... prepended, and sanitize the filename. RLS's foldername[1] = uid is a further backstop, but don't break the principle of not trusting input.


5. Sharing patterns: team, public, time-limited

In a real app, "personal folders" alone aren't enough. Express representative sharing requirements in RLS.

-- チーム共有:teamsフォルダ配下は、そのチームのメンバーだけ読める
create policy "Team members can read team files"
on storage.objects for select to authenticated
using (
  bucket_id = 'team-files'
  and (storage.foldername(name))[1] = 'teams'
  and (storage.foldername(name))[2] in (
    select team_id::text from public.team_members
    where user_id = (select auth.uid())
  )
);

Make the path convention teams/<team_id>/... and judge whether the second folder name (team_id) is in "the set of teams you belong to." It's the same optimization form as rewriting a JOIN into a set reference. Don't forget to index team_members(user_id, team_id) either.

A "public but only the person can write" avatar is a public bucket + write RLS (owner_id or a uid folder). "Temporarily hand to an outsider" is a signed URL. Designing by splitting into the four types of ownership, sharing, public, and temporary keeps Storage authorization clear.


6. Verification: test that "outside your own folder is denied"

Like RLS, Storage policies become trustworthy in production only by testing not just "allow" but "deny." Since storage.objects is also an ordinary table, you can assert by impersonating an authenticated user.

-- pgTAP例:他人のuidフォルダのオブジェクトが見えないことを固定する
begin;
  select plan(1);
  set local role authenticated;
  set local request.jwt.claims = '{"sub":"11111111-1111-1111-1111-111111111111"}';
  -- 別ユーザー(2222...)のフォルダにある行が、このユーザーには0件であること
  select is(
    (select count(*) from storage.objects
     where bucket_id = 'user-files'
       and (storage.foldername(name))[1] = '22222222-2222-2222-2222-222222222222'
    )::int, 0,
    '他人のuidフォルダのファイルは見えない'
  );
  select * from finish();
rollback;

Follow the article on protecting RLS with pgTAP for CI-gating deny tests. In upload integration tests, always include one case that "upload to another person's folder fails."


Conclusion: Storage is "another RLS"

  • A Supabase Storage file is a row in storage.objects. Access control is RLS there, starting from the same default deny as the database.
  • The standard is "the leading folder = uid." With (storage.foldername(name))[1] = (select auth.jwt()->>'sub'), confine each user to their own folder. You can also make it owner-based with owner_id.
  • A public bucket is readable by anyone who knows the URL. Distribute confidential ones with a private bucket + a minimal-expiry signed URL.
  • For size, MIME, and content that RLS doesn't look at, defend in depth with bucket restrictions + server-side validation. Don't trust the client-derived filename and Content-Type.
  • Fix with a test that "another person's folder is denied," not just "allow."

Hardening even Storage with RLS lets you protect both data and files with the DB's structure, not "the app's goodwill." Next, the authorization of real-time communication — go to protecting Supabase Realtime with RLS.

Primary sources (always confirm the latest)

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading