Step 10
Step 10 — Object Storage and File Permissions
0 views
Step 10 — Object Storage and File Permissions
Once you have deployment down, files are next. User profile photos, attachments, receipts — where to put these bytes and who gets to see them. This step is hands-on: create buckets with Supabase Storage, upload, lock things down so only your own files are visible, and expose them temporarily with signed URLs.
Why object storage instead of the DB
There is a temptation to put files in a DB column (base64 BLOB), but at scale you regret it.
- DB backups, replication, and indexes get heavier by the weight of the binaries.
- A DB row cannot be served directly by a CDN. Files need to come out as URLs for edge caching to apply.
So the standard is bytes in object storage, only metadata (URL, size, mime) in the DB. A row in the DB's file table is a pointer saying "this file lives at this storage location."
(1) Create buckets — public vs private
The top-level unit of Storage is the bucket. Create two.
avatars— public. Anyone who knows the URL can GET it. For public profile images.user-uploads— private. Cannot be opened by URL alone. Needs an RLS policy or a signed URL. For sensitive files like receipts.
From the dashboard's Storage menu, or via SQL:
insert into storage.buckets (id, name, public)
values ('avatars', 'avatars', true),
('user-uploads', 'user-uploads', false);
Path design matters. Put the owner ID as a prefix at the front of the object key.
{userId}/{kind}/{uuid}.{ext}
e.g.) 9f3c.../avatars/2a7b.png
With this layout, (a) account deletion is cleaned up by deleting the whole {userId}/ prefix, and (b) the RLS policy in the next step simplifies to "first path segment == own ID."
(2) Upload, URL, delete with supabase-js
You handle files via the storage namespace of supabase-js.
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(URL, ANON_KEY);
// upload an avatar to the public bucket
const path = `${userId}/avatars/${crypto.randomUUID()}.png`;
const { error } = await supabase.storage
.from('avatars')
.upload(path, file, { contentType: 'image/png', upsert: false });
// public URL — just string assembly, no network call
const { data } = supabase.storage.from('avatars').getPublicUrl(path);
// INSERT data.publicUrl into the app DB metadata row
// delete — an array of keys
await supabase.storage.from('avatars').remove([path]);
Where people get stuck — upload defaults to upsert: false. If you will overwrite the same path, as in replacing a profile photo, you must set upsert: true. Skip it and you get a "Duplicate" error.
// overwrite the same path — declare upsert
await supabase.storage.from('avatars').upload(path, file, { upsert: true });
getPublicUrl is for public buckets only. For private buckets, use the signed URL in step (4).
(3) File RLS — only your own files visible
Permissions for Storage objects are not a separate system. They follow the RLS policies on the storage.objects table directly. This is why step (1) said metadata is a DB table row.
Let's lock the user-uploads (private) bucket to "own files only." The core is to compare the first segment of the object key against auth.uid() — the {userId}/... path design from step (1) clicks into place here.
-- read only files under your own prefix
create policy "read own files" on storage.objects
for select
using (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- upload only to your own prefix
create policy "upload to own prefix" on storage.objects
for insert
with check (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = auth.uid()::text
);
-- delete only your own files
create policy "delete own files" on storage.objects
for delete
using (
bucket_id = 'user-uploads'
and (storage.foldername(name))[1] = auth.uid()::text
);
storage.foldername(name)is the object key split by/as an array.[1]is the first segment.- The
bucket_idcondition keeps the policy applied only to the intended bucket. - Test it — log in as user A and try to read user B's file path. It is correct if RLS blocks it with an empty result.
(4) Presigned URL — temporarily expose a private file
A file in a private bucket cannot be opened by URL alone. Yet external sharing, email attachments, and temporary download links are needed. A signed URL fills that gap — a URL with an embedded time-limited token granting "this object, until this time, to anyone" access.
// a download URL valid for only 60 seconds
const { data } = await supabase.storage
.from('user-uploads')
.createSignedUrl(`${userId}/receipts/8e10.pdf`, 60);
// send data.signedUrl back in the response
Keep the expiry (in seconds) short. Even if the link leaks, the window closes soon. Use 30~60 seconds for an immediate download, a few hours for something put in an email — match it to the purpose.
One practical pattern: originals private, only derivatives public. Keep a user's high-resolution uploaded original in a private bucket opened only via signed URLs, and put only the resized/watermarked derivative in a public bucket served fast by a CDN.
S3 compatibility — one step further
Supabase Storage also provides an S3-compatible endpoint. You can handle the same bucket with a standard S3 SDK like @aws-sdk/client-s3. Use it for multipart uploads of large files, or for moving to Cloudflare R2 or MinIO later by swapping only the endpoint and credentials while the code stays nearly the same — a safety net against vendor lock-in. S3 credentials bypass RLS, so keep them server-only in environment variables.
Common pitfalls
- Creating a bucket without setting RLS — even a private bucket, without policies, gets blocked or opened against intent. Bundle bucket creation and policy writing into one task.
- Exposing the
service_roleor S3 secret key in the client bundle — these keys bypass RLS. If they land in the frontend bundle, every file opens. Put only theanonkey in the client. - Storing file bytes in the DB — do not base64 a file into a DB column. Metadata only in the DB, bytes in Storage.
- Public bucket CDN cache — even if you upload a new derivative to the same path, the old cache lingers. Call cache invalidation, or use a version token in the path or querystring.
- Client uploading directly — if size, mime, and count validation rely only on client code, it is easy to bypass. For sensitive paths, go through a server route that validates before upload.
Deeper
Course wrap-up
By finishing here, on top of the 6 deploy options — Docker · Caddy · AWS · Fly.io · Replit · GitHub Pages — you now also have a way to handle user files safely with object storage. It does not stop at getting code running: where to put the files that pile up on top of it — buckets · RLS · signed URLs — and who gets to see them is the last piece of operations. Side projects → GitHub Pages / Replit; real workloads → Fly.io or a single server + Caddy, with Supabase Storage layered on.
🎉 You finished Docker · Caddy · Cloud — 10 deploy options
What's next? Pick another course below.