RORK LABJP
DEADLINE — From August 31, 2026, Google Play requires target API level 36 (Android 16) or higher for both new apps and updates to existing ones. Twelve days leftEXTENSION — If you cannot make the date, the deadline extension form in Play Console buys you until November 1. The extension is not automatic, so the request itself has to land before August 31TARGET SDK — Even for the Expo and React Native apps Rork produces, the targetSdkVersion is yours to verify. A template pinned to an older SDK will not meet the requirement on its ownRORK MAX — Where the original Rork emits React Native and Expo, Rork Max generates native Swift, covering iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageFUNDING — A $15M seed round led by Left Lane Capital was announced on April 9, alongside the acquisition of app builder Paperline to bring in engineering talentiOS — Developer beta 6 of iOS 27 arrived on August 17 with the autumn release drawing closer. If you ship native Swift output, the new OS timeline feeds straight into your release planDEADLINE — From August 31, 2026, Google Play requires target API level 36 (Android 16) or higher for both new apps and updates to existing ones. Twelve days leftEXTENSION — If you cannot make the date, the deadline extension form in Play Console buys you until November 1. The extension is not automatic, so the request itself has to land before August 31TARGET SDK — Even for the Expo and React Native apps Rork produces, the targetSdkVersion is yours to verify. A template pinned to an older SDK will not meet the requirement on its ownRORK MAX — Where the original Rork emits React Native and Expo, Rork Max generates native Swift, covering iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessageFUNDING — A $15M seed round led by Left Lane Capital was announced on April 9, alongside the acquisition of app builder Paperline to bring in engineering talentiOS — Developer beta 6 of iOS 27 arrived on August 17 with the autumn release drawing closer. If you ship native Swift output, the new OS timeline feeds straight into your release plan
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 $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-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 →