The answer. A private object cannot be shown with a plain <img src>: the browser sends no Authorization header, so the authenticated URL 400s. The bridge is a signed URL, minted by anyone with SELECT rights on the object: unguessable, valid for the seconds you pass, and served without re-checking policies. Add transform and Storage returns a resized, recompressed variant (width, height, resize mode, quality, format) through its image proxy; for public buckets the same transforms are query parameters on the /render/image/public/ path. For galleries where signed URLs expire mid-session, a small server route that checks access with the user's client and then streams the transformed download is the durable alternative. Uploads have their own threshold: a standard upload is one request, so anything over 6 MB should use the resumable TUS endpoint, which retries per 6 MB chunk.
The pattern.
const { data } = await supabase.storage.from("attachments").createSignedUrl(path, 600, { transform: { width: 320, height: 320, resize: "cover", quality: 70 } });
// data.signedUrl → <img src>, valid 10 minutes, policy already checked when minted
public: https://<ref>.supabase.co/storage/v1/render/image/public/avatars/u1.png?width=200&height=200
resumable: POST https://<ref>.supabase.co/storage/v1/upload/resumable (tus-js-client, chunkSize 6 * 1024 * 1024,
headers: authorization, apikey, x-upsert; metadata: bucketName, objectName, contentType, cacheControl)
Watch out.
- Image transformations are a Pro-plan feature, metered per origin image after the included quota, and capped at 25 MB or 50 megapixels per source.
- A signed URL is a bearer token: log lines and analytics beacons that capture full URLs leak it for its lifetime.
- The TUS chunk size is fixed at exactly 6 MB; a different value fails the upload rather than degrading.
Related: storage-rls-lives-on-storage-objects-folders-are-the-boundary · edge-function-per-new-row-through-a-webhook · r2-serve-objects-with-their-own-headers-etag-range