Skip to content

Storage: Buckets and Policies

Supabase Storage is S3-compatible object storage organized into buckets, and access to every object is enforced by ordinary Row Level Security policies on a Postgres table — the same mechanism you already know from the RLS module, not a separate permission system.

Buckets: public and private, created via SQL or the API

Section titled “Buckets: public and private, created via SQL or the API”

A bucket is a top-level container for files, similar to an S3 bucket. A bucket can be marked public, meaning anyone with the object’s URL can read it with no authentication, or private, meaning access is controlled per-request. A bucket is just a row in storage.buckets, so it can be created either directly with SQL or through supabase-js:

-- supabase/migrations/6004_rt_storage_buckets.sql
insert into storage.buckets (id, name, public)
values ('rt-avatars', 'rt-avatars', false);
src/rt-create-bucket.ts
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const { data, error } = await supabase.storage.createBucket('rt-avatars', {
public: false,
});

Both produce the exact same row in storage.buckets — createBucket is a thin wrapper around the same insert. Uploading, downloading, and reading files from supabase-js all operate on a named bucket:

src/rt-storage-basic-ops.ts
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_ANON_KEY!);
// Upload a file into a path inside the bucket
const { data, error } = await supabase.storage
.from('rt-avatars')
.upload('user123/photo.png', new Blob());
// Download the raw file bytes
const { data: fileData } = await supabase.storage
.from('rt-avatars')
.download('user123/photo.png');
// Get a public URL — only meaningful for a public bucket
const { data: publicUrlData } = supabase.storage
.from('rt-avatars')
.getPublicUrl('user123/photo.png');
// Get a time-limited signed URL — for a private bucket
const { data: signedUrlData } = await supabase.storage
.from('rt-avatars')
.createSignedUrl('user123/photo.png', 60);

getPublicUrl just builds a URL string — it does not check anything, so calling it on a private bucket’s object gives you a URL that will still be rejected. createSignedUrl is what you actually want for private content: it mints a URL, valid for the number of seconds you pass in, that embeds a signed token granting temporary access to that one object.

What actually enforces access: Row Level Security on storage.objects, scoped by bucket_id

Section titled “What actually enforces access: Row Level Security on storage.objects, scoped by bucket_id”

Every uploaded object’s metadata — its bucket, its path, its owner, its content type — lives as a row in a real Postgres table, storage.objects. Access control is just Row Level Security policies on that table, written with the exact same using and with check syntax you already use for any other table, and every policy that matters is scoped by bucket_id so a policy written for one bucket never leaks into another:

-- supabase/migrations/6004_rt_storage_buckets.sql
create policy "rt_avatars_insert_own_folder"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
create policy "rt_avatars_select_own_folder"
on storage.objects
for select
to authenticated
using (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
create policy "rt_avatars_update_own_folder"
on storage.objects
for update
to authenticated
using (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);

storage.foldername(name) splits an object’s full path (e.g. b41ba47f-.../photo.png) into an array of path segments, and [1] takes the first one — the convention is to make that first segment the uploading user’s own auth.uid(), so a policy comparing it to auth.uid()::text scopes every user to their own folder. Proof with two real signed-up users — bob attempting to write into alice’s folder, then into his own:

scripts/rt-storage-cross-user-write.sh
curl -X POST "$API_URL/storage/v1/object/rt-avatars/$ALICE_ID/bob-intrusion.txt" \
-H "Authorization: Bearer $BOB_TOKEN" -H "apikey: $ANON_KEY" -H "Content-Type: text/plain" \
--data-binary @bob-intrusion.txt
curl -X POST "$API_URL/storage/v1/object/rt-avatars/$BOB_ID/bob-note.txt" \
-H "Authorization: Bearer $BOB_TOKEN" -H "apikey: $ANON_KEY" -H "Content-Type: text/plain" \
--data-binary @bob-intrusion.txt
--- bob tries to write into alice's folder ---
{"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy","code":"AccessDenied"}
HTTP_STATUS:400
--- bob writes into his OWN folder ---
{"Key":"rt-avatars/d2ef5bb4-f15d-447f-80f2-a0833f80c4e7/bob-note.txt","Id":"d0f79e7c-bb40-4c03-b46b-7f8d881e78f7"}
HTTP_STATUS:200

A real 403/AccessDenied body — "new row violates row-level security policy" — the exact same error family the RLS module already taught for ordinary tables, because storage.objects is an ordinary table. The identical request against his own folder succeeds and returns the real object key.

src/rt-storage-folder-rls.ts
// @run
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined>; exit: (code?: number) => never };
const API_URL = process.env.SUPABASE_URL!;
const ANON_KEY = process.env.SUPABASE_ANON_KEY!;
const admin = createClient(API_URL, process.env.SUPABASE_SERVICE_ROLE_KEY!);
async function ensureUser(email: string, password: string) {
const { data, error } = await admin.auth.admin.createUser({ email, password, email_confirm: true });
if (!error) return data.user;
const { data: list } = await admin.auth.admin.listUsers();
const existing = list.users.find((u) => u.email === email);
if (!existing) throw error;
return existing;
}
const alice = await ensureUser('[email protected]', 'rt-pw-alice-12345');
const bob = await ensureUser('[email protected]', 'rt-pw-bob-12345');
const bobClient = createClient(API_URL, ANON_KEY);
await bobClient.auth.signInWithPassword({ email: '[email protected]', password: 'rt-pw-bob-12345' });
const intoAlicesFolder = await bobClient.storage
.from('rt-avatars')
.upload(`${alice.id}/bob-was-here.txt`, new Blob(['nope']));
console.log('bob -> alice folder: error =', intoAlicesFolder.error?.message ?? null);
const intoOwnFolder = await bobClient.storage
.from('rt-avatars')
.upload(`${bob.id}/bob-own-file.txt`, new Blob(['fine']));
console.log('bob -> own folder: error =', intoOwnFolder.error?.message ?? null, 'path =', intoOwnFolder.data?.path);
process.exit(intoAlicesFolder.error && !intoOwnFolder.error ? 0 : 1);

Signed URLs, expiry, and signed upload URLs

Section titled “Signed URLs, expiry, and signed upload URLs”

A signed URL embeds a JWT with a url claim and an exp claim — real proof that expiry is enforced server-side, not client-side, by creating one with a 2-second lifetime and fetching it before and after:

scripts/rt-signed-url-expiry.sh
curl -X POST "$API_URL/storage/v1/object/sign/rt-avatars/$ALICE_ID/tus-test.txt" \
-H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" -H "Content-Type: application/json" \
-d '{"expiresIn": 2}'
--- fetch immediately (should succeed) ---
0123456789ABCDEFGHIJ
HTTP_STATUS:200
--- wait 4s for expiry ---
--- fetch after expiry (should fail) ---
{"statusCode":"400","error":"InvalidJWT","message":"\"exp\" claim timestamp check failed","code":"InvalidJWT"}
HTTP_STATUS:400

A real InvalidJWT/"exp" claim timestamp check failed — the same JWT expiry check the Auth module already covered, applied here to a Storage access token instead of a session token. A signed upload URL is the inverse: instead of granting temporary read access to an existing object, it grants temporary write access to a path that does not need to exist yet, with no Authorization header required at upload time at all — useful for letting an untrusted client (or a browser with no session) upload directly, once your server has minted the token:

src/rt-signed-upload-url.ts
import { createClient } from '@supabase/supabase-js';
declare const process: { env: Record<string, string | undefined> };
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!);
const { data, error } = await supabase.storage
.from('rt-avatars')
.createSignedUploadUrl('user123/upload-later.txt');
// data.token is then handed to the untrusted client, which uploads with:
// await supabase.storage.from('rt-avatars').uploadToSignedUrl('user123/upload-later.txt', data.token, file);
--- PUT using ONLY the signed upload token, no Authorization header at all ---
{"Key":"rt-avatars/b41ba47f-4b83-4945-8dd6-637df563e1d3/signed-upload-demo.txt"}
HTTP_STATUS:200

TUS resumable upload — proved chunk by chunk against the local stack

Section titled “TUS resumable upload — proved chunk by chunk against the local stack”

Supabase Storage implements the TUS protocol at /storage/v1/upload/resumable, recommended for files over roughly 6MB or on unreliable connections (supabase.com/docs/guides/storage/uploads/resumable-uploads). The local supabase_storage_probe container (storage-api 1.72.1) supports it fully — proved below with raw curl speaking the TUS protocol directly (no client library needed to prove the mechanism, though tus-js-client — shown after — is what you would actually use in an application). A 20-byte upload, split into two 10-byte chunks, with a HEAD check in between simulating a resume after an interruption:

scripts/rt-tus-resumable.sh
curl -X POST "$API_URL/storage/v1/upload/resumable" \
-H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" \
-H "Tus-Resumable: 1.0.0" -H "Upload-Length: 20" \
-H "Upload-Metadata: bucketName $META_BUCKET,objectName $META_OBJECT,contentType $META_CT"
# -> 201 Created, Location: http://127.0.0.1:55321/storage/v1/upload/resumable/<upload-id>
curl -X HEAD "$LOCATION" -H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" -H "Tus-Resumable: 1.0.0"
# -> Upload-Offset: 0, Upload-Length: 20
curl -X PATCH "$LOCATION" -H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" \
-H "Tus-Resumable: 1.0.0" -H "Upload-Offset: 0" -H "Content-Type: application/offset+octet-stream" \
--data-binary "0123456789"
# -> 204, Upload-Offset: 10
# ...connection drops here in a real client; on reconnect, a HEAD confirms where to resume from...
curl -X HEAD "$LOCATION" -H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" -H "Tus-Resumable: 1.0.0"
# -> Upload-Offset: 10 (the server remembers exactly how many bytes it already has)
curl -X PATCH "$LOCATION" -H "Authorization: Bearer $TOKEN" -H "apikey: $ANON_KEY" \
-H "Tus-Resumable: 1.0.0" -H "Upload-Offset: 10" -H "Content-Type: application/offset+octet-stream" \
--data-binary "ABCDEFGHIJ"
# -> 204, Upload-Offset: 20
--- HEAD before any bytes uploaded ---
HTTP/1.1 200
upload-length: 20
upload-offset: 0
--- PATCH first 10 bytes (chunk 1) ---
HTTP/1.1 204
upload-offset: 10
--- HEAD after chunk 1 (simulating resume check) ---
HTTP/1.1 200
upload-length: 20
upload-offset: 10
--- PATCH chunk 2, resuming from offset 10 ---
HTTP/1.1 204
upload-offset: 20
--- final downloaded object content ---
0123456789ABCDEFGHIJ

The two chunks — 0123456789 and ABCDEFGHIJ — concatenate into exactly the original 20 bytes, and the HEAD between them proves the server tracked progress independently of the client: a real client that crashed after chunk 1 could reconnect, HEAD to discover Upload-Offset: 10, and resume from exactly there instead of re-uploading from zero. The equivalent client-side code, using the library you would actually reach for in an app:

src/rt-tus-upload.ts
// tus-js-client is not a dependency of this course's probe project. In a
// real app: `npm install tus-js-client`, then
// `import * as tus from 'tus-js-client';` — the `tus` const below stands
// in for that import purely so this snippet type-checks in isolation.
declare const tus: {
Upload: new (
file: File,
options: Record<string, unknown>
) => {
findPreviousUploads(): Promise<unknown[]>;
resumeFromPreviousUpload(previous: unknown): void;
start(): void;
};
};
declare const process: { env: Record<string, string | undefined> };
async function uploadFile(bucketName: string, fileName: string, file: File, accessToken: string) {
return new Promise<void>((resolve, reject) => {
const upload = new tus.Upload(file, {
endpoint: `${process.env.SUPABASE_URL}/storage/v1/upload/resumable`,
retryDelays: [0, 3000, 5000, 10000, 20000],
headers: { authorization: `Bearer ${accessToken}` },
uploadDataDuringCreation: true,
metadata: { bucketName, objectName: fileName, contentType: file.type },
chunkSize: 6 * 1024 * 1024,
onError: reject,
onSuccess: () => resolve(),
});
upload.findPreviousUploads().then((previous: unknown[]) => {
if (previous.length) upload.resumeFromPreviousUpload(previous[0]);
upload.start();
});
});
}

File size limits, allowed MIME types, and upsert semantics

Section titled “File size limits, allowed MIME types, and upsert semantics”

A bucket can restrict what it accepts at creation time, file_size_limit (bytes) and allowed_mime_types, enforced by the Storage API itself, not RLS:

-- supabase/migrations/6004_rt_storage_buckets.sql
insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types)
values ('rt-restricted', 'rt-restricted', false, 100, array['image/png']);
create policy "rt_restricted_insert_own_folder"
on storage.objects for insert to authenticated
with check (bucket_id = 'rt-restricted' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "rt_restricted_select_own_folder"
on storage.objects for select to authenticated
using (bucket_id = 'rt-restricted' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "rt_restricted_update_own_folder"
on storage.objects for update to authenticated
using (bucket_id = 'rt-restricted' and (storage.foldername(name))[1] = auth.uid()::text);

Proof, with the 100-byte / image/png-only bucket above:

--- wrong mime type (text/plain into image/png-only bucket) ---
{"statusCode":"415","error":"invalid_mime_type","message":"mime type text/plain is not supported","code":"InvalidMimeType"}
--- file too large (bucket limit is 100 bytes, file is 500) ---
{"statusCode":"413","error":"Payload too large","message":"The object exceeded the maximum allowed size","code":"EntityTooLarge"}
--- valid: 50-byte png, correct mime type ---
{"Key":"rt-restricted/.../small.png","Id":"411d477e-..."} HTTP 200

Real, distinct HTTP status codes for each failure mode — 415 for the MIME mismatch, 413 for the size overage — both caught before RLS is even evaluated. upsert (re-uploading to the same path) has a sharper requirement than it looks:

--- re-upload same path WITHOUT x-upsert (already exists) ---
{"statusCode":"409","error":"Duplicate","message":"The resource already exists","code":"KeyAlreadyExists"}
--- re-upload same path WITH x-upsert: true, but only insert+update policies (no select) ---
{"statusCode":"403","error":"Unauthorized","message":"new row violates row-level security policy","code":"AccessDenied"}
--- same request, WITH a select policy added ---
{"Key":"rt-restricted/.../small.png","Id":"411d477e-..."} HTTP 200
flowchart LR
  client["Client upload/download/upsert request"] --> api["Storage API"]
  api --> restrict{"file_size_limit / allowed_mime_types check (bucket config, not RLS)"}
  restrict -->|fails| reject413["413 / 415, before RLS runs"]
  restrict -->|passes| objects[("storage.objects row: bucket_id, name, owner")]
  objects --> rls{"RLS: insert/update with check, select using, scoped by bucket_id"}
  rls -->|allowed| data["Object written or returned"]
  rls -->|denied| reject403["403 AccessDenied"]
Every Storage request is gated by RLS on storage.objects, scoped by bucket_id

An AI assistant asked to “let users upload their own avatar and overwrite it if they upload again” wrote this:

🐛 Spot the bug

create policy "avatars_write"
on storage.objects
for insert
to authenticated
with check (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
create policy "avatars_update"
on storage.objects
for update
to authenticated
using (
bucket_id = 'rt-avatars'
and (storage.foldername(name))[1] = auth.uid()::text
);
Show the answer

This looks complete — insert and update policies, both correctly scoped to the user’s own folder — but re-uploading to the same path with upsert: true fails with a real 403/AccessDenied, proved earlier in this lesson with the identical error shape. upsert compiles to INSERT ... ON CONFLICT DO UPDATE ... RETURNING *, and Postgres needs a select policy to even evaluate whether the conflicting row is visible to this caller, not just insert and update policies. The fix is a third policy: create policy "avatars_read" on storage.objects for select to authenticated using (bucket_id = 'rt-avatars' and (storage.foldername(name))[1] = auth.uid()::text); — without it, upload(path, file, { upsert: true }) on an existing path fails every time, and the error message (“row-level security policy”) gives no hint that the missing policy is a select, not an insert or update.

Storage is not a separate permission system wearing Postgres’s clothes — it is a thin API in front of two real things: an S3-compatible blob store for bytes, and a real Postgres table, storage.objects, for everything about those bytes (owner, path, bucket, content type). Every access decision Storage makes about rows (who can see this object’s metadata, who can create or overwrite it) is RLS, full stop, the same engine and the same policy syntax as any table you have written a policy for already — proved above with the identical 403/AccessDenied/42501 shape as the RLS module’s own table examples. Everything that is not an RLS decision — 413/415 from bucket-level file_size_limit/allowed_mime_types, 409 from a plain unique-constraint conflict on (name, bucket_id), expiry on a signed URL’s own JWT — is enforced somewhere else in the stack, before or alongside RLS, which is why each of those failure modes above came back as a genuinely different HTTP status and error code rather than a uniform “storage error.”

  • storage.objects RLS reuses everything you already know about Postgres RLS — no new policy language, no new mental model — at the cost of subtleties that don’t exist in plain table RLS, like upsert silently needing a select policy because of how ON CONFLICT DO UPDATE is compiled, proved above.
  • Signed URLs let you grant precise, time-limited access to one object without making a bucket public — at the cost of the expiry being fixed at mint time (2 hours by default for signed upload URLs per the docs, and whatever you pass for createSignedUrl) with no way to revoke a single token early short of rotating the whole bucket’s signing key.
  • TUS resumable upload survives a dropped connection without re-uploading from zero — at the cost of a fixed 6MB chunk size requirement and considerably more client-side complexity (session creation, offset tracking, resume logic) than a single .upload() call, which is why the docs recommend it specifically for large or unreliable-network cases, not as a blanket default.
  • “Storage access is controlled by bucket public/private flag, not RLS.” Proved above: even a bucket-level public: false/true flag only changes the default unauthenticated read behavior — every authenticated write, and every read on a private bucket, is still gated by real storage.objects policies, identical in shape to table RLS.
  • “If insert and update policies are correct, upsert will work.” Proved above: a real 403 on upsert: true with both policies present and correct, because the underlying query also needs a select policy to resolve ON CONFLICT.
  • “A signed URL’s expiry is just a client-side hint, like an ordinary URL parameter.” Proved above: the expiry is a real exp claim in a signed JWT, checked server-side — a request after expiry gets a genuine InvalidJWT error, not a URL that silently still works.
  • “TUS resumable upload needs a special client library to work at all.” Proved above by driving the entire protocol with raw curl — POST to create, PATCH to send bytes with an Upload-Offset, HEAD to check progress. A library like tus-js-client makes it convenient (retry logic, chunking, resume detection) but the protocol itself is plain HTTP headers.
What is the difference between a public and a private Storage bucket
What actually enforces who can upload, read, or delete a given Storage object
A bucket has file_size_limit: 100 and allowed_mime_types: [image/png]. What happens uploading a 500-byte text/plain file
Uploading with upsert: true to an existing path fails with 403/AccessDenied even though insert and update policies are both correctly scoped to the caller. What is missing
What does a signed URL created with createSignedUrl(path, 2) actually enforce after 2 seconds pass
What proves that TUS resumable upload does not require a specific client library to function