<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[AlgoSmiths Engineering]]></title><description><![CDATA[Engineering guides from the AlgoSmiths team in New Delhi: Supabase security, databases and getting AI-built apps ready for real users.]]></description><link>https://algosmiths.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6adc191da0df6fe90b34a54e/9ff19085-e5aa-4d65-b53b-390ed8c3e7e4.png</url><title>AlgoSmiths Engineering</title><link>https://algosmiths.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 03 Nov 2026 10:16:37 GMT</lastBuildDate><atom:link href="https://algosmiths.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Securing Supabase Storage Buckets: Private Files and Signed URLs]]></title><description><![CDATA[Every Supabase Storage bucket starts out private, and switching one to public hands every file inside it to anyone who has the URL. Securing Supabase…]]></description><link>https://algosmiths.hashnode.dev/securing-supabase-storage-buckets-private-files-and-signed-urls</link><guid isPermaLink="true">https://algosmiths.hashnode.dev/securing-supabase-storage-buckets-private-files-and-signed-urls</guid><category><![CDATA[supabase]]></category><category><![CDATA[Security]]></category><category><![CDATA[backend]]></category><dc:creator><![CDATA[samrat]]></dc:creator><pubDate>Tue, 03 Nov 2026 06:35:51 GMT</pubDate><content:encoded><![CDATA[<p>Every Supabase Storage bucket starts out private, and switching one to public hands every file inside it to anyone who has the URL. Securing Supabase Storage comes down to three decisions: which buckets are public, what policies you write on the <code>storage.objects</code> table, and how long your signed URLs stay valid.</p>
<p>This guide is for founders and small teams running a Supabase app, including apps generated with Lovable, Bolt or Cursor, where uploads work but nobody has checked who can read the files afterwards. It covers the public-or-private choice, the policies files need, how signed URLs behave once a CDN is involved, and how to test a bucket from outside your own app. If your database tables are the open question rather than your files, start with our walkthrough of <a href="https://algosmiths.com/blog/post/supabase-row-level-security">finding and closing row-level security gaps</a>.</p>
<h2>Key takeaways</h2>
<ul>
<li><p>Buckets are <strong>private by default</strong>. Marking one public removes read access control entirely, so a long random filename is obscurity rather than protection.</p>
</li>
<li><p>File permissions are policies on the <code>storage.objects</code> table, not a bucket setting. Locking down your database tables does nothing for your files.</p>
</li>
<li><p>A signed URL is a bearer token: whoever holds it can use it until it expires, and it is signed with a key you cannot rotate yourself.</p>
</li>
<li><p>A cached signed-URL response can outlive its token, so deleting the object is the only dependable way to cut off access.</p>
</li>
<li><p>One <code>select</code> policy covers both downloading a file and listing a folder, which is how users end up able to enumerate a bucket.</p>
</li>
</ul>
<h2>When should a bucket be public, and when private?</h2>
<p>Public when the file is meant for everyone who can see the page it appears on, private for anything that belongs to one account. Supabase's <a href="https://supabase.com/docs/guides/storage/buckets/fundamentals">bucket documentation</a> is direct about what public means: access control is bypassed for retrieving and serving files, so anyone with the URL gets the file, with no session and no token.</p>
<p>Profile pictures, marketing images and blog media are reasonable public-bucket content. Invoices, contracts, identity documents, CSV exports and anything a customer uploaded about their own business are not.</p>
<table>
<thead>
<tr>
<th>Behaviour</th>
<th>Public bucket</th>
<th>Private bucket</th>
</tr>
</thead>
<tbody><tr>
<td>Who can read a file</td>
<td>Anyone with the URL</td>
<td>Whoever a policy allows</td>
</tr>
<tr>
<td>How files are served</td>
<td>Permanent <code>/object/public/</code> URL</td>
<td>Signed URL or authenticated request</td>
</tr>
<tr>
<td>CDN caching</td>
<td>One cache entry per file</td>
<td>One cache entry per token</td>
</tr>
<tr>
<td>Cutting off access</td>
<td>Delete or move the file</td>
<td>Delete the file; expiry alone is not enough</td>
</tr>
<tr>
<td>Best for</td>
<td>Avatars, logos, public media</td>
<td>Documents, exports, per-account files</td>
</tr>
</tbody></table>
<h3>What a public bucket still protects</h3>
<p>Uploads, deletes, moves and copies. Public changes reads only, so your insert and delete policies still run, and a public bucket with no insert policy still rejects uploads from the browser.</p>
<p>That split catches teams out in both directions. Making a bucket public does not open it for writes, and making it private later does not secure URLs that are already in circulation.</p>
<h3>Why a random filename is not access control</h3>
<p>Public object URLs leak the way all URLs leak: into screenshots, support tickets, referrer headers, shared documents and the occasional search index. Once one is out, the file is out, and the only remedy is to move or delete it.</p>
<p>The useful question for each bucket is what happens if one of its URLs ends up in a public post. If the answer is "nothing much", public is fine. If you are also still deciding where files should live and what to check on the way in, our guide to <a href="https://algosmiths.com/blog/post/secure-file-uploads-for-saas">secure file uploads, limits and storage costs</a> covers the upload side of the same problem.</p>
<h2>How do Supabase Storage policies work?</h2>
<p>Every file has a row in the <code>storage.objects</code> table, and Storage checks row-level security policies on that table for each operation. Supabase's <a href="https://supabase.com/docs/guides/storage/security/access-control">Storage access control guide</a> notes that uploads are refused outright until a policy exists, so the usual cause of a failing upload is a missing policy rather than a bucket setting.</p>
<p>Two columns do most of the work. <code>bucket_id</code> scopes a policy to one bucket, and <code>name</code> holds the object's full path. The helper function <code>storage.foldername(name)</code> returns that path as an array, so element one is the top-level folder.</p>
<p>The convention that holds up is one folder per user, named with their user id, with the path chosen by your code rather than by the browser.</p>
<pre><code class="language-sql">create policy "Users upload into their own folder"
on storage.objects for insert to authenticated
with check (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);

create policy "Users read their own files"
on storage.objects for select to authenticated
using (
  bucket_id = 'documents'
  and (storage.foldername(name))[1] = (select auth.uid()::text)
);
</code></pre>
<p>Both clauses matter. <code>with check</code> decides what may be written, and <code>using</code> decides which existing rows may be read or changed, which is why an upload rule and a download rule are two separate statements.</p>
<h3>Which policies does each operation need?</h3>
<ul>
<li><p><strong>Upload:</strong> an <code>insert</code> policy on <code>storage.objects</code>.</p>
</li>
<li><p><strong>Overwrite with upsert:</strong> <code>insert</code> plus <code>select</code> and <code>update</code>.</p>
</li>
<li><p><strong>Download from a private bucket, or sign a URL:</strong> <code>select</code>.</p>
</li>
<li><p><strong>Delete:</strong> <code>delete</code>, scoped the same way as the read rule.</p>
</li>
</ul>
<p>Write them one per operation rather than as a single <code>for all</code> policy. A catch-all rule hides which clause was meant to govern uploads and which was meant to govern reads, and it is the first thing to get too loose when someone asks an AI builder to fix a failing request.</p>
<h3>Stop users from listing the whole bucket</h3>
<p>One SQL privilege is used by more than one Storage action: downloading an object and listing a folder both go through <code>select</code>. A read policy written for downloads therefore also lets the client enumerate every path it matches.</p>
<p>Supabase provides operation-aware helpers for this, so a listing rule can be narrower than a download rule.</p>
<pre><code class="language-sql">create policy "Users list only their own objects"
on storage.objects for select to authenticated
using (
  storage.allow_only_operation('object.list')
  and owner_id = (select auth.uid()::text)
);
</code></pre>
<p>This matters most where the filenames are themselves information. A list of paths like <code>exports/acme-corp-q1.csv</code> tells a competitor who your customers are, even if every download is refused.</p>
<h2>How long should a signed URL last?</h2>
<p>Long enough for the browser to fetch the file and no longer, which usually means seconds or minutes. A signed URL is a temporary address that authorises access to one object, and <code>createSignedUrl</code> takes its lifetime in seconds.</p>
<pre><code class="language-ts">const { data, error } = await supabase.storage
  .from('documents')
  .createSignedUrl(`${userId}/contract.pdf`, 60)
</code></pre>
<p>Sixty seconds is plenty for an image the page renders on load. A download link a user clicks from an email needs minutes, not hours. A document left open all afternoon needs a fresh URL on refresh rather than a longer expiry.</p>
<p>Keep signed URLs out of logs, analytics events and anything that forwards a full URL to a third party. They are credentials in URL form.</p>
<h3>Expiry is not revocation</h3>
<p>Storage signs these URLs with a dedicated internal key, separate from your Auth JWT signing key. Supabase's documentation on <a href="https://supabase.com/docs/guides/storage/serving/downloads">serving assets from Storage</a> states that rotating, disabling or switching auth keys does not affect URLs that are already signed, and that revoking them means contacting Supabase support.</p>
<p>Treat the object, not the token, as the thing you control. If a file has to stop being reachable now, delete it or move it to a new path.</p>
<h3>Signed URLs and the CDN cache</h3>
<p>Smart CDN caching is enabled from the Pro plan up, and it treats every unique <code>?token=</code> value as its own cache key. Two consequences follow, both set out in <a href="https://supabase.com/docs/guides/storage/cdn/smart-cdn">Supabase's Smart CDN guide</a>.</p>
<p>A new signed URL on every page view means a cache miss on every page view, with the origin serving everything. Re-using one URL for the life of its token is what makes caching work at all.</p>
<p>And a cached response can keep being served for a URL whose token has already expired, because cache duration and token expiry are independent. Deleting the object invalidates every cached entry for it, though that change can take up to a minute to propagate.</p>
<p>So if a file has no real per-account restriction, a public bucket caches better and removes the signing work altogether. Choose private because the content needs it, not by reflex.</p>
<h2>Supabase Storage security settings to check before launch</h2>
<p>Six settings account for most of the distance between a prototype and a bucket you would let strangers upload to.</p>
<ol>
<li><strong>Bucket visibility.</strong> Open the Storage dashboard and read the public flag on every bucket, including the ones an AI builder created for you.</li>
<li><strong>Global file size limit.</strong> Free projects cap a single file at 50 MB, while Pro and Team allow up to 500 GB, per the <a href="https://supabase.com/docs/guides/storage/uploads/file-limits">Storage limits documentation</a>. Set the global ceiling to the largest file your product genuinely accepts.</li>
<li><strong>Per-bucket restrictions.</strong> Give each bucket a smaller size limit and an allowed MIME type list, so a misconfigured client cannot get past the checks in your API.</li>
<li><strong>Path convention.</strong> Generate keys on the server under the owner's user id, and keep customer names out of paths, since every path shows up in logs.</li>
<li><strong>Service key placement.</strong> The service key bypasses storage policies completely. It belongs on a server you control, never in front-end code.</li>
<li><strong>Image transformations.</strong> Available on Pro and above, and billable per use. Switch the feature off if you are not using it.</li>
</ol>
<h2>How do you test a bucket from outside your app?</h2>
<p>Call the Storage API directly with your publishable key, because your own front end is the one client that behaves. Two checks find most real problems.</p>
<p>First, open a public object URL in a private window while signed out. If the file loads, that bucket is public, whatever your interface suggests.</p>
<p>Second, ask the API to list a bucket with no user signed in:</p>
<pre><code class="language-bash">curl -X POST "https://YOUR_PROJECT_REF.supabase.co/storage/v1/object/list/documents" \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Authorization: Bearer YOUR_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"prefix":"","limit":100}'
</code></pre>
<p>An empty array or an error means the bucket is closed to signed-out visitors. A list of filenames means anyone on the internet can read your storage layout. Then repeat the request with a second account's token, asking for the first account's folder, which is the test that catches a policy matching on the wrong column.</p>
<p>Storage is one item on a longer pre-launch list, and our <a href="https://algosmiths.com/blog/post/ai-built-app-production-readiness">launch checklist for AI-built apps</a> covers the rest.</p>
<h2>Common mistakes with Supabase Storage</h2>
<ul>
<li><p>Treating a database backup as a backup of your files. Database backups do not include stored objects, as <a href="https://algosmiths.com/blog/post/database-backups-for-startups">the gaps a database backup leaves</a> explains.</p>
</li>
<li><p>Making a bucket public to fix a broken image, then never changing it back.</p>
</li>
<li><p>Letting the browser choose the storage path, so a user can write into someone else's folder.</p>
</li>
<li><p>Signing URLs that last a week because a one-minute URL expired during testing.</p>
</li>
<li><p>Assuming an expired token makes a file unreachable, when a cached response may still be served.</p>
</li>
<li><p>Writing one broad <code>select</code> policy, then finding the client can list every object it matches.</p>
</li>
<li><p>Reaching for the service key on the client to make permission errors go away.</p>
</li>
</ul>
<h2>Frequently asked questions</h2>
<h3>Is a public bucket safe for profile pictures?</h3>
<p>Usually, if the picture is already visible to other users of your product. The risk is not the image but what sits beside it in the same bucket, so keep public buckets for genuinely public content and never mix documents in. Uploads and deletes still need policies either way.</p>
<h3>Can I make a public bucket private later?</h3>
<p>Yes, and the files stop being reachable through their public URLs as soon as you do. Anything that embedded those URLs needs to switch to signed URLs first, otherwise images in your own app break. Copies of those links held elsewhere also stop working, which is the point.</p>
<h3>Do I need storage policies if every upload goes through my backend?</h3>
<p>You need them unless no client ever holds a publishable key with storage access. While the Storage API is reachable with that key, the browser route exists whether or not your app uses it, and a feature added later by an AI builder may well start using it.</p>
<h3>Why does my upload fail with a row-level security error?</h3>
<p>Almost always a missing or mismatched <code>insert</code> policy. Check the bucket name in the policy, confirm the path your code generates starts with the folder the policy expects, and confirm the request is authenticated, because a policy limited to the authenticated role never matches an anonymous call.</p>
<h3>Does signing URLs slow the app down?</h3>
<p>Each signed URL is an API call, which is fine for one document and expensive for a gallery of 200 thumbnails. Sign in batches with <code>createSignedUrls</code>, re-use each URL for the life of its token, and keep genuinely public assets in a public bucket.</p>
<h2>Before your first real upload</h2>
<p>Go bucket by bucket and decide whether the content is public or per-account. Write the policies for the private ones, generate paths on the server, keep expiry short, and remember that deleting the object is the only reliable kill switch. Then run the list request against your own project and see what a stranger sees.</p>
<p>If you would rather have senior engineers close these gaps for you, AlgoSmiths works on fixed-price projects with a working demo every week. Our <a href="https://algosmiths.com/services/backend-api">backend and API engineering work</a> is where storage, policies and the services around them get built.</p>
<hr />
<p><em>Originally published on the <a href="https://algosmiths.com/blog/post/securing-supabase-storage-buckets">AlgoSmiths blog</a>. AlgoSmiths builds web, mobile and AI products on fixed-price contracts, and takes apps built with Lovable, Bolt or Cursor to production. <a href="https://algosmiths.com/services">See our services</a>.</em></p>
]]></content:encoded></item><item><title><![CDATA[Supabase Row Level Security: Fix Data Leaks in Lovable Apps]]></title><description><![CDATA[Supabase Row Level Security for Lovable apps: how tables end up exposed to the anon key, how to test yours in ten minutes, and policies that fix it.]]></description><link>https://algosmiths.hashnode.dev/supabase-row-level-security-fix-data-leaks-in-lovable-apps</link><guid isPermaLink="true">https://algosmiths.hashnode.dev/supabase-row-level-security-fix-data-leaks-in-lovable-apps</guid><category><![CDATA[supabase]]></category><category><![CDATA[postgres]]></category><category><![CDATA[Security]]></category><category><![CDATA[webdev]]></category><category><![CDATA[lovable]]></category><dc:creator><![CDATA[samrat]]></dc:creator><pubDate>Tue, 27 Oct 2026 15:52:42 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6adc191da0df6fe90b34a54e/c70728ca-10e0-4211-be9d-308f233b634d.jpg" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Supabase Row Level Security (RLS) is a PostgreSQL feature that decides, row by row, which records each request is allowed to read or change. In a Lovable or Bolt app the browser talks straight to your database over a public API, so RLS is usually the only thing standing between a stranger and your users' data. With it switched off, every row in the table is readable. If it is your sign-in flow that is broken instead, our guide to <a href="https://algosmiths.com/blog/post/supabase-auth-in-production">redirects, auth emails and sessions</a> covers that side.</p>
<p>This guide is for founders and small teams who built on Supabase with an AI builder and need to know whether their tables are exposed. It covers what RLS does, why generated apps leak, how to test your own tables in about ten minutes, and working policies for user-owned rows, team rows and admin access. Data access is also the first item on our <a href="https://algosmiths.com/blog/post/ai-built-app-production-readiness">pre-launch checklist for AI-built apps</a>.</p>
<h2>Key takeaways</h2>
<ul>
<li>RLS is off by default on new tables, and an unprotected table in an exposed schema can be read or written by anyone holding your public API key.</li>
<li><strong>Grants and policies are separate checks.</strong> Adding a policy does not remove privileges already granted to the <code>anon</code> and <code>authenticated</code> roles.</li>
<li>You can test your own app in ten minutes with two accounts, a logged-out browser window and one <code>curl</code> command.</li>
<li>Most tables need four policies, one each for select, insert, update and delete, rather than a single catch-all rule.</li>
<li>Index every column your policies filter on. In Supabase's own measurements that took a query on a 100,000-row table from 171ms to under 0.1ms.</li>
</ul>
<h2>What does Supabase Row Level Security actually do?</h2>
<p>RLS adds an invisible <code>WHERE</code> clause to every query that touches a table. You write the clause once as a policy, and PostgreSQL applies it to every request, whether it comes from your app, a REST call or a database client.</p>
<p>A policy like this one on a <code>projects</code> table lets signed-in users see only their own rows:</p>
<pre><code class="language-sql">create policy "Owners read their own projects"
on public.projects for select
to authenticated
using ( (select auth.uid()) = owner_id );
</code></pre>
<p>The <code>using</code> clause decides which existing rows a statement can touch. A separate <code>with check</code> clause decides what a new or updated row may look like, which is what stops a user inserting a row that belongs to someone else.</p>
<p>Once RLS is enabled, the default is deny. <a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html">PostgreSQL's row security documentation</a> explains that with row security on and no policy present, no rows are visible or modifiable. Policies then open access back up: permissive policies combine with <code>OR</code>, restrictive policies with <code>AND</code>.</p>
<p>This is authorization, not authentication. Supabase Auth proves who the user is; RLS decides what that user may reach. Our <a href="https://algosmiths.com/blog/post/authentication-for-saas-startups">guide to auth and access control for SaaS products</a> covers the wider picture.</p>
<h3>Grants and policies are two different checks</h3>
<p>PostgreSQL checks table grants first and policies second, and this is where most Supabase apps go wrong. <a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase's Row Level Security documentation</a> notes that on existing projects a new table in <code>public</code> starts with select, insert, update and delete already granted to <code>anon</code>, <code>authenticated</code> and <code>service_role</code>, and that adding policies does not take those grants back.</p>
<p>Securing a table is therefore three statements, not one:</p>
<pre><code class="language-sql">alter table public.projects enable row level security;
revoke all on table public.projects from anon, authenticated;
grant select, insert, update, delete
  on table public.projects to authenticated;
</code></pre>
<p>If a request fails that your policy should allow, check the grant before you rewrite the policy. A missing grant raises error <code>42501</code> before any policy runs.</p>
<h3>Which role is your request running as?</h3>
<p>Every API key resolves to a PostgreSQL role, and your policies match on that role. <a href="https://supabase.com/docs/guides/getting-started/api-keys">Supabase's API keys guide</a> sets out the mapping and notes that the legacy <code>anon</code> and <code>service_role</code> keys are being deprecated by the end of 2026 in favour of publishable and secret keys.</p>
<table>
<thead>
<tr>
<th>Key in the request</th>
<th>User signed in</th>
<th>PostgreSQL role</th>
<th>Effect of RLS</th>
</tr>
</thead>
<tbody><tr>
<td>Publishable or anon</td>
<td>No</td>
<td><code>anon</code></td>
<td>Policies apply</td>
</tr>
<tr>
<td>Publishable or anon</td>
<td>Yes</td>
<td><code>authenticated</code></td>
<td>Policies apply, <code>auth.uid()</code> set</td>
</tr>
<tr>
<td>Secret or service_role</td>
<td>Not applicable</td>
<td><code>service_role</code></td>
<td>Bypassed entirely</td>
</tr>
</tbody></table>
<p>The publishable key is meant to be public. It ships inside your JavaScript bundle, so anyone can read it out of the page source in seconds. It is not a password, and treating it as one is the misunderstanding behind most Supabase data leaks.</p>
<h2>Why do Lovable and Bolt apps leak data by default?</h2>
<p>Because the generated front end treats the screen as the access control, and nobody told the database. Your dashboard renders only your own projects, but the API endpoint behind it will hand over every row to anyone who asks with the public key.</p>
<p>Nothing on screen tells you this is happening. The app looks correct because you are the only user, and generated code rarely includes the migration that enables RLS, revokes the grants and writes the policies.</p>
<p>This has already caused real incidents. <a href="https://nvd.nist.gov/vuln/detail/cve-2025-48757">CVE-2025-48757</a> describes insufficient row-level security policies in Lovable through 15 April 2025 that allowed unauthenticated attackers to read or write arbitrary database tables of generated sites, scored 9.3 and rated critical by MITRE. The vendor disputes the framing, on the grounds that each customer accepts responsibility for their own application's data. Either way, the policies are yours to write and yours to verify.</p>
<h2>How do you test your tables with the anon key?</h2>
<p>Test from outside your app, because your app is the one client that behaves. Two checks find almost every real leak: a second account in a private window, and a direct request to the REST API.</p>
<h3>The two-account browser test</h3>
<p>Create two ordinary accounts. Sign in as the first and add some data. Sign in as the second in a private window and try to reach the first account's records by changing the ID in the URL. Then sign out completely and reload. If you can see or change anything that is not meant to be public, your policies are missing or too loose. Do not run this test with your admin account, which usually carries a role your policies treat differently.</p>
<h3>The curl test that skips your app entirely</h3>
<p>This is the test that matters, because an attacker will never use your interface. Take the project URL and publishable key out of your front-end bundle and ask the REST API directly:</p>
<pre><code class="language-bash">curl "https://YOUR_PROJECT_REF.supabase.co/rest/v1/projects?select=*" \
  -H "apikey: YOUR_PUBLISHABLE_KEY" \
  -H "Authorization: Bearer YOUR_PUBLISHABLE_KEY"
</code></pre>
<p>No signed-in user is involved, so the request runs as <code>anon</code>. An empty array or a permission error means the table is closed to signed-out visitors. Rows coming back means anyone on the internet can read that table. Repeat it for every table name you can find in your code, then try a <code>POST</code> to the same path to check writes.</p>
<p>Manual checks drift, so write them down. Supabase supports pgTAP database tests, so the allow and deny cases for each table can live in <code>supabase/tests/</code> and run with <code>supabase test db</code>. A future prompt that regenerates your schema then cannot quietly reopen a table.</p>
<h2>Policy examples for user-owned rows, team rows and admin access</h2>
<p>Three ownership shapes cover most products. Write all four operations for each table: PostgreSQL does not accept several operations in one <code>for</code> clause, and a catch-all <code>for all</code> policy hides which rule was meant to cover which operation.</p>
<h3>Rows owned by one user</h3>
<p>The simplest case is a row that belongs to whoever is named in its <code>owner_id</code> column.</p>
<pre><code class="language-sql">create policy "Owners select" on public.projects
  for select to authenticated
  using ( (select auth.uid()) = owner_id );

create policy "Owners insert" on public.projects
  for insert to authenticated
  with check ( (select auth.uid()) = owner_id );

create policy "Owners update" on public.projects
  for update to authenticated
  using ( (select auth.uid()) = owner_id )
  with check ( (select auth.uid()) = owner_id );

create policy "Owners delete" on public.projects
  for delete to authenticated
  using ( (select auth.uid()) = owner_id );
</code></pre>
<p>The update policy needs both clauses. <code>using</code> picks the rows the user may edit, and <code>with check</code> stops them reassigning the row to somebody else by rewriting <code>owner_id</code>. An update also needs a matching select policy to behave as expected.</p>
<p>Always name the role with <code>to authenticated</code>. A policy without it runs for <code>anon</code> as well, and <code>auth.uid()</code> returns null on a signed-out request, so the comparison quietly evaluates to false instead of failing loudly.</p>
<h3>Rows shared by a team or workspace</h3>
<p>Here membership lives in its own table, and the obvious version deadlocks. A policy on <code>projects</code> reads <code>team_members</code> to find who the project is shared with, and the policy on <code>team_members</code> reads <code>projects</code> to find the owner. PostgreSQL raises <code>42P17</code>, infinite recursion detected in policy, and every query fails.</p>
<p>Break the cycle with a <code>security definer</code> function, which reads the membership table as its owner so the second policy never runs:</p>
<pre><code class="language-sql">create schema if not exists private;

create function private.my_team_ids()
returns setof uuid
language sql
security definer
set search_path = ''
stable
as $$
  select team_id from public.team_members
  where user_id = (select auth.uid())
$$;

revoke execute on function private.my_team_ids() from public;
grant usage on schema private to authenticated;
grant execute on function private.my_team_ids() to authenticated;

create policy "Team members select" on public.projects
  for select to authenticated
  using ( team_id in (select private.my_team_ids()) );
</code></pre>
<p>Two details are not optional. Set <code>search_path = ''</code> and schema-qualify every name inside the function, otherwise a caller can point an unqualified name at an object they control and have it run with the function owner's privileges. And keep the function in a schema that is not exposed through the Data API, or it becomes callable over the API with those same privileges.</p>
<p>How far a shared-table pattern takes you depends on your tenancy model. Our write-up on <a href="https://algosmiths.com/blog/post/multi-tenant-saas-architecture">choosing a multi-tenant SaaS architecture</a> compares row-level isolation like this with schema-per-tenant and database-per-tenant setups.</p>
<h3>Admin and staff access</h3>
<p>Give admins their own extra policy rather than loosening the owner policy. Permissive policies combine with <code>OR</code>, so an additional policy widens access without weakening the rule that protects ordinary users.</p>
<pre><code class="language-sql">create policy "Staff read everything" on public.projects
  for select to authenticated
  using ( (select private.is_staff()) );
</code></pre>
<p>Source the admin flag from somewhere the user cannot edit. Supabase's <code>raw_user_meta_data</code> is writable by the user through the auth API, so a policy built on it lets an attacker promote themselves. Use a staff table read through a <code>security definer</code> function, or <code>raw_app_meta_data</code>, which users cannot change. A JWT is also a snapshot: removing someone's role does not change <code>auth.jwt()</code> until their token refreshes.</p>
<h2>Where RLS does not protect you</h2>
<p>RLS covers tables reached with a publishable key. Three paths go around it, and each needs its own control.</p>
<ul>
<li><strong>The secret or service_role key.</strong> It holds the <code>BYPASSRLS</code> attribute, so no policy applies to it. One secret key in front-end code makes every policy you wrote irrelevant. Our <a href="https://algosmiths.com/blog/post/secrets-management-for-startups">guide to handling API keys and environment variables</a> covers where each kind of key belongs and how to rotate one that has leaked.</li>
<li><strong>Views.</strong> PostgreSQL creates views as <code>security definer</code> by default, so a view over a protected table hands out the rows its policies were meant to withhold. On PostgreSQL 15 and later, create views with <code>security_invoker = true</code>.</li>
<li><strong>Storage.</strong> Files sit behind separate policies on the storage schema. A locked-down table next to a public bucket of user uploads is still a leak. Our guide to <a href="https://algosmiths.com/blog/post/securing-supabase-storage-buckets">securing your storage buckets</a> covers the storage policies and signed URLs.</li>
</ul>
<h2>Does RLS slow your app down?</h2>
<p>Not if you index the columns your policies filter on, and badly if you do not. A policy is evaluated per candidate row, so an unindexed filter column turns a simple read into a sequential scan of the whole table.</p>
<p><a href="https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv">Supabase's published RLS performance tests</a>, run against a 100,000-row table, show how large the difference is.</p>
<table>
<thead>
<tr>
<th>Change</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>Index added on the policy's <code>user_id</code> column</td>
<td>171ms</td>
<td>under 0.1ms</td>
</tr>
<tr>
<td><code>auth.uid()</code> wrapped as <code>(select auth.uid())</code></td>
<td>179ms</td>
<td>9ms</td>
</tr>
<tr>
<td>Table join moved into a <code>security definer</code> function</td>
<td>11,000ms</td>
<td>7ms</td>
</tr>
<tr>
<td>Role named with <code>to authenticated</code>, anon request</td>
<td>170ms</td>
<td>under 0.1ms</td>
</tr>
</tbody></table>
<p>Wrapping a helper call in a subselect lets the planner run it once per statement instead of once per row. It works for <code>auth.uid()</code>, <code>auth.jwt()</code> and <code>security definer</code> functions, as long as the result does not depend on the row being checked.</p>
<p>Keep the filter in your client query as well as in the policy. Calling <code>.eq('owner_id', userId)</code> alongside the policy gives the planner something to work with, and leaves RLS as the guarantee rather than the filter.</p>
<h2>Common RLS mistakes</h2>
<ul>
<li>Enabling RLS and stopping there. Without policies the table is closed, the app breaks, and the usual fix reopens it to everyone.</li>
<li>Writing a <code>using ( true )</code> policy. That is right for a genuinely public feed and wrong everywhere else, and it is what AI tools generate when asked to fix a query that returns nothing.</li>
<li>Omitting <code>with check</code> on insert and update, which lets a user create a row in, or move a row into, someone else's account.</li>
<li>Leaving the default grants in place after adding policies, so <code>anon</code> keeps an insert path nobody tested.</li>
<li>Testing on an empty table. Zero rows returned proves nothing until another user's row exists to be leaked.</li>
</ul>
<h2>Frequently asked questions</h2>
<h3>Is it safe to expose the Supabase anon key in my front-end code?</h3>
<p>Yes, that is what it is for. The publishable or anon key identifies your application rather than your user, and Supabase documents it as safe to ship in web pages and mobile apps. It is only safe because RLS limits what it can reach, so the key being public and your data being public are two different things.</p>
<h3>Do I still need RLS if everything goes through my own backend?</h3>
<p>You need it unless the Data API is switched off entirely. As long as the REST endpoint is reachable with a publishable key, the browser route exists whether or not your app uses it. Keeping RLS on also means a feature added later through an AI builder cannot bypass your checks.</p>
<h3>Why does my update succeed but change nothing?</h3>
<p>Almost always a missing select policy, or a <code>using</code> clause that matches no rows. A <code>using</code> clause that filters every row out returns success with zero rows changed rather than an error. Add <code>returning</code> to the statement and you will see that it affected nothing.</p>
<h3>Can I let Lovable write the policies for me?</h3>
<p>Use it for a first draft, then verify the result yourself. Generated policies often omit the <code>to authenticated</code> clause, skip <code>with check</code>, or leave the default grants untouched. The curl test takes a minute and tells you whether the SQL did what the explanation claimed.</p>
<h3>How long does retrofitting RLS onto a live app take?</h3>
<p>For a small app with a handful of tables and clear ownership columns, a day or two including tests. It takes longer when rows have no owner column, when sharing rules were only ever enforced in the front end, or when views and background jobs have to be reworked too.</p>
<h2>Getting your tables locked down</h2>
<p>Start with the curl test, because it tells you within a minute whether you have a problem. Then go table by table: enable RLS, revoke the default grants, write four policies, index the columns those policies filter on, and save the allow and deny cases as tests. Keep the secret key on the server, and check your views and storage buckets before you call it done. If the policies check out and the live site still misbehaves, the cause is usually configuration, which our <a href="https://algosmiths.com/blog/post/lovable-preview-vs-production">nine fixes for a Lovable app that breaks in production</a> covers.</p>
<p>If you would rather have senior engineers audit the policies and close the gaps, AlgoSmiths works on fixed-price projects with a working demo every week. Our <a href="https://algosmiths.com/services/backend-api">backend and API engineering work</a> is the place to start.</p>
<hr />
<p><em>Originally published on the</em> <a href="https://algosmiths.com/blog/post/supabase-row-level-security"><em>AlgoSmiths blog</em></a><em>. AlgoSmiths builds web, mobile and AI products on fixed-price contracts, and takes apps built with Lovable, Bolt or Cursor to production.</em> <a href="https://algosmiths.com/services"><em>See our services</em></a><em>.</em></p>
]]></content:encoded></item></channel></rss>