RORK LABJP
PLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to missEXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration planAPPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spentEXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInputEAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updatesPLAY — Google Play's target API level 36 requirement took effect yesterday, August 31. From today, new apps and updates must target Android 16VISIBILITY — Apps still on API 35 stay listed but disappear for users on newer Android versions. No error is raised; new installs simply fade, which makes the change easy to missEXTENSION — If you missed the deadline, an extension through November 1, 2026 can be requested in Play Console — best filed alongside a concrete migration planAPPLE — On the Apple side, the event lands September 9 and iOS 27 is reported to ship September 14. Testing generated apps on iOS 27 hardware before release week is time well spentEXPO — Expo released expo-paste-input on August 28, a native module that brings image, GIF, and sticker paste to React Native TextInputEAS — EAS Observe reached general availability on August 20, putting crash and performance monitoring on the same EAS platform as builds and updates
Articles/Dev Tools
Dev Tools/2026-05-13Intermediate

Supabase Storage Returns 403 After Image Upload in Rork Apps ─ Fixing RLS Policies and Bucket Settings

Uploaded an image to Supabase Storage in your Rork app, but the URL returns 403 Forbidden? This guide covers the three most common causes: bucket Public/Private settings, missing RLS SELECT policies, and the getPublicUrl vs createSignedUrl confusion — with working code examples.

Supabase33Storage2RLS3403 errortroubleshooting66image upload

You successfully uploaded the image. The Supabase console shows the file is there. But when you try to display it in your app, you get a blank screen — and the logs show 403 Forbidden.

I've been building apps independently since 2014, with products accumulating over 50 million downloads. One pattern I've seen repeat itself constantly: permission errors almost always come from configuration mismatches, not broken code. The code is usually fine — it's the combination of settings on both the dashboard and code sides that don't align.

With Rork, the AI generates correct Supabase Storage API calls, but the bucket settings and Row Level Security (RLS) policies still need to be configured in the Supabase dashboard. This gap is where most 403 errors originate. Here are the three main patterns and how to fix each one.

The 3 Patterns Behind 403 Errors

When Supabase Storage returns 403, the cause almost always falls into one of these categories:

  • Pattern 1: The bucket is Private, but you're calling getPublicUrl() expecting a public URL
  • Pattern 2: RLS is enabled on storage.objects, but there's no policy granting SELECT access
  • Pattern 3: Confusion between getPublicUrl() and createSignedUrl(), or a timing issue right after upload

Let's walk through each one.

Pattern 1: Bucket Visibility Doesn't Match Your Code

Supabase Storage buckets are either Public or Private. When Rork generates code for image display, it typically uses getPublicUrl():

// Code Rork commonly generates
const { data } = supabase.storage
  .from('avatars')
  .getPublicUrl(`${userId}/profile.jpg`);
 
// Use data.publicUrl as the Image source

If this returns 403, check whether the avatars bucket is actually set to Public in your dashboard.

How to check:

  1. Go to Supabase Dashboard → Storage → Buckets
  2. Look at the "Public" toggle for your bucket
  3. If it's off, either enable it or switch to createSignedUrl() in your code

Using signed URLs for Private buckets:

// For Private buckets, use createSignedUrl
const { data, error } = await supabase.storage
  .from('avatars')
  .createSignedUrl(`${userId}/profile.jpg`, 3600); // Expires in 1 hour
 
if (error) {
  console.error('Failed to get signed URL:', error.message);
  return;
}
 
if (data?.signedUrl) {
  setImageUrl(data.signedUrl);
}

For images that appear frequently (like profile photos), set a longer expiry — 24 hours to 7 days is practical. For sensitive content, keep expiry short and regenerate on demand.

Pattern 2: Missing SELECT Policy in RLS

Even with a Public bucket, if RLS is enabled on storage.objects, any operation not explicitly covered by a policy will be denied. This includes reads.

Where to check:

  1. Supabase Dashboard → Storage → Policies
  2. Look for a policy on storage.objects that covers the SELECT operation

If none exists, add one:

Allow anyone to read files from a public bucket:

CREATE POLICY "Public avatars are viewable by everyone"
ON storage.objects
FOR SELECT
USING (bucket_id = 'avatars');

Allow authenticated users to read only their own files:

CREATE POLICY "Users can view their own avatar"
ON storage.objects
FOR SELECT
TO authenticated
USING (
  bucket_id = 'avatars' AND
  (storage.foldername(name))[1] = auth.uid()::text
);

storage.foldername(name) is a Supabase-specific function that returns the first path segment of the file name. When you upload files as userId/filename.jpg, this gives you a clean ownership check.

Important: INSERT and SELECT policies are configured separately. "I can upload but can't view" almost always means you added an INSERT policy and forgot the SELECT policy.

Pattern 3: Mixing Up download(), getPublicUrl(), and createSignedUrl()

The download() confusion:

// ❌ Wrong: trying to get a URL using download()
const { data, error } = await supabase.storage
  .from('avatars')
  .download(`${userId}/profile.jpg`);
// data is a Blob — not a URL string you can pass to Image
 
// ✅ Correct: use getPublicUrl or createSignedUrl for URL strings
const { data } = supabase.storage
  .from('avatars')
  .getPublicUrl(`${userId}/profile.jpg`);
// data.publicUrl is a string — pass it directly to your Image component

download() returns binary data (a Blob). To display an image from Supabase Storage, use getPublicUrl() for public buckets or createSignedUrl() for private ones.

Timing issues right after upload:

In rare cases, trying to fetch the URL immediately after upload can cause a brief 403 before the file propagates. Always wait for the upload response before requesting the URL:

const uploadAndGetUrl = async (file: File, path: string): Promise<string> => {
  // Step 1: Upload the file
  const { error: uploadError } = await supabase.storage
    .from('avatars')
    .upload(path, file, { upsert: true });
 
  if (uploadError) {
    throw new Error(`Upload failed: ${uploadError.message}`);
  }
 
  // Step 2: Get the URL only after successful upload
  const { data } = supabase.storage
    .from('avatars')
    .getPublicUrl(path);
 
  return data.publicUrl;
};

Additional Pitfalls When Using Rork AI-Generated Code

Bucket Name Typos Across Generated Files

When Rork generates code across multiple screens, the bucket name can silently differ between the upload screen and the display screen:

// Upload screen
await supabase.storage.from('user-avatars').upload(path, file);
 
// Display screen (bucket name is wrong — root cause of 403)
const { data } = supabase.storage
  .from('avatars') // ❌ Should be 'user-avatars'
  .getPublicUrl(path);

Prevent this by centralizing bucket names as constants:

// constants/storage.ts
export const STORAGE_BUCKETS = {
  AVATARS: 'user-avatars',
  POSTS: 'post-images',
} as const;
 
// Consistent across all files
await supabase.storage.from(STORAGE_BUCKETS.AVATARS).upload(path, file);
const { data } = supabase.storage.from(STORAGE_BUCKETS.AVATARS).getPublicUrl(path);

Leading Slash in File Paths

// ❌ Leading slash causes issues in some versions
const path = `/${userId}/profile.jpg`;
 
// ✅ Always use paths without a leading slash
const path = `${userId}/profile.jpg`;

Supabase Storage paths should consistently omit the leading slash. Behavior can vary across versions, so standardizing this early saves debugging time later.

Checklist Before Moving On

  • [ ] Verified the bucket's Public/Private setting in the Supabase dashboard
  • [ ] Confirmed getPublicUrl or createSignedUrl usage matches the bucket visibility
  • [ ] Added a SELECT policy on storage.objects (or confirmed RLS is disabled)
  • [ ] Upload and display code use identical bucket names and file paths
  • [ ] Opened the URL directly in a browser and confirmed no 403

Going through these in order will surface the cause in most cases.

For the initial Supabase Storage setup in Rork, Rork + Supabase Storage Image Upload Guide walks through the fundamentals. For RLS design patterns more broadly, Supabase Edge Functions and RLS API Design goes deeper into the permission architecture.

Debugging permissions takes patience — it's a matter of isolating which layer is rejecting the request. Supabase lets you test RLS policies directly with SQL in the dashboard, which is useful for confirming your policy logic before revisiting the app code.

Share

Thank You for Reading

Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $15 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

Dev Tools2026-05-11
That 'CORS Error' in Your Rork + Supabase Edge Function? It's Probably Something Else
Seeing CORS errors when calling Supabase Edge Functions from your Rork app? React Native apps don't enforce CORS — the real cause is usually something else entirely. Here's how to diagnose and fix it.
Dev Tools2026-04-18
Why Supabase Data Isn't Loading in Your Rork App — Diagnosis by Pattern
Systematically diagnose why Supabase data won't load in your Rork app. Covers the most common causes: RLS misconfiguration, wrong API keys, async handling mistakes, and table name typos — with code examples.
Dev Tools2026-08-18
Version code 1 has already been used — who owns that number, app.json or EAS?
When a Play upload stops on versionCode, check where the number actually lives before bumping it. A one-command way to tell whether app.json, EAS, or build.gradle wins.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →