Supabase Storage Private Buckets + Signed URLs
Production-ready private file storage for a Supabase + Cloudflare Workers stack — a private bucket created as an idempotent SQL migration, server-side service_role uploads (the client never talks to Storage and never holds the service key), and on-demand signed URLs with short TTLs. IDOR-proof by construction — the object path is derived from the verified JWT ({userId}/{itemId}.jpg, never client-supplied) and ownership is checked through RLS before any URL is signed — with defense-in-depth RLS policies on storage.objects, orphan-object cleanup (Postgres ON DELETE CASCADE never touches Storage), and the pitfalls of public buckets and leaked signed URLs.
Copies a short instruction with your private download link. Paste it into your agent: it downloads the whole skill (the source code, the setup steps and the mistakes we already made for you) and builds the screen.
Works with
What it does
User files — avatars, photos, attachments — are the easiest place to leak private data. A public Supabase bucket makes every object readable by anyone who has (or guesses) its URL, with no token required. Letting the client upload directly to Storage with the anon key pushes all validation onto Storage RLS alone, exposes your bucket layout to the client, and breaks the zero-dependency iOS guarantee of this stack. And even with a private bucket, a backend that signs URLs for whatever id the client sends is one missing ownership check away from serving one user's private photos to another (a classic IDOR).
This skill closes all three holes with one consistent model:
- Private bucket, as code — the bucket (size limit, MIME allowlist,
public = false) is an idempotent SQL migration insupabase/migrations/, reproducible on any project viasupabase db push - Server-side uploads only — the client POSTs bytes to your Worker; the Worker uploads with the
service_roleclient. The client never talks to Storage and never holds any Supabase key - Signed URLs, on demand — reads go through short-TTL signed URLs generated per request. The database stores the object path; URLs are minted fresh each time
- IDOR-proof paths — every object lives under
{userId}/...whereuserIdcomes from the verified JWT, never from the request. Ownership is checked through RLS before anyservice_roleStorage operation runs - Defense in depth — RLS policies on
storage.objectsisolate owner folders even if some future code path talks to Storage with a user JWT
This skill assumes the Workers + Supabase scaffolding from infra-supabase, the serviceClient / userClient factories and auth middleware from auth-supabase-anonymous, and the RLS-isolated profiles + items schema from infra-supabase-rls — it adds the file-storage layer on top. (On the AWS stack, the counterpart is S3 + per-identity IAM paths in infra-cdk; read them side by side.)
What you get
- ✓274 lines of source code, ready to paste
- ✓Architecture
- ✓Dependencies
- ✓Implementation
- ✓Integration Checklist
- ✓Common Customizations
- ✓Known Pitfalls
Architecture
┌──────────────────────────────────────────────────────────────────┐
│ iOS Client │
│ (URLSession only — never talks to Storage, never holds a key) │
└──────────────────────────────┬─────────────────────────────────────┘
│ POST /v1/items/:id/photo (raw bytes + Bearer JWT)
│ GET /v1/items/:id/photo-url (Bearer JWT)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Cloudflare Workers (Hono backend) │
│ 1. verify JWT (identityMiddleware) │
│ 2. ownership gate: userClient SELECT (RLS) → 404 if not owner │
│ 3. derive path from the token: {userId}/{itemId}.jpg │
│ 4. serviceClient → upload / createSignedUrl │
└──────────────────────────────┬─────────────────────────────────────┘
│ service_role (Storage API over HTTPS)
▼
┌──────────────────────────────────────────────────────────────────┐
│ Supabase Storage — PRIVATE bucket "item-photos" │
│ public = false · file_size_limit 5MB · MIME allowlist │
│ storage.objects RLS: owner-folder isolation (defense in depth) │
└──────────────────────────────────────────────────────────────────┘
▲
│ short-TTL signed URL — the ONLY way
│ the client ever reads an objectDefense in depth — three independent guards, any one of which stops an IDOR:
| Layer | Guard | What it stops |
|---|---|---|
| 1. Path derivation | object path is built from authUser.userId (verified JWT), never read from the request | a client naming another user's folder in an upload |
| 2. Ownership gate | userClient SELECT under RLS before any service_role Storage op — "not found" == "not yours" → 404 | signing/reading/writing objects for rows the caller does not own |
3. storage.objects RLS | owner-folder policies ((storage.foldername(name))[1] = auth.uid()) | any future code path that talks to Storage with a user JWT instead of going through the Worker |
Why the ownership gate matters: theservice_roleclient bypasses all RLS, onstorage.objectsincluded. Once you callserviceClient, nothing downstream protects you — so the RLS-enforced lookup (layer 2) must run first, while you are still acting as the user. This is the same "service_role is for system ops only, after the app has decided the caller is allowed" discipline as infra-supabase-rls.
Key decisions:
| Decision | Choice | Why |
|---|---|---|
| Bucket visibility | Private | public buckets serve every object to anyone with the URL — no token, no expiry |
| Upload path | Server-side, service_role | client stays dependency-free; server validates size/type; no Supabase key ever ships in the app |
| Read path | Signed URL, minted on demand | time-limited bearer link; DB stores only the object path |
| Path convention | {userId}/{itemId}.jpg | owner is the first folder segment → trivially checkable by both the Worker and storage.objects RLS |
| Signed URL TTL | 3600 s | long enough for a session's image loads, short enough that a leaked URL dies quickly |
Integration Checklist
1. Migration
- Bucket created via SQL migration with
public = false, afile_size_limit, and anallowed_mime_typesallowlist - Migration is idempotent: bucket uses
on conflict (id) do nothing; policies usedrop policy if exists+create storage.objectsowner-folder policies applied for all four verbs (select / insert / update / delete)- Owning table stores the object path (
photo_path text), never a URL npm run db:pushapplies cleanly to the linked project
2. Endpoints
- Upload endpoint: RLS ownership gate → Worker-level size/type validation →
service_roleupload → path persisted viauserClient - Object path is always
{userId}/...withuserIdfrom the verified token — grep your routes for any client-supplied path reaching a Storage call - Signed-URL endpoint re-proves ownership through RLS on every call and checks the path prefix before signing
- Delete flows (row delete + account delete) remove the corresponding objects
3. Clients + secrets
- Storage operations use
serviceClientonly after an RLS-enforced ownership check - The
service_rolekey lives only in.dev.vars/ Cloudflare Secret (infra-supabase) — never in the iOS app - The iOS client renders images from signed URLs only; it holds no Supabase key and never calls Storage
4. IDOR test (must pass before shipping)
Two independent users (each via your /v1/auth/anon); user A owns ITEM_A with a photo:
TOKEN_A=... ; TOKEN_B=... ; ITEM_A=... ; A_UID=...
# 1) B requests a signed URL for A's item → 404 (RLS gate hides the row).
curl -s -o /dev/null -w "%{http_code}\n" \
"$WORKER_URL/v1/items/$ITEM_A/photo-url" -H "Authorization: Bearer $TOKEN_B"
# Expected: 404
# 2) B uploads to A's item → 404 (ownership gate fires before any Storage op).
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
"$WORKER_URL/v1/items/$ITEM_A/photo" \
-H "Authorization: Bearer $TOKEN_B" -H "Content-Type: image/jpeg" \
--data-binary @photo.jpg
# Expected: 404
# 3) B reads A's object from Storage directly with a user JWT (bypassing your backend)
# → blocked by the storage.objects RLS policies (defense in depth).
curl -s -o /dev/null -w "%{http_code}\n" \
"$SUPABASE_URL/storage/v1/object/authenticated/item-photos/$A_UID/$ITEM_A.jpg" \
-H "apikey: $ANON_KEY" -H "Authorization: Bearer $TOKEN_B"
# Expected: NOT 200 (400/404)Known Pitfalls
1. Bucket created as public → every object is world-readable
Symptom: private user photos load in any browser via /storage/v1/object/public/<bucket>/<path> — no token, no expiry, forever.
Cause: the bucket was created with public = true (or toggled public in the dashboard "to make images load"). Public buckets serve every object to anyone who has or guesses the URL; with predictable paths like {userId}/{itemId}.jpg, guessing is enumeration.
Fix: public = false in the migration, reads only via signed URLs. If images "don't load", the fix is minting a signed URL server-side — never flipping the bucket public.
2. Storing the signed URL instead of the object path
Symptom: images render for about an hour after upload, then break everywhere (DB, caches, other devices) with 400/403 errors.
Cause: the signed URL — a time-limited credential — was persisted into the database as if it were the file's address.
Fix: store the object path (photo_path); mint a fresh signed URL on every read via the signed-URL endpoint. The URL is a disposable output, never state.
3. Signed URLs are bearer credentials — mind the leak surface
Symptom: someone who was never authenticated opens a user's private photo.
Cause: within its TTL a signed URL grants access to anyone who holds it — no JWT check, no RLS. URLs leak through chat messages, request logs, analytics, browser history, and referrer headers; and an issued URL cannot be revoked before it expires (short of deleting the object).
Fix: keep the TTL short (minutes-to-an-hour, matched to how long the client actually displays the image), generate on demand rather than handing out long-lived links, and never write signed URLs into logs or the database. Treat "sharing" as a product feature with its own endpoint, not as forwarding a signed URL.
4. Trusting a client-supplied object path → IDOR on write
Symptom: user B uploads into user A's folder (overwriting A's photo) by posting a crafted path or id.
Cause: the upload endpoint accepted a path (or built one from unverified input) instead of deriving it from the verified token.
Fix: the object path is always constructed server-side as {authUser.userId}/.... The client sends bytes and a row id — never a path. Layer 3 (storage.objects RLS) would also block this for user-JWT calls, but service_role uploads bypass it, which is exactly why the path must be derived, not received.
5. Signing URLs without an ownership check → IDOR on read
Symptom: user B fetches /v1/items/<A's-id>/photo-url and receives a working signed URL for A's private photo.
Cause: the endpoint went straight to serviceClient.createSignedUrl with the client's id. service_role bypasses RLS, so Storage happily signs for any object.
Fix: prove ownership before signing — the RLS-enforced userClient lookup (404 when the row is not the caller's), plus the path-prefix check as a final guard. Never let a service_role Storage call be reachable without an ownership decision in front of it.
6. ON DELETE CASCADE never deletes Storage objects → orphans
Symptom: storage usage (and cost) only ever grows; deleted users' photos still exist in the bucket — a data-retention and privacy problem, not just a billing one.
Cause: assuming the DB cascade covers files. Storage objects live outside Postgres; deleting auth.users → profiles → items rows leaves every uploaded object in place.
Fix: delete objects explicitly at both deletion points (row delete + account delete, Implementation §5), and run a scheduled orphan sweep (Common Customizations #4) to catch best-effort failures.
7. Re-running the bucket migration errors or clobbers dashboard settings
Symptom: supabase db push fails with a duplicate-key error on storage.buckets, or a re-applied migration silently resets a size limit that was tuned in the dashboard.
Cause: the migration used a bare insert (fails on re-apply) or an upsert/update (overwrites console-set properties).
Fix: insert ... on conflict (id) do nothing — records the bucket's existence idempotently without touching an existing bucket's properties. Policies pair drop policy if exists with create for the same reason (see the migration discipline in infra-supabase).
8. Letting the client upload directly to Storage with the anon key
Symptom: the iOS app gains a Supabase dependency and embedded keys; server-side validation (size, type, ownership, rate limits) is skipped; the bucket layout becomes public client knowledge.
Cause: following generic Supabase tutorials that call supabase.storage.upload() from the app.
Fix: on this stack the client talks only to your Worker (auth-supabase-anonymous); uploads go through the Worker endpoint. The storage.objects RLS policies exist as insurance, not as an invitation to build a client-direct path.
9. Logical prefixes in a stored key array break ownership checks
Symptom: some images load fine on the device that took them but return 404 after a reinstall or on a second device — in Trovy, every receipt photo (2026-10 audit).
Cause: the client tagged entries inside the key array with a logical prefix (receipt:p/<uuid>.png in photo_keys) to mark their role. The delete path stripped the prefix, but the read path's ownership check matched the bare key exactly (contains("photo_keys", [key])) — it can never match the prefixed form. The original device never noticed: it served the bytes from its local cache.
Fix: normalize prefixes in every place that maps an array entry to an object — ownership check (match key or receipt:key), delete, account deletion, export — ideally through one shared helper. Better still, keep roles out of the key string (a parallel receipt_keys column or an object array { key, role }), so the stored value always is the object key. Test reads from a fresh install, not just the capturing device.
Production Readiness Checklist
- Bucket is private with
file_size_limit+allowed_mime_typesset in the migration storage.objectsowner-folder RLS applied for all four verbs- All Storage operations sit behind an RLS-enforced ownership gate; no
service_roleStorage call is reachable without one - Object paths are derived server-side from the verified JWT — nothing client-supplied reaches a Storage call
- DB stores object paths only; signed URLs are minted per read with a short TTL and never logged or persisted
- Row-delete and account-delete flows remove the corresponding objects; a scheduled orphan sweep backs them up
- Account deletion order: keys collected while rows still exist (flat layouts) → user deleted → objects removed
- Every image path (incl. role-prefixed keys) loads from a fresh install, not only the capturing device
- The two-token IDOR test passes: cross-user photo-url 404 · cross-user upload 404 · direct Storage read with a foreign user JWT blocked
More in Backend
- AWS Infrastructure with CDKFreeProduction-ready AWS infrastructure using CDK — App Runner for HTTP services, Aurora Serverless for database, Cognito for auth, Lambda for serverless functions, and S3 for storage
- Supabase Row Level Security (RLS) Data IsolationFreeProduction-ready Row Level Security patterns for a Supabase + Cloudflare Workers stack — the database-layer isolation primitive that makes cross-user data leaks impossible even when a route is buggy or someone talks to PostgREST directly. Covers the owner-isolation table skeleton (1:1 profile + one-to-many children), the five load-bearing RLS rules ((select auth.uid()) wrapping, TO authenticated, forward-set child subqueries, indexing policy columns, trusting only auth.uid()/app_metadata), the service_role (bypass RLS) vs user-JWT (RLS enforced) client split, server-side owner_id injection, public read-only tables whose writes funnel through service_role, and a two-token cross-access isolation test that must return 403/empty.
- Supabase + Cloudflare Workers InfrastructureFreeProduction-ready serverless infrastructure using Supabase (managed Postgres + Auth + Storage) and Cloudflare Workers (Hono backend) — the zero-VPC, zero-server counterpart to the AWS CDK stack. Covers Supabase CLI migrations, the immutable-region rule, secret management (.dev.vars / Cloudflare Secret, never git), Workers deployment via wrangler, and dual-mode JWT verification (JWKS ES256 preferred, HS256 fallback).
- TikTok Ad Attribution with RevenueCat (Dual-Channel)ProFull-depth TikTok attribution for RevenueCat apps — client SDK reports the purchase moment, your backend forwards renewals/refunds from RC webhooks to TikTok Events API. Ships the complete Workers forwarder, device-identifier relay chain (ATT → RC → webhook → Events API), ASN wiring, and a battle-tested verification SOP (iOS + Cloudflare Workers)
- TikTok Ad Attribution & TrackingProTikTok ad attribution module with ATT permission flow, standard/custom event tracking, and debug logging — bridges to TikTok Business SDK via closure injection. Includes complete integration guide with credentials setup, App Store compliance, and campaign launch workflow (iOS only)