codingstairs
NotesEDULifeContact
⌕Search⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

Get in touch

Send without signing in. Add your email if you'd like a reply.

  • Leave a message anonymously →
  • ✉ warragon112@gmail.com
  • KakaoTalk Open Chat ↗

© 2026 codingstairs

  • Notes
  • EDU
  • Search
  • Life
  • Contact
  • Legal
  • RSS
  • GitHub
Notes›data

Supabase Storage — File Upload and Permissions

Published 2026-05-18·0 views

Supabase Storage — File Upload and Permissions

The Supabase note covered Storage in just one paragraph. This note goes deep on exactly that spot — bucket design, upload/delete code, per-file permissions (RLS), the S3-compatible API, and signed URLs. The practical question of where to put images and who gets to see them.

1. The object storage idea

You can put files like images, PDFs, and video directly into a database BLOB column, but at scale you almost always regret it.

  • DB bloat — a table holding binaries makes backups, replication, and indexes all heavier.
  • Caching and CDN trouble — a DB row cannot be served directly by a CDN. Files need to be exposed as URLs for edge caching to apply.
  • No streaming — partial transfer (Range) of large files needs the basics that object storage provides.

So the common split is this. File bytes go to object storage, while only metadata (file URL, size, mime type, uploader ID) goes to the DB. A row in the DB's images table is not the actual bytes but a pointer saying "this file lives at this storage location and is this big."

The de facto standard API for object storage is Amazon S3. R2, MinIO, GCS, and Supabase Storage all provide or absorb an S3-compatible interface. "Speaking the S3 API" is what gives you portability.

2. Supabase Storage buckets and paths

The top-level unit of Supabase Storage is the bucket. There are two kinds.

  • public bucket — anyone who knows the object URL can GET it. Suited for public images and thumbnails.
  • private bucket — not accessible by URL alone. You must go through an RLS policy or a signed URL. Suited for sensitive files like user-uploaded originals or receipts.

Within a bucket, a file is identified by an object key (a path string). Path design drives operations. The recommended pattern is to put owner and domain at the front as a prefix.

{userId}/{domain}/{uuid}.{ext}

e.g.) 9f3c.../avatars/2a7b-...png
      9f3c.../receipts/8e10-...pdf

With this layout:

  • On account deletion, listing and deleting the whole {userId}/ prefix cleans up all of that person's files in one pass.
  • RLS policies can easily compare the first path segment ((storage.foldername(name))[1]) against the user ID.
  • Using a UUID in the filename reduces both same-name upload collisions and path-guessing attacks at once.

An object's metadata (bucket, key, size, mime, owner) is stored as one row in PostgreSQL's storage.objects table. So Storage too is ultimately a DB table, and that leads into the permissions discussion in the next section.

3. Upload, delete, and URL with supabase-js

You handle files via the storage namespace of supabase-js. After picking a bucket, you call upload, remove, and getPublicUrl.

import { createClient } from '@supabase/supabase-js';

const supabase = createClient(URL, ANON_KEY);
const bucket = 'avatars';

// upload — accepts File / Blob / ArrayBuffer
const path = `${userId}/avatars/${crypto.randomUUID()}.png`;
const { data, error } = await supabase.storage
  .from(bucket)
  .upload(path, file, {
    contentType: 'image/png',
    cacheControl: '3600',
    upsert: false,            // fails if the same path already exists
  });

// overwrite the same path — declare upsert explicitly
await supabase.storage
  .from(bucket)
  .upload(path, file, { upsert: true });

// permanent URL for a public bucket — just string assembly, no network call
const { data: pub } = supabase.storage.from(bucket).getPublicUrl(path);
// store pub.publicUrl in the DB metadata row

// delete — accepts an array of keys
await supabase.storage.from(bucket).remove([path]);

Things to remember.

  • upload defaults to upsert: false. If you intend to re-upload to the same path, you must explicitly set upsert: true. Omitting it raises a "Duplicate" error.
  • getPublicUrl is for public buckets only. Used on a private bucket, the URL is still built but opening it is rejected — for that, use the signed URL in section 6.
  • What upload returns is just key information, not file bytes. The common flow is to separately INSERT metadata like publicUrl, path, and size into your app's DB table right after upload.

4. File access permissions — RLS

Permissions for Storage objects are not a separate system. They follow the Row Level Security policies on the storage.objects table directly. This is why section 2 said metadata is a DB table row.

  • public bucket — usually has a policy granting SELECT to everyone (including anon). Writes (INSERT/UPDATE/DELETE) are still restricted.
  • private bucket — restricts even SELECT to authenticated users, and usually narrows it to "own files only."

The core of an "own files only" policy is to compare the first path segment of the object key against auth.uid(). The {userId}/... path design from section 2 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) returns the object key split by / as an array. [1] is the first segment.
  • Include a bucket_id condition so the policy applies only to the intended bucket — as buckets grow, policies otherwise blur together.
  • The service_role key bypasses RLS. Use it only for admin tasks (bulk cleanup, etc.) in server routes, and never expose it to clients.

5. S3-compatible API

Beyond its own API, Supabase Storage provides an S3-compatible endpoint. So you can handle the same bucket with a standard S3 SDK like @aws-sdk/client-s3.

import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';

const s3 = new S3Client({
  forcePathStyle: true,
  region: '<project-region>',                // project region
  endpoint: 'https://<project>.supabase.co/storage/v1/s3',
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY!,  // server only
    secretAccessKey: process.env.S3_SECRET_KEY!,
  },
});

await s3.send(new PutObjectCommand({
  Bucket: 'my-bucket',
  Key: 'reports/quarterly.pdf',
  Body: fileBuffer,
  ContentType: 'application/pdf',
}));

Why bother with S3 compatibility.

  • Multipart upload — the standard flow of splitting a large file into chunks for parallel, resumable uploads is supported as-is by the S3 SDK.
  • Portability — when you later move to Cloudflare R2, MinIO, or AWS S3 proper, the code works almost unchanged by swapping only the endpoint and credentials. You are not locked to one vendor.
  • Reuse of existing tools — tools that know S3, like aws s3 cp, rclone, and backup scripts, plug straight in.

There are three configuration pieces. The endpoint (the storage S3 path), the region (the project region string), and the access key / secret key. S3 credentials are strong permissions that do not pass through RLS, so keep them server-only just like the service_role key.

6. Presigned URLs (signed URLs)

A file in a private bucket cannot be opened by URL alone. But binding all access to a login session makes external sharing, email attachments, and temporary download links awkward. A signed URL (presigned URL) fills that gap.

A signed URL is a URL with an embedded time-limited token that grants "this object, until this time, to anyone" access.

// a download URL valid for only 60 seconds
const { data, error } = await supabase.storage
  .from('user-uploads')
  .createSignedUrl(`${userId}/receipts/8e10.pdf`, 60);
// send data.signedUrl back in the response

// several objects at once
const { data: many } = await supabase.storage
  .from('user-uploads')
  .createSignedUrls([keyA, keyB], 60);

Keeping the expiry (in seconds) short is the key. 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 common policy: 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. It is a compromise that handles original-leak risk and serving speed at once.

Common pitfalls

New bucket, new table with no RLS — without policies on storage.objects, you get exposure that differs from intent. Even if you created a private bucket, the absence of any RLS policy makes it "nobody can see it," or conversely one overly broad policy opens everything. When you create a bucket, bundle writing the policy into the same task.

service_role key exposed in the client bundle — service_role and the S3 secret key bypass RLS. If they land in the built frontend bundle, every file opens. Put only the anon key in the client and strong keys in server environment variables — a bundle check in CI is recommended.

Storing file bytes in the DB — the mistake of base64-encoding a file into a DB column. The DB bloats and a CDN cannot attach. Put only metadata (URL, size, mime) in the DB, and bytes in Storage.

Public bucket CDN cache — public objects are cached by the CDN. Even if you upload a new derivative to the same path, the old cache lingers for a while. Call cache invalidation, or put a version token into the path (or querystring) to make a new URL.

Missing upsert on re-upload to the same path — upload defaults to upsert: false. If you intend to overwrite the same key, as in replacing a profile photo, you must set upsert: true. Skip it and it fails with a duplicate error.

Client uploading directly to storage — uploading straight from the client with upload is fast, but file size, mime, and count validation then rely only on client code. For sensitive paths, it is safer to go through a server route that validates before upload, or to have the server issue a signed upload URL.

Closing thoughts

The hard part of Storage is not "where to put the file" but "who gets to see it." Bucket public/private, the path prefix design, and RLS on storage.objects — these three must mesh for permissions to stay consistent. Start simple: serve public images from a single public bucket, and add a private bucket plus a prefix policy at the point where per-user files appear.

Next

  • supabase
  • image-pipeline

References: Supabase Storage docs, Storage S3-compatible API, Storage access control (RLS), Amazon S3 API reference.

More in data

All in this category →
  • Keep DB seed sources outside the code tree
  • Kafka in Practice — Topic Design and Message Flow
  • Orchestrating multiple PostgreSQL pools
  • Backup and Restore
  • Image Pipeline
  • Push Notifications — FCM and Web Push