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.

Free
Free with a ShipSwift account.

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

+70

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 in supabase/migrations/, reproducible on any project via supabase db push
  • Server-side uploads only — the client POSTs bytes to your Worker; the Worker uploads with the service_role client. 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}/... where userId comes from the verified JWT, never from the request. Ownership is checked through RLS before any service_role Storage operation runs
  • Defense in depth — RLS policies on storage.objects isolate 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
SupabaseStoragesigned URLprivate bucketservice_roleRLSIDORstorage.objectsfile uploadCloudflare WorkersHonomigrationsorphan cleanupimage upload

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 object

Defense in depth — three independent guards, any one of which stops an IDOR:

LayerGuardWhat it stops
1. Path derivationobject path is built from authUser.userId (verified JWT), never read from the requesta client naming another user's folder in an upload
2. Ownership gateuserClient SELECT under RLS before any service_role Storage op — "not found" == "not yours" → 404signing/reading/writing objects for rows the caller does not own
3. storage.objects RLSowner-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: the service_role client bypasses all RLS, on storage.objects included. Once you call serviceClient, 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:

DecisionChoiceWhy
Bucket visibilityPrivatepublic buckets serve every object to anyone with the URL — no token, no expiry
Upload pathServer-side, service_roleclient stays dependency-free; server validates size/type; no Supabase key ever ships in the app
Read pathSigned URL, minted on demandtime-limited bearer link; DB stores only the object path
Path convention{userId}/{itemId}.jpgowner is the first folder segment → trivially checkable by both the Worker and storage.objects RLS
Signed URL TTL3600 slong 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, a file_size_limit, and an allowed_mime_types allowlist
  • Migration is idempotent: bucket uses on conflict (id) do nothing; policies use drop policy if exists + create
  • storage.objects owner-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:push applies cleanly to the linked project

2. Endpoints

  • Upload endpoint: RLS ownership gate → Worker-level size/type validation → service_role upload → path persisted via userClient
  • Object path is always {userId}/... with userId from 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 serviceClient only after an RLS-enforced ownership check
  • The service_role key 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_types set in the migration
  • storage.objects owner-folder RLS applied for all four verbs
  • All Storage operations sit behind an RLS-enforced ownership gate; no service_role Storage 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