"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
publicorprivate. - Object = one file. Its substance is one row in the
storage.objectstable, with columns likebucket_id,name(path), andowner_id(the user who uploaded). - Folder = not a physical hierarchy but a virtual concept of slash-delimited parts within
name(theavatarsand<uid>ofavatars/<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.
| Helper | What it returns | Example (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.
The vulnerabilities in this article — is your app safe from them?
An expert audit of your Next.js × Supabase authorization & RLS
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.
- Always include the
bucket_idmatch. 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. 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.- Narrowing by
owner_idis 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 type | Read (GET) | Where to use |
|---|---|---|
| public | Anyone can read with the public URL (RLS doesn't apply to reads) | Logos, public avatars, etc., needing no secrecy |
| private | Auth + RLS, or a signed URL is needed | Personal 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
namedirectly 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'sfoldername[1] = uidis 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."
7. Upload holes that RLS alone doesn't close
RLS decides "who may perform which operation on which path" — and nothing more. What goes into a file name, what the content really is, how it is served, and how long a URL stays usable all sit outside RLS, and files can leak there even with flawless policies. Before real data goes in, check these five points one by one.
7-1. Don't let users choose object names (overwrites and swaps)
If a user-chosen name becomes the path, "replacing someone else's file" can happen entirely within RLS. The typical case is the shared team folder from section 5. According to Supabase (Storage Access Control), uploading needs only INSERT, but allowing overwrites through upsert additionally requires SELECT and UPDATE. Open UPDATE on a team folder and any member can overwrite the same path — for example, replacing the contract PDF a colleague uploaded with a different file of the same name.
The default behavior is the safe one: uploading to a path that already exists fails with 400 Asset Already Exists (Standard Uploads). Keep it that way.
- Generate the last path segment on the server (the
crypto.randomUUID()from section 4). Store the user's original file name in the DB for display, and keep it out of the path (OWASP ASVS 5.0 requirement 5.3.2 asks for internally generated values rather than user-submitted names in file paths). - Don't use
upsert: true. If you need a replace feature, restrict theUPDATEpolicy toowner_id = (select auth.uid()), even in shared folders. - If user input must become part of a path, reject values containing
/or... Supabase's file-name restriction is a list of allowed characters and includes., so validating which folder a name points to is the application's job.
7-2. Content-Type is what the client claims — don't serve HTML/SVG from your own origin
A bucket's allowed_mime_types is useful, but it is checked against the type the uploader declared. The SDK's contentType option is "the Content-Type header value," and in Supabase Storage's server code (uploader.ts in supabase/storage) the allowed-MIME check runs against the request's Content-Type (for multipart, the form's contentType field or the part's type). It does not look at the bytes. HTML declared as image/png can pass.
Where this really bites is serving. If user-uploaded HTML or SVG is displayed in the browser from the same origin as your app (a proxy through a Route Handler, a custom domain on the same site, and so on), the script inside the file runs with your site's privileges (stored XSS). Three countermeasures:
- Leave
text/htmlandimage/svg+xmlout of the allowed MIME types. Validate images by magic number as in section 4, and re-encode them if needed (ASVS 5.2.2). - Return files that don't need to be displayed as downloads. Adding the
?downloadquery to a Supabase public URL makes the browser download the asset instead of rendering it (Serving assets). If you serve files yourself, setContent-Disposition: attachment(ASVS 3.2.1). - Don't serve user files from your app's origin (ASVS 3.5.4 asks for separate applications on different hostnames so the same-origin policy works in your favor).
7-3. Public buckets sit outside RLS — and watch for policies that allow listing
As section 3 said, a public bucket bypasses access control for retrieving and serving files: anyone with the URL can read them (Storage Buckets). RLS still applies only to uploading, deleting, moving, and copying. Two things are easy to miss:
- Switching a bucket from private to public exposes everything in it, existing files included. The docs say all files uploaded to a public bucket are publicly accessible. Putting ID documents into a bucket created for profile pictures is broken from the start — use separate buckets per purpose.
- Even in a private bucket, a broad
SELECTpolicy allows listing. The official policy examples warn that an "anyone can read avatars" policy without thestorage.allow_any_operation()filter lets users list the bucket's contents. If file names contain customer or project names, the listing alone leaks information.
7-4. A signed URL is a key that works for whoever holds it
Until it expires, a signed URL opens the file for anyone who has the URL. It checks neither login state nor identity. A URL forwarded in chat, or left in an access log or an error-monitoring tool, is a read grant as it stands.
There is a more important fact. According to Supabase (Serving assets), Storage signed URLs are signed with a dedicated internal key separate from the Auth JWT signing key. Rotating the Auth keys or disabling the legacy keys therefore does not affect them; they remain valid until their expiry time. To revoke them, the docs say you need to contact Supabase support.
- Keep the expiry as short as the use case allows (section 3). "A bit longer, just in case" means handing out a key you cannot revoke for longer.
- Don't store signed URLs long-term in the DB or in email bodies. Issue one when it is needed, after checking authorization.
- Don't write signed URLs to application logs or error reports.
7-5. Size limits come in two tiers: global and per bucket
Size limits have two layers (File Limits): the global limit in Storage Settings (up to 50 MB on the Free plan, up to 500 GB on the Pro and Team plans) and a per-bucket limit that cannot exceed it (file_size_limit in storage.buckets). The docs recommend setting the global limit to the largest file your app accepts and setting smaller per-bucket limits as needed.
If file_size_limit or allowed_mime_types is unset, no bucket-level restriction applies. Checking the settings of buckets created in the Dashboard once with SQL before production removes the guesswork.
-- list each bucket's visibility, size limit and allowed MIME types (null = no bucket-level restriction)
select id, public, file_size_limit, allowed_mime_types
from storage.buckets
order by id;
Separately from the per-file size limit, cap the number of files and the total volume one user can store (ASVS 5.2.4). Without that cap, a single legitimate user can inflate your storage bill.
All five points in this section are of the "the policies are right, and it still leaks" kind, and RLS tests alone won't find them. If you want a third party to check before real data goes in, my Next.js × Supabase security audit covers Storage bucket settings and serving paths as well.
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 withowner_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."
- What sits outside RLS — object names, the declared MIME type, the serving origin, signed-URL lifetime, and size/count limits — has to be closed separately from the policies (section 7).
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.