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()andcreateSignedUrl(), 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 sourceIf this returns 403, check whether the avatars bucket is actually set to Public in your dashboard.
How to check:
- Go to Supabase Dashboard → Storage → Buckets
- Look at the "Public" toggle for your bucket
- 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:
- Supabase Dashboard → Storage → Policies
- Look for a policy on
storage.objectsthat covers theSELECToperation
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 componentdownload() 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
getPublicUrlorcreateSignedUrlusage matches the bucket visibility - [ ] Added a
SELECTpolicy onstorage.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.