◉RORK LABJP
●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…●GPT-6.1 SOL — Added to Rork's model menu (09-29) with Low, Medium and High effort; included on Pro and Max●SONNET 5.5 — Claude Sonnet 5.5 is selectable: strong at UI design and faster than Sonnet 5 (09-28)●SDK 58 — Beta since 09/15 with no stable date yet; running projects in Expo Go has required login since 09/03●WIDGET — A write-up traces an iOS widget that renders black only in production builds●OBSERVE — EAS Observe now records native crashes too (SDK 57.0.21 or later, 10/07)●NEW — My App Open Ad Only Showed When the App…
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.

Supabase37StorageRLS3403 errortroubleshooting69image 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 $10 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-10-07
Japanese Headings Won't Turn Bold on Android: How to Trace and Fix a fontWeight That Does Nothing
A troubleshooting note for Rork apps where headings look bold on iPhone but stay thin on Android. How to tell the symptoms apart, pick weights through fontFamily, and verify the fix.
⬡ 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.
⬡ Dev Tools2026-05-26
iOS Foreground Notifications Disappear in Expo — A Practical Fix Guide
A focused walkthrough of why iOS silently drops push notifications when the app is in the foreground, and how to wire setNotificationHandler correctly for iOS 14+ with expo-notifications.