Storage: Buckets and Policies
The idea in one sentence
Section titled “The idea in one sentence”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.sqlinsert into storage.buckets (id, name, public)values ('rt-avatars', 'rt-avatars', false);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:
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 bucketconst { data, error } = await supabase.storage .from('rt-avatars') .upload('user123/photo.png', new Blob());
// Download the raw file bytesconst { data: fileData } = await supabase.storage .from('rt-avatars') .download('user123/photo.png');
// Get a public URL — only meaningful for a public bucketconst { data: publicUrlData } = supabase.storage .from('rt-avatars') .getPublicUrl('user123/photo.png');
// Get a time-limited signed URL — for a private bucketconst { 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.sqlcreate policy "rt_avatars_insert_own_folder"on storage.objectsfor insertto authenticatedwith check ( bucket_id = 'rt-avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "rt_avatars_select_own_folder"on storage.objectsfor selectto authenticatedusing ( bucket_id = 'rt-avatars' and (storage.foldername(name))[1] = auth.uid()::text);
create policy "rt_avatars_update_own_folder"on storage.objectsfor updateto authenticatedusing ( 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:
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:200A 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.
// @runimport { 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 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:
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) ---0123456789ABCDEFGHIJHTTP_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:400A 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:
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:200TUS 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:
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 200upload-length: 20upload-offset: 0
--- PATCH first 10 bytes (chunk 1) ---HTTP/1.1 204upload-offset: 10
--- HEAD after chunk 1 (simulating resume check) ---HTTP/1.1 200upload-length: 20upload-offset: 10
--- PATCH chunk 2, resuming from offset 10 ---HTTP/1.1 204upload-offset: 20
--- final downloaded object content ---0123456789ABCDEFGHIJThe 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:
// 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.sqlinsert 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 authenticatedwith 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 authenticatedusing (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 authenticatedusing (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 200Real, 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 200flowchart 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"] Reading AI-generated code
Section titled “Reading AI-generated code”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.objectsfor insertto authenticatedwith check (bucket_id = 'rt-avatars'and (storage.foldername(name))[1] = auth.uid()::text);
create policy "avatars_update"on storage.objectsfor updateto authenticatedusing (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.
Mental Model
Section titled “Mental Model”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.”
Trade-offs
Section titled “Trade-offs”storage.objectsRLS 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, likeupsertsilently needing aselectpolicy because of howON CONFLICT DO UPDATEis 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.
Common Misconceptions
Section titled “Common Misconceptions”- “Storage access is controlled by bucket public/private flag, not RLS.” Proved above: even a bucket-level
public: false/trueflag only changes the default unauthenticated read behavior — every authenticated write, and every read on a private bucket, is still gated by realstorage.objectspolicies, identical in shape to table RLS. - “If insert and update policies are correct, upsert will work.” Proved above: a real
403onupsert: truewith both policies present and correct, because the underlying query also needs aselectpolicy to resolveON CONFLICT. - “A signed URL’s expiry is just a client-side hint, like an ordinary URL parameter.” Proved above: the expiry is a real
expclaim in a signed JWT, checked server-side — a request after expiry gets a genuineInvalidJWTerror, 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—POSTto create,PATCHto send bytes with anUpload-Offset,HEADto check progress. A library liketus-js-clientmakes it convenient (retry logic, chunking, resume detection) but the protocol itself is plain HTTP headers.