<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Samrat Patel</title>
    <description>The latest articles on DEV Community by Samrat Patel (@samrat-patel).</description>
    <link>https://dev.to/samrat-patel</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4203652%2Fab08e8e1-e8bd-4502-ad99-214e7312e0f0.png</url>
      <title>DEV Community: Samrat Patel</title>
      <link>https://dev.to/samrat-patel</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/samrat-patel"/>
    <language>en</language>
    <item>
      <title>Supabase Auth in Production: Redirects, Email and Sessions</title>
      <dc:creator>Samrat Patel</dc:creator>
      <pubDate>Sat, 14 Nov 2026 10:24:02 +0000</pubDate>
      <link>https://dev.to/samrat-patel/supabase-auth-in-production-redirects-email-and-sessions-3g6l</link>
      <guid>https://dev.to/samrat-patel/supabase-auth-in-production-redirects-email-and-sessions-3g6l</guid>
      <description>&lt;p&gt;Most Supabase Auth problems in production come from configuration rather than code: a Site URL still pointing at localhost, a redirect allow list that does not include your domain, a built-in email service that only delivers to your own team, and session settings nobody has opened. Each one takes minutes to fix once you know which is which.&lt;/p&gt;

&lt;p&gt;This guide is for founders and small teams taking a Supabase app live, including apps generated with Lovable, Bolt or Cursor, where sign-in worked perfectly on a laptop and fails for the first real user. It covers redirects, confirmation emails, OAuth providers and sessions, and names the setting to change in each case.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Site URL is the default destination for every auth email.&lt;/strong&gt; It ships as &lt;code&gt;http://localhost:3000&lt;/code&gt; and has to be changed to your production address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The built-in email service is for testing.&lt;/strong&gt; It refuses addresses outside your organisation and sends about two messages an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Token has expired or is invalid" is often an email scanner&lt;/strong&gt; opening the one-time link before your user does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OAuth needs two settings to agree:&lt;/strong&gt; the provider's authorised redirect URI and your Supabase redirect allow list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sessions never end by default.&lt;/strong&gt; Inactivity timeouts, time-boxing and single-session enforcement are opt-in and only on paid plans.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why does Supabase Auth break in production?
&lt;/h2&gt;

&lt;p&gt;Because a new project ships with development defaults and nothing warns you they are still in place. The authentication code generated for you is usually fine. The environment around it is not.&lt;/p&gt;

&lt;p&gt;These are the failures we see most often when a Supabase app meets its first real users, and where each one is fixed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Usual cause&lt;/th&gt;
&lt;th&gt;Where to fix it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Confirmation link opens localhost&lt;/td&gt;
&lt;td&gt;Site URL never changed&lt;/td&gt;
&lt;td&gt;URL configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redirect lands on the wrong page&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;redirectTo&lt;/code&gt; not on the allow list&lt;/td&gt;
&lt;td&gt;URL configuration&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Only your own team gets emails&lt;/td&gt;
&lt;td&gt;Built-in email service still in use&lt;/td&gt;
&lt;td&gt;SMTP settings&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Token has expired or is invalid"&lt;/td&gt;
&lt;td&gt;A scanner opened the link first&lt;/td&gt;
&lt;td&gt;Email template&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Google returns a redirect URI error&lt;/td&gt;
&lt;td&gt;Callback URL missing in the provider&lt;/td&gt;
&lt;td&gt;Google Cloud console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Users signed out at random&lt;/td&gt;
&lt;td&gt;Two clients refreshing one session&lt;/td&gt;
&lt;td&gt;Client setup&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Signing a user in is a separate job from deciding what they can read. Our guide to &lt;a href="https://algosmiths.com/blog/post/supabase-row-level-security" rel="noopener noreferrer"&gt;writing row-level security policies&lt;/a&gt; covers the half of this that lives in the database, and it is the half that leaks data when it is missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you fix the Site URL and redirect URLs?
&lt;/h2&gt;

&lt;p&gt;Set Site URL to your production address, then add every other legitimate destination to the redirect allow list. Supabase's documentation states that Site URL defaults to &lt;code&gt;http://localhost:3000&lt;/code&gt; and that this setting is critical for email confirmations and password resets (&lt;a href="https://supabase.com/docs/guides/auth/redirect-urls" rel="noopener noreferrer"&gt;Supabase redirect URL documentation&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The two settings do different jobs. &lt;strong&gt;Site URL&lt;/strong&gt; is the fallback destination when your code does not pass a &lt;code&gt;redirectTo&lt;/code&gt; value, and it is what email templates fall back to. &lt;strong&gt;The allow list&lt;/strong&gt; is the set of addresses Supabase agrees to send a user to when your code does pass one.&lt;/p&gt;

&lt;p&gt;When authentication fails, the user is still sent to the redirect URL, with the failure described in the URL fragment. Reading &lt;code&gt;error_code&lt;/code&gt; and &lt;code&gt;error_description&lt;/code&gt; on that page turns "it bounced me back to the login screen" into a message you can act on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Preview deployments and wildcards
&lt;/h3&gt;

&lt;p&gt;Preview URLs change on every deploy, so the allow list accepts wildcard patterns, where &lt;code&gt;.&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt; count as separators. For a Vercel project, Supabase suggests keeping Site URL on the production domain and adding entries such as &lt;code&gt;http://localhost:3000/**&lt;/code&gt; and &lt;code&gt;https://*-your-team.vercel.app/**&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Keep the globstar for development and preview environments only. The same documentation recommends an exact path for the production redirect, which also keeps the allow list readable a year from now.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why don't confirmation emails arrive?
&lt;/h2&gt;

&lt;p&gt;Because the project is almost certainly still using Supabase's built-in email service. It only delivers to members of your organisation, and Supabase describes it as best-effort, not intended for production, currently limited to two messages per hour; other addresses fail with "Email address not authorized" (&lt;a href="https://supabase.com/docs/guides/auth/auth-smtp" rel="noopener noreferrer"&gt;Supabase custom SMTP documentation&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;That is why sign-up works for the founder and nobody else. Your address is on the team, so your own test passes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Built-in email service&lt;/th&gt;
&lt;th&gt;Custom SMTP&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who can receive mail&lt;/td&gt;
&lt;td&gt;Organisation members only&lt;/td&gt;
&lt;td&gt;Anyone&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Throughput&lt;/td&gt;
&lt;td&gt;About 2 messages per hour&lt;/td&gt;
&lt;td&gt;30 per hour, adjustable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delivery guarantee&lt;/td&gt;
&lt;td&gt;Best effort, no SLA&lt;/td&gt;
&lt;td&gt;Your provider's&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sender address&lt;/td&gt;
&lt;td&gt;Supabase's&lt;/td&gt;
&lt;td&gt;Your own domain&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Intended for&lt;/td&gt;
&lt;td&gt;Demos and template testing&lt;/td&gt;
&lt;td&gt;Production&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Switching to custom SMTP
&lt;/h3&gt;

&lt;p&gt;Any provider that speaks SMTP works, and Supabase names Resend, AWS SES, Postmark, Twilio SendGrid, ZeptoMail and Brevo among them. Once it is enabled, delivery opens up to real addresses with a starting limit of 30 messages per hour, which you raise in the rate-limit settings as your sending domain earns a reputation.&lt;/p&gt;

&lt;p&gt;Sending is not the same as arriving. A password reset from a brand-new domain with no authentication records gets filtered no matter how well the template is written, and the DNS side of that is covered in our walkthrough of &lt;a href="https://algosmiths.com/blog/post/transactional-email-for-startups" rel="noopener noreferrer"&gt;SPF, DKIM and DMARC for app email&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do confirmation links say the token expired?
&lt;/h2&gt;

&lt;p&gt;Usually because something opened the link before your user did. Corporate mail security, including Safe Links in Microsoft Defender for Office 365, prefetches URLs in incoming messages, which consumes a single-use confirmation link and leaves the real click with "Token has expired or is invalid" (&lt;a href="https://supabase.com/docs/guides/auth/auth-email-templates" rel="noopener noreferrer"&gt;Supabase email template documentation&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Three fixes, in the order we usually try them:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Send a code instead of a link.&lt;/strong&gt; Put &lt;code&gt;{{ .Token }}&lt;/code&gt; in the template, ask the user to type the six digits, and verify them with &lt;code&gt;verifyOtp&lt;/code&gt;. A scanner has nothing to consume.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add a confirmation page.&lt;/strong&gt; Link to a page on your own site that carries the confirmation URL as a parameter, and make the user press a button there.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify on your server.&lt;/strong&gt; Point the template at your own endpoint with the token hash, exchange it for a session server-side, then redirect the user into the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The third option is the one to reach for in a server-rendered app, because the default link returns the session in a URL fragment that your server never sees. The template link looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&amp;amp;type=email"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
  Confirm your email address
&lt;span class="nt"&gt;&amp;lt;/a&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the route behind it exchanges the hash for a session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verifyOtp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="n"&gt;token_hash&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="kd"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'email'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One more setting worth checking in your email provider: click tracking rewrites every link in a message, which breaks auth links in particular. Turn it off for transactional mail.&lt;/p&gt;

&lt;h2&gt;
  
  
  What does OAuth need in production?
&lt;/h2&gt;

&lt;p&gt;Two sets of configuration that have to agree with each other. In the provider's console your application's domain goes in the authorised JavaScript origins and your Supabase project's callback URL goes in the authorised redirect URIs; in Supabase, whatever your code passes as &lt;code&gt;redirectTo&lt;/code&gt; has to be on the allow list (&lt;a href="https://supabase.com/docs/guides/auth/social-login/auth-google" rel="noopener noreferrer"&gt;Supabase guide to Sign in with Google&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Two details catch teams out at launch.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The consent screen shows your project reference&lt;/strong&gt; unless you set a custom domain. Supabase suggests something like &lt;code&gt;auth.example.com&lt;/code&gt; for an app on &lt;code&gt;example.com&lt;/code&gt;, and points out that the default makes a phishing page harder for users to spot.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider tokens are not stored or refreshed for you.&lt;/strong&gt; If your product calls Google or Slack APIs on a user's behalf, keeping those tokens alive is your job, on a server you control.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Server-rendered apps also need a callback route. The PKCE flow, which is the exchange used when the session has to be created on a server rather than in the browser, returns a code that your route swaps for a session before the user lands on a page.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long do Supabase sessions last?
&lt;/h2&gt;

&lt;p&gt;By default, until the user signs out, on as many devices as they like. The access token is a JWT that expires after an hour by default, while the refresh token behind it never expires but can only be used once (&lt;a href="https://supabase.com/docs/guides/auth/sessions" rel="noopener noreferrer"&gt;Supabase session documentation&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;That single-use rule has a ten-second reuse window, which exists so a server and a browser can hand the same token around without ending the session. Outside that window, a reused token is treated as a compromised session and every refresh token for it is revoked.&lt;/p&gt;

&lt;p&gt;This is behind most reports of users being logged out for no reason. The usual cause is two clients refreshing the same session at once, which is easy to do when a server-rendered app also creates a browser client that manages its own refresh.&lt;/p&gt;

&lt;h3&gt;
  
  
  When should you shorten a session?
&lt;/h3&gt;

&lt;p&gt;When a customer's security review, an auditor or a shared-device workflow asks for it. Inactivity timeouts, time-boxed sessions and single-session-per-user are configurable on Pro plans and above, and they take effect at the next refresh rather than immediately. If a buyer's security questionnaire started this conversation, our broader guide to &lt;a href="https://algosmiths.com/blog/post/authentication-for-saas-startups" rel="noopener noreferrer"&gt;sessions, roles and SSO in a SaaS product&lt;/a&gt; covers what they usually ask for next.&lt;/p&gt;

&lt;p&gt;Two numbers matter when you set them. The effective session length is your timeout plus the access token lifetime, because the check happens when the token is refreshed. And Supabase advises against access token lifetimes under five minutes, since clock skew on user devices produces failures that are painful to debug.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Updating Site URL but leaving the old preview domain as the only entry on the allow list.&lt;/li&gt;
&lt;li&gt;Launching on the built-in email service and reading "Email address not authorized" as a bug in the sign-up form.&lt;/li&gt;
&lt;li&gt;Leaving click tracking on for the provider that sends your auth email.&lt;/li&gt;
&lt;li&gt;Testing sign-in only in the browser profile where you are already signed in, which hides a broken redirect.&lt;/li&gt;
&lt;li&gt;Treating a successful login as access control. The database decides what that user can read.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Why does my magic link still redirect to localhost?
&lt;/h3&gt;

&lt;p&gt;Either Site URL is still set to the development address, or the template uses &lt;code&gt;{{ .SiteURL }}&lt;/code&gt; while your code passes a &lt;code&gt;redirectTo&lt;/code&gt; value. Switching the template to &lt;code&gt;{{ .RedirectTo }}&lt;/code&gt; fixes the second case, and both need the destination on the allow list.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need custom SMTP if I am on a paid plan?
&lt;/h3&gt;

&lt;p&gt;Yes. The restriction belongs to the built-in email service rather than to the plan, so a Pro project still cannot email addresses outside the organisation until SMTP is configured. Treat it as a launch requirement alongside a custom domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I keep users signed in for 30 days?
&lt;/h3&gt;

&lt;p&gt;That is already the default behaviour, since sessions last until sign-out unless you configure a timeout. If you want exactly 30 days, set a time-box of 30 days on a Pro plan and leave the access token lifetime at an hour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why is the browser signed in while my server routes see an anonymous user?
&lt;/h3&gt;

&lt;p&gt;Because the session lives in browser storage, where server code cannot read it. Server-rendered apps need the session in cookies, which is what Supabase's server-side helpers do, plus a callback route that completes the code exchange before rendering.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before the first real sign-up
&lt;/h2&gt;

&lt;p&gt;Work through the list in order: Site URL and the allow list, custom SMTP, an email template that survives link scanners, provider callback URLs, and a session policy you have chosen on purpose. None of it is difficult, and all of it stays invisible until a stranger tries to sign in. If the app also behaves differently on its live domain than in preview, our list of &lt;a href="https://algosmiths.com/blog/post/lovable-preview-vs-production" rel="noopener noreferrer"&gt;nine gaps between a preview and a published app&lt;/a&gt; covers the rest.&lt;/p&gt;

&lt;p&gt;If you would rather hand the login, email and session setup to senior engineers, AlgoSmiths works on fixed-price projects with a working demo every week. Our &lt;a href="https://algosmiths.com/services/saas-product" rel="noopener noreferrer"&gt;SaaS product engineering work&lt;/a&gt; is where auth, billing and onboarding get built together.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://algosmiths.com/blog/post/supabase-auth-in-production" rel="noopener noreferrer"&gt;AlgoSmiths blog&lt;/a&gt;. AlgoSmiths builds web, mobile and AI products on fixed-price contracts, and takes apps built with Lovable, Bolt or Cursor to production. &lt;a href="https://algosmiths.com/services" rel="noopener noreferrer"&gt;See our services&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>authentication</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Lovable Cloud to Supabase Migration: A Step-by-Step Guide</title>
      <dc:creator>Samrat Patel</dc:creator>
      <pubDate>Thu, 05 Nov 2026 17:53:57 +0000</pubDate>
      <link>https://dev.to/samrat-patel/lovable-cloud-to-supabase-migration-a-step-by-step-guide-4ekk</link>
      <guid>https://dev.to/samrat-patel/lovable-cloud-to-supabase-migration-a-step-by-step-guide-4ekk</guid>
      <description>&lt;p&gt;To migrate a Lovable Cloud app to your own Supabase project, create a new Supabase project, export the database from Lovable, restore it with &lt;code&gt;pg_restore&lt;/code&gt;, then move the sign-in settings, storage files, secrets and Edge Functions and point the app at the new backend. Lovable cannot hand you its Cloud project, so the move is a copy followed by a switch.&lt;/p&gt;

&lt;p&gt;This guide is for founders who built on Lovable Cloud and want the database, users and files in an account they control, and for the developers they hire to do it. It lists the steps in order, with a check after each one and a plan for the final switch. If the code is not in GitHub yet, start with &lt;a href="https://algosmiths.com/blog/post/export-your-lovable-app" rel="noopener noreferrer"&gt;our guide to exporting your Lovable app to GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Lovable Cloud runs on a Supabase project that Lovable manages. It does not appear in your own Supabase dashboard, and you cannot link it to your account.&lt;/li&gt;
&lt;li&gt;The path is a new Supabase project, a copy of the database and the files, and a change to the app's connection settings.&lt;/li&gt;
&lt;li&gt;The database export includes user accounts and password hashes, so people keep their passwords. Sessions end, and files, secrets, sign-in provider settings and connectors move by hand.&lt;/li&gt;
&lt;li&gt;Keep the old backend running until the new one passes every check. Delete nothing in Lovable until you hold verified copies.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why can't you connect your own Supabase to a Lovable Cloud app?
&lt;/h2&gt;

&lt;p&gt;Because the Supabase project behind Cloud belongs to Lovable. &lt;a href="https://docs.lovable.dev/features/cloud" rel="noopener noreferrer"&gt;Lovable's documentation&lt;/a&gt; describes Cloud as a built-in backend on Supabase's open-source foundation that Lovable manages for you. &lt;a href="https://supabase.com/docs/guides/troubleshooting/cant-access-supabase-project-lovable-cloud" rel="noopener noreferrer"&gt;Supabase's support article&lt;/a&gt; spells out the effects: the project is not owned by your Supabase account, so it never shows up in your dashboard. You get no SQL editor, no external database URL and no service role or API keys, and once Cloud is enabled the project cannot be switched to an external Supabase connection.&lt;/p&gt;

&lt;p&gt;Lovable's own guidance for leaving Cloud is a manual move. You export the database, restore it into a Supabase project you own, move the storage files and the code, and set up authentication, secrets and external services again at the destination.&lt;/p&gt;

&lt;h2&gt;
  
  
  What moves with the export and what you move by hand?
&lt;/h2&gt;

&lt;p&gt;The database export carries the tables, the data, the user accounts and the password hashes. Everything else needs its own step.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Part of your app&lt;/th&gt;
&lt;th&gt;How it travels&lt;/th&gt;
&lt;th&gt;What you do&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Tables, records, user accounts, password hashes&lt;/td&gt;
&lt;td&gt;Database export&lt;/td&gt;
&lt;td&gt;Restore with &lt;code&gt;pg_restore&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Schema and policies&lt;/td&gt;
&lt;td&gt;Migration files in the repository&lt;/td&gt;
&lt;td&gt;Apply them in order&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage files&lt;/td&gt;
&lt;td&gt;Not in the export&lt;/td&gt;
&lt;td&gt;Download, then upload to matching buckets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Edge Function code&lt;/td&gt;
&lt;td&gt;Repository, &lt;code&gt;supabase/functions/&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Deploy with the Supabase CLI&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secrets, sign-in provider settings, API keys&lt;/td&gt;
&lt;td&gt;Not exported&lt;/td&gt;
&lt;td&gt;Set them again&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App connectors and AI features&lt;/td&gt;
&lt;td&gt;Not exported&lt;/td&gt;
&lt;td&gt;Replace with your own accounts&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What do you prepare before you start?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Git sync turned on, so the repository holds the current code and the migration files.&lt;/li&gt;
&lt;li&gt;A Supabase account, and a plan for who pays for the new project.&lt;/li&gt;
&lt;li&gt;PostgreSQL client tools built with zstd support. A &lt;code&gt;pg_restore&lt;/code&gt; without it can list the export but cannot restore the data.&lt;/li&gt;
&lt;li&gt;A list of every place the app uses connectors, AI features or scheduled jobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How do you migrate Lovable Cloud to Supabase step by step?
&lt;/h2&gt;

&lt;p&gt;Keep the live app on the built-in backend until the new backend is complete. Point the app at the new backend at the final switch. These steps follow &lt;a href="https://docs.lovable.dev/tips-tricks/external-deployment-hosting" rel="noopener noreferrer"&gt;Lovable's guide to moving the backend&lt;/a&gt;, with the checks we run on top.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the Supabase project
&lt;/h3&gt;

&lt;p&gt;In the Supabase dashboard choose New project. Set a name, a strong database password and a region close to your users, then wait for it to finish. Save the project ID, the project URL and the public (anon) API key. You need all three in step 7.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Rebuild the schema
&lt;/h3&gt;

&lt;p&gt;Apply every migration file in timestamp order, earliest first. Supabase migrations live in &lt;code&gt;supabase/migrations/&lt;/code&gt; and you can paste each one into the SQL editor. Drizzle migrations live in &lt;code&gt;drizzle/migrations/&lt;/code&gt; and follow the order in &lt;code&gt;drizzle/migrations/meta/_journal.json&lt;/code&gt;. For the Supabase layout the CLI does the same work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx supabase login
npx supabase &lt;span class="nb"&gt;link&lt;/span&gt; &lt;span class="nt"&gt;--project-ref&lt;/span&gt; your-new-project-id
npx supabase db push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;supabase db push&lt;/code&gt; does not apply Drizzle migrations. Check the result before you move on: every table exists, and every table has the &lt;a href="https://algosmiths.com/blog/post/supabase-row-level-security" rel="noopener noreferrer"&gt;row level security policies&lt;/a&gt; you expect. A table with row level security switched off exposes its rows to anyone who holds the public key.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Export and restore the data
&lt;/h3&gt;

&lt;p&gt;In Lovable open More, then Database, then Manage, then Settings. Under Export project data choose Export data, then Export, then Start export. Lovable emails you when the file is ready and saves it in the project's storage, so download it from More, then Storage. Databases up to 15 GB can be exported, the file can be up to 5 GB, and you get one export every 24 hours (&lt;a href="https://docs.lovable.dev/features/database-backups" rel="noopener noreferrer"&gt;Lovable's backup documentation&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;The file is a PostgreSQL custom-format archive with zstd compression. You cannot paste it into the SQL editor. Look inside it first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pg_restore &lt;span class="nt"&gt;--list&lt;/span&gt; your-export.backup
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then restore into the new project, following Supabase's &lt;a href="https://supabase.com/docs/guides/platform/migrating-within-supabase/backup-restore" rel="noopener noreferrer"&gt;backup and restore guidance&lt;/a&gt;. Account for the managed schemas, roles and extensions already in a new project, and reconcile any seed rows your migrations inserted before you import the same rows from the export. Do not add &lt;code&gt;--clean&lt;/code&gt; without reading what it does, because it drops the objects being restored. Run the restore on the new project first as a test, then compare row counts for your main tables against the old database.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Reconfigure sign-in
&lt;/h3&gt;

&lt;p&gt;Users keep their passwords because the hashes travel in the export. Sessions do not carry over, so everyone signs in again once. In the new project open Authentication, then Sign In / Providers, and enable each provider you use. In each OAuth console, such as Google or GitHub, change the redirect URLs to the new project URL. Our guide to &lt;a href="https://algosmiths.com/blog/post/supabase-auth-in-production" rel="noopener noreferrer"&gt;Supabase auth in production&lt;/a&gt; lists what else to check in the auth settings before you switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Copy the storage files
&lt;/h3&gt;

&lt;p&gt;In Lovable download the files from each bucket under More, then Storage. In Supabase open Storage and upload them to the buckets with the same names. The bucket access policies arrive with your schema migrations, so they should already exist.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 6: Set secrets and deploy Edge Functions
&lt;/h3&gt;

&lt;p&gt;Secrets are not exported. Add each one your functions read on the Edge Function Secrets page, or from the command line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;supabase secrets &lt;span class="nb"&gt;set &lt;/span&gt;&lt;span class="nv"&gt;NAME&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;value
supabase secrets &lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;--env-file&lt;/span&gt; ./secrets.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deploy the function code from &lt;code&gt;supabase/functions/&lt;/code&gt; with &lt;code&gt;npx supabase functions deploy&lt;/code&gt;. Then list your scheduled jobs and compare them with the schedules that exist after the restore. Recreate missing database schedules with &lt;code&gt;cron.schedule&lt;/code&gt;, and recreate schedules that call your app with your own scheduler. Make sure the old and new backends do not run the same job at the same time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 7: Test on a separate deployment
&lt;/h3&gt;

&lt;p&gt;Leave the Lovable project and its repository unchanged. On a separate deployment set the three browser values to the new project: &lt;code&gt;VITE_SUPABASE_URL&lt;/code&gt;, &lt;code&gt;VITE_SUPABASE_PUBLISHABLE_KEY&lt;/code&gt; and &lt;code&gt;VITE_SUPABASE_PROJECT_ID&lt;/code&gt;. The build embeds these values, so rebuild after every change. Apps built on TanStack Start need &lt;code&gt;SUPABASE_URL&lt;/code&gt;, &lt;code&gt;SUPABASE_PUBLISHABLE_KEY&lt;/code&gt; and &lt;code&gt;SUPABASE_PROJECT_ID&lt;/code&gt; for the server code.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 8: Verify before you switch
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;The app loads without console errors.&lt;/li&gt;
&lt;li&gt;You can create and read records.&lt;/li&gt;
&lt;li&gt;A migrated account signs in with its old password.&lt;/li&gt;
&lt;li&gt;Uploads and downloads work in storage.&lt;/li&gt;
&lt;li&gt;Edge Functions respond, and scheduled jobs ran after their first scheduled time.&lt;/li&gt;
&lt;li&gt;The new project has &lt;a href="https://algosmiths.com/blog/post/database-backups-for-startups" rel="noopener noreferrer"&gt;backups you have tested&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then run our &lt;a href="https://algosmiths.com/tools/supabase-security-checker" rel="noopener noreferrer"&gt;free Supabase security checker&lt;/a&gt; against the test deployment's public address. A fresh project with a restored database is the moment a missing policy or an open bucket appears.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you switch to the new backend with little downtime?
&lt;/h2&gt;

&lt;p&gt;Treat the switch as a short, planned pause. Lovable's guide gives this order:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pause writes from users, jobs, webhooks and integrations.&lt;/li&gt;
&lt;li&gt;Request the final export. Lovable allows one export per 24 hours, so do your test export the day before.&lt;/li&gt;
&lt;li&gt;Copy any storage files that changed since your last copy.&lt;/li&gt;
&lt;li&gt;Restore the final export into the new project.&lt;/li&gt;
&lt;li&gt;In the Lovable project open Code, edit &lt;code&gt;.env&lt;/code&gt; and replace the three &lt;code&gt;VITE_SUPABASE_*&lt;/code&gt; values with the new project's values. In &lt;code&gt;supabase/config.toml&lt;/code&gt; set &lt;code&gt;project_id&lt;/code&gt; to the new project ID.&lt;/li&gt;
&lt;li&gt;Save both files. The preview and the next publish then use the new backend, and Lovable pushes the change to your repository.&lt;/li&gt;
&lt;li&gt;Point your custom domain at the new host if you moved the frontend, and unpublish the Lovable address if you no longer want it to serve the app.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Time the export and the restore during your test run. Those two numbers set how long users wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  When is it safe to delete the old Cloud backend?
&lt;/h2&gt;

&lt;p&gt;After you hold verified copies of everything, and not before. Lovable states that removing the database deletes the backend, its backups, the app's user accounts, the files in storage and the exports saved there, and that the removal cannot be undone. Download the export and the storage files and keep them outside Lovable. Exporting code or changing a domain does not cancel your hosting or your Lovable subscription, and Cloud usage continues until you change it.&lt;/p&gt;

&lt;p&gt;Connectors and AI features run through Lovable, so each needs a replacement that uses your own accounts. Managed OAuth with automatic token refresh is available on the built-in backend alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What mistakes break a Lovable Cloud migration?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Restoring with &lt;code&gt;--clean&lt;/code&gt; and dropping objects you needed.&lt;/li&gt;
&lt;li&gt;Moving the database and forgetting the storage files.&lt;/li&gt;
&lt;li&gt;Leaving the old redirect URLs in the OAuth consoles, so sign-in fails on the new project.&lt;/li&gt;
&lt;li&gt;Pointing production at the new backend before a separate deployment passed the checks.&lt;/li&gt;
&lt;li&gt;Removing the Cloud database before the downloaded export restores without errors, which deletes the export too.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Do users keep their passwords after the migration?
&lt;/h3&gt;

&lt;p&gt;Yes. The export includes user accounts and password hashes, so a restored account signs in with the same password. Sessions do not carry over, so signed-in users sign in once more. If the destination cannot restore accounts, offer a password reset flow instead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I paste the export into the Supabase SQL editor?
&lt;/h3&gt;

&lt;p&gt;No. The export is a compressed PostgreSQL archive, not SQL text. Restore it with &lt;code&gt;pg_restore&lt;/code&gt; from a build that supports zstd.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do my AI features and connectors move?
&lt;/h3&gt;

&lt;p&gt;No. They run through Lovable, so you replace them with your own provider accounts and integrations at the destination. Search the code for connector and AI calls before you switch.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I point the same Lovable project at my new Supabase project?
&lt;/h3&gt;

&lt;p&gt;Lovable's guide does this by editing &lt;code&gt;.env&lt;/code&gt; and &lt;code&gt;supabase/config.toml&lt;/code&gt; in the Code view. Supabase's support article says a Cloud project cannot be switched to an external Supabase connection. Because the two sources describe different routes, test the change on a separate deployment and check Lovable's current documentation before you make it on the live project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Moving without breaking the live app
&lt;/h2&gt;

&lt;p&gt;The safe order is to build the new backend in full, test it on a separate deployment, then switch in one short pause and keep the old backend until the checks pass. If you want engineers to run the move and the checks for you, talk to us about our fixed-price &lt;a href="https://algosmiths.com/services/ai-app-rescue/lovable" rel="noopener noreferrer"&gt;Lovable app rescue&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://algosmiths.com/blog/post/lovable-cloud-to-supabase-migration" rel="noopener noreferrer"&gt;AlgoSmiths blog&lt;/a&gt;. AlgoSmiths builds web, mobile and AI products on fixed-price contracts, and takes apps built with Lovable, Bolt or Cursor to production. &lt;a href="https://algosmiths.com/services" rel="noopener noreferrer"&gt;See our services&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>lovable</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Securing Supabase Storage Buckets: Private Files and Signed URLs</title>
      <dc:creator>Samrat Patel</dc:creator>
      <pubDate>Sun, 01 Nov 2026 12:43:16 +0000</pubDate>
      <link>https://dev.to/samrat-patel/securing-supabase-storage-buckets-private-files-and-signed-urls-5fo</link>
      <guid>https://dev.to/samrat-patel/securing-supabase-storage-buckets-private-files-and-signed-urls-5fo</guid>
      <description>&lt;p&gt;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 &lt;code&gt;storage.objects&lt;/code&gt; table, and how long your signed URLs stay valid.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://algosmiths.com/blog/post/supabase-row-level-security" rel="noopener noreferrer"&gt;finding and closing row-level security gaps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Buckets are &lt;strong&gt;private by default&lt;/strong&gt;. Marking one public removes read access control entirely, so a long random filename is obscurity rather than protection.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;File permissions are policies on the &lt;code&gt;storage.objects&lt;/code&gt; table, not a bucket setting. Locking down your database tables does nothing for your files.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;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.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;A cached signed-URL response can outlive its token, so deleting the object is the only dependable way to cut off access.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;One &lt;code&gt;select&lt;/code&gt; policy covers both downloading a file and listing a folder, which is how users end up able to enumerate a bucket.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When should a bucket be public, and when private?
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://supabase.com/docs/guides/storage/buckets/fundamentals" rel="noopener noreferrer"&gt;bucket documentation&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Behaviour&lt;/th&gt;
&lt;th&gt;Public bucket&lt;/th&gt;
&lt;th&gt;Private bucket&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Who can read a file&lt;/td&gt;
&lt;td&gt;Anyone with the URL&lt;/td&gt;
&lt;td&gt;Whoever a policy allows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;How files are served&lt;/td&gt;
&lt;td&gt;Permanent &lt;code&gt;/object/public/&lt;/code&gt; URL&lt;/td&gt;
&lt;td&gt;Signed URL or authenticated request&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN caching&lt;/td&gt;
&lt;td&gt;One cache entry per file&lt;/td&gt;
&lt;td&gt;One cache entry per token&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cutting off access&lt;/td&gt;
&lt;td&gt;Delete or move the file&lt;/td&gt;
&lt;td&gt;Delete the file; expiry alone is not enough&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Best for&lt;/td&gt;
&lt;td&gt;Avatars, logos, public media&lt;/td&gt;
&lt;td&gt;Documents, exports, per-account files&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  What a public bucket still protects
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why a random filename is not access control
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://algosmiths.com/blog/post/secure-file-uploads-for-saas" rel="noopener noreferrer"&gt;secure file uploads, limits and storage costs&lt;/a&gt; covers the upload side of the same problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do Supabase Storage policies work?
&lt;/h2&gt;

&lt;p&gt;Every file has a row in the &lt;code&gt;storage.objects&lt;/code&gt; table, and Storage checks row-level security policies on that table for each operation. Supabase's &lt;a href="https://supabase.com/docs/guides/storage/security/access-control" rel="noopener noreferrer"&gt;Storage access control guide&lt;/a&gt; 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.&lt;/p&gt;

&lt;p&gt;Two columns do most of the work. &lt;code&gt;bucket_id&lt;/code&gt; scopes a policy to one bucket, and &lt;code&gt;name&lt;/code&gt; holds the object's full path. The helper function &lt;code&gt;storage.foldername(name)&lt;/code&gt; returns that path as an array, so element one is the top-level folder.&lt;/p&gt;

&lt;p&gt;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.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users upload into their own folder"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'documents'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foldername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users read their own files"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'documents'&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;foldername&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;))[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both clauses matter. &lt;code&gt;with check&lt;/code&gt; decides what may be written, and &lt;code&gt;using&lt;/code&gt; decides which existing rows may be read or changed, which is why an upload rule and a download rule are two separate statements.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which policies does each operation need?
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Upload:&lt;/strong&gt; an &lt;code&gt;insert&lt;/code&gt; policy on &lt;code&gt;storage.objects&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Overwrite with upsert:&lt;/strong&gt; &lt;code&gt;insert&lt;/code&gt; plus &lt;code&gt;select&lt;/code&gt; and &lt;code&gt;update&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Download from a private bucket, or sign a URL:&lt;/strong&gt; &lt;code&gt;select&lt;/code&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Delete:&lt;/strong&gt; &lt;code&gt;delete&lt;/code&gt;, scoped the same way as the read rule.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Write them one per operation rather than as a single &lt;code&gt;for all&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Stop users from listing the whole bucket
&lt;/h3&gt;

&lt;p&gt;One SQL privilege is used by more than one Storage action: downloading an object and listing a folder both go through &lt;code&gt;select&lt;/code&gt;. A read policy written for downloads therefore also lets the client enumerate every path it matches.&lt;/p&gt;

&lt;p&gt;Supabase provides operation-aware helpers for this, so a listing rule can be narrower than a download rule.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Users list only their own objects"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;allow_only_operation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'object.list'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matters most where the filenames are themselves information. A list of paths like &lt;code&gt;exports/acme-corp-q1.csv&lt;/code&gt; tells a competitor who your customers are, even if every download is refused.&lt;/p&gt;

&lt;h2&gt;
  
  
  How long should a signed URL last?
&lt;/h2&gt;

&lt;p&gt;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 &lt;code&gt;createSignedUrl&lt;/code&gt; takes its lifetime in seconds.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;documents&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSignedUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;userId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/contract.pdf`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Expiry is not revocation
&lt;/h3&gt;

&lt;p&gt;Storage signs these URLs with a dedicated internal key, separate from your Auth JWT signing key. Supabase's documentation on &lt;a href="https://supabase.com/docs/guides/storage/serving/downloads" rel="noopener noreferrer"&gt;serving assets from Storage&lt;/a&gt; states that rotating, disabling or switching auth keys does not affect URLs that are already signed, and that revoking them means contacting Supabase support.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Signed URLs and the CDN cache
&lt;/h3&gt;

&lt;p&gt;Smart CDN caching is enabled from the Pro plan up, and it treats every unique &lt;code&gt;?token=&lt;/code&gt; value as its own cache key. Two consequences follow, both set out in &lt;a href="https://supabase.com/docs/guides/storage/cdn/smart-cdn" rel="noopener noreferrer"&gt;Supabase's Smart CDN guide&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Supabase Storage security settings to check before launch
&lt;/h2&gt;

&lt;p&gt;Six settings account for most of the distance between a prototype and a bucket you would let strangers upload to.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Bucket visibility.&lt;/strong&gt; Open the Storage dashboard and read the public flag on every bucket, including the ones an AI builder created for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global file size limit.&lt;/strong&gt; Free projects cap a single file at 50 MB, while Pro and Team allow up to 500 GB, per the &lt;a href="https://supabase.com/docs/guides/storage/uploads/file-limits" rel="noopener noreferrer"&gt;Storage limits documentation&lt;/a&gt;. Set the global ceiling to the largest file your product genuinely accepts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-bucket restrictions.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Path convention.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service key placement.&lt;/strong&gt; The service key bypasses storage policies completely. It belongs on a server you control, never in front-end code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image transformations.&lt;/strong&gt; Available on Pro and above, and billable per use. Switch the feature off if you are not using it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  How do you test a bucket from outside your app?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Second, ask the API to list a bucket with no user signed in:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://YOUR_PROJECT_REF.supabase.co/storage/v1/object/list/documents"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: YOUR_PUBLISHABLE_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_PUBLISHABLE_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"prefix":"","limit":100}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Storage is one item on a longer pre-launch list, and our &lt;a href="https://algosmiths.com/blog/post/ai-built-app-production-readiness" rel="noopener noreferrer"&gt;launch checklist for AI-built apps&lt;/a&gt; covers the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common mistakes with Supabase Storage
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;Treating a database backup as a backup of your files. Database backups do not include stored objects, as &lt;a href="https://algosmiths.com/blog/post/database-backups-for-startups" rel="noopener noreferrer"&gt;the gaps a database backup leaves&lt;/a&gt; explains.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Making a bucket public to fix a broken image, then never changing it back.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Letting the browser choose the storage path, so a user can write into someone else's folder.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Signing URLs that last a week because a one-minute URL expired during testing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Assuming an expired token makes a file unreachable, when a cached response may still be served.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Writing one broad &lt;code&gt;select&lt;/code&gt; policy, then finding the client can list every object it matches.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;Reaching for the service key on the client to make permission errors go away.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is a public bucket safe for profile pictures?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I make a public bucket private later?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I need storage policies if every upload goes through my backend?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my upload fail with a row-level security error?
&lt;/h3&gt;

&lt;p&gt;Almost always a missing or mismatched &lt;code&gt;insert&lt;/code&gt; 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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Does signing URLs slow the app down?
&lt;/h3&gt;

&lt;p&gt;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 &lt;code&gt;createSignedUrls&lt;/code&gt;, re-use each URL for the life of its token, and keep genuinely public assets in a public bucket.&lt;/p&gt;

&lt;h2&gt;
  
  
  Before your first real upload
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://algosmiths.com/services/backend-api" rel="noopener noreferrer"&gt;backend and API engineering work&lt;/a&gt; is where storage, policies and the services around them get built.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://algosmiths.com/blog/post/securing-supabase-storage-buckets" rel="noopener noreferrer"&gt;AlgoSmiths blog&lt;/a&gt;. AlgoSmiths builds web, mobile and AI products on fixed-price contracts, and takes apps built with Lovable, Bolt or Cursor to production. &lt;a href="https://algosmiths.com/services" rel="noopener noreferrer"&gt;See our services&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>backend</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Supabase Row Level Security: Fix Data Leaks in Lovable Apps</title>
      <dc:creator>Samrat Patel</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:23:37 +0000</pubDate>
      <link>https://dev.to/samrat-patel/supabase-row-level-security-fix-data-leaks-in-lovable-apps-418h</link>
      <guid>https://dev.to/samrat-patel/supabase-row-level-security-fix-data-leaks-in-lovable-apps-418h</guid>
      <description>&lt;p&gt;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 &lt;a href="https://algosmiths.com/blog/post/supabase-auth-in-production" rel="noopener noreferrer"&gt;redirects, auth emails and sessions&lt;/a&gt; covers that side.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://algosmiths.com/blog/post/ai-built-app-production-readiness" rel="noopener noreferrer"&gt;pre-launch checklist for AI-built apps&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grants and policies are separate checks.&lt;/strong&gt; Adding a policy does not remove privileges already granted to the &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt; roles.&lt;/li&gt;
&lt;li&gt;You can test your own app in ten minutes with two accounts, a logged-out browser window and one &lt;code&gt;curl&lt;/code&gt; command.&lt;/li&gt;
&lt;li&gt;Most tables need four policies, one each for select, insert, update and delete, rather than a single catch-all rule.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What does Supabase Row Level Security actually do?
&lt;/h2&gt;

&lt;p&gt;RLS adds an invisible &lt;code&gt;WHERE&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;A policy like this one on a &lt;code&gt;projects&lt;/code&gt; table lets signed-in users see only their own rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners read their own projects"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
&lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
&lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;using&lt;/code&gt; clause decides which existing rows a statement can touch. A separate &lt;code&gt;with check&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Once RLS is enabled, the default is deny. &lt;a href="https://www.postgresql.org/docs/current/ddl-rowsecurity.html" rel="noopener noreferrer"&gt;PostgreSQL's row security documentation&lt;/a&gt; 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 &lt;code&gt;OR&lt;/code&gt;, restrictive policies with &lt;code&gt;AND&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This is authorization, not authentication. Supabase Auth proves who the user is; RLS decides what that user may reach. Our &lt;a href="https://algosmiths.com/blog/post/authentication-for-saas-startups" rel="noopener noreferrer"&gt;guide to auth and access control for SaaS products&lt;/a&gt; covers the wider picture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Grants and policies are two different checks
&lt;/h3&gt;

&lt;p&gt;PostgreSQL checks table grants first and policies second, and this is where most Supabase apps go wrong. &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase's Row Level Security documentation&lt;/a&gt; notes that on existing projects a new table in &lt;code&gt;public&lt;/code&gt; starts with select, insert, update and delete already granted to &lt;code&gt;anon&lt;/code&gt;, &lt;code&gt;authenticated&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt;, and that adding policies does not take those grants back.&lt;/p&gt;

&lt;p&gt;Securing a table is therefore three statements, not one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;all&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;anon&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a request fails that your policy should allow, check the grant before you rewrite the policy. A missing grant raises error &lt;code&gt;42501&lt;/code&gt; before any policy runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which role is your request running as?
&lt;/h3&gt;

&lt;p&gt;Every API key resolves to a PostgreSQL role, and your policies match on that role. &lt;a href="https://supabase.com/docs/guides/getting-started/api-keys" rel="noopener noreferrer"&gt;Supabase's API keys guide&lt;/a&gt; sets out the mapping and notes that the legacy &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; keys are being deprecated by the end of 2026 in favour of publishable and secret keys.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key in the request&lt;/th&gt;
&lt;th&gt;User signed in&lt;/th&gt;
&lt;th&gt;PostgreSQL role&lt;/th&gt;
&lt;th&gt;Effect of RLS&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Publishable or anon&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;&lt;code&gt;anon&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Policies apply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Publishable or anon&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;code&gt;authenticated&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Policies apply, &lt;code&gt;auth.uid()&lt;/code&gt; set&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret or service_role&lt;/td&gt;
&lt;td&gt;Not applicable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;service_role&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Bypassed entirely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do Lovable and Bolt apps leak data by default?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This has already caused real incidents. &lt;a href="https://nvd.nist.gov/vuln/detail/cve-2025-48757" rel="noopener noreferrer"&gt;CVE-2025-48757&lt;/a&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you test your tables with the anon key?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The two-account browser test
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  The curl test that skips your app entirely
&lt;/h3&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://YOUR_PROJECT_REF.supabase.co/rest/v1/projects?select=*"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"apikey: YOUR_PUBLISHABLE_KEY"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer YOUR_PUBLISHABLE_KEY"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No signed-in user is involved, so the request runs as &lt;code&gt;anon&lt;/code&gt;. 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 &lt;code&gt;POST&lt;/code&gt; to the same path to check writes.&lt;/p&gt;

&lt;p&gt;Manual checks drift, so write them down. Supabase supports pgTAP database tests, so the allow and deny cases for each table can live in &lt;code&gt;supabase/tests/&lt;/code&gt; and run with &lt;code&gt;supabase test db&lt;/code&gt;. A future prompt that regenerates your schema then cannot quietly reopen a table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Policy examples for user-owned rows, team rows and admin access
&lt;/h2&gt;

&lt;p&gt;Three ownership shapes cover most products. Write all four operations for each table: PostgreSQL does not accept several operations in one &lt;code&gt;for&lt;/code&gt; clause, and a catch-all &lt;code&gt;for all&lt;/code&gt; policy hides which rule was meant to cover which operation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rows owned by one user
&lt;/h3&gt;

&lt;p&gt;The simplest case is a row that belongs to whoever is named in its &lt;code&gt;owner_id&lt;/code&gt; column.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners select"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners insert"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;insert&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners update"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="k"&gt;check&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Owners delete"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The update policy needs both clauses. &lt;code&gt;using&lt;/code&gt; picks the rows the user may edit, and &lt;code&gt;with check&lt;/code&gt; stops them reassigning the row to somebody else by rewriting &lt;code&gt;owner_id&lt;/code&gt;. An update also needs a matching select policy to behave as expected.&lt;/p&gt;

&lt;p&gt;Always name the role with &lt;code&gt;to authenticated&lt;/code&gt;. A policy without it runs for &lt;code&gt;anon&lt;/code&gt; as well, and &lt;code&gt;auth.uid()&lt;/code&gt; returns null on a signed-out request, so the comparison quietly evaluates to false instead of failing loudly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Rows shared by a team or workspace
&lt;/h3&gt;

&lt;p&gt;Here membership lives in its own table, and the obvious version deadlocks. A policy on &lt;code&gt;projects&lt;/code&gt; reads &lt;code&gt;team_members&lt;/code&gt; to find who the project is shared with, and the policy on &lt;code&gt;team_members&lt;/code&gt; reads &lt;code&gt;projects&lt;/code&gt; to find the owner. PostgreSQL raises &lt;code&gt;42P17&lt;/code&gt;, infinite recursion detected in policy, and every query fails.&lt;/p&gt;

&lt;p&gt;Break the cycle with a &lt;code&gt;security definer&lt;/code&gt; function, which reads the membership table as its owner so the second policy never runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_team_ids&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;setof&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="k"&gt;sql&lt;/span&gt;
&lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt;
&lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;
&lt;span class="k"&gt;stable&lt;/span&gt;
&lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt;
  &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;team_members&lt;/span&gt;
  &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="err"&gt;$$&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;revoke&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_team_ids&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;usage&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;schema&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;grant&lt;/span&gt; &lt;span class="k"&gt;execute&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_team_ids&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Team members select"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;my_team_ids&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details are not optional. Set &lt;code&gt;search_path = ''&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;How far a shared-table pattern takes you depends on your tenancy model. Our write-up on &lt;a href="https://algosmiths.com/blog/post/multi-tenant-saas-architecture" rel="noopener noreferrer"&gt;choosing a multi-tenant SaaS architecture&lt;/a&gt; compares row-level isolation like this with schema-per-tenant and database-per-tenant setups.&lt;/p&gt;

&lt;h3&gt;
  
  
  Admin and staff access
&lt;/h3&gt;

&lt;p&gt;Give admins their own extra policy rather than loosening the owner policy. Permissive policies combine with &lt;code&gt;OR&lt;/code&gt;, so an additional policy widens access without weakening the rule that protects ordinary users.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Staff read everything"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;private&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_staff&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Source the admin flag from somewhere the user cannot edit. Supabase's &lt;code&gt;raw_user_meta_data&lt;/code&gt; 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 &lt;code&gt;security definer&lt;/code&gt; function, or &lt;code&gt;raw_app_meta_data&lt;/code&gt;, which users cannot change. A JWT is also a snapshot: removing someone's role does not change &lt;code&gt;auth.jwt()&lt;/code&gt; until their token refreshes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where RLS does not protect you
&lt;/h2&gt;

&lt;p&gt;RLS covers tables reached with a publishable key. Three paths go around it, and each needs its own control.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The secret or service_role key.&lt;/strong&gt; It holds the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute, so no policy applies to it. One secret key in front-end code makes every policy you wrote irrelevant. Our &lt;a href="https://algosmiths.com/blog/post/secrets-management-for-startups" rel="noopener noreferrer"&gt;guide to handling API keys and environment variables&lt;/a&gt; covers where each kind of key belongs and how to rotate one that has leaked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Views.&lt;/strong&gt; PostgreSQL creates views as &lt;code&gt;security definer&lt;/code&gt; 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 &lt;code&gt;security_invoker = true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Storage.&lt;/strong&gt; 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 &lt;a href="https://algosmiths.com/blog/post/securing-supabase-storage-buckets" rel="noopener noreferrer"&gt;securing your storage buckets&lt;/a&gt; covers the storage policies and signed URLs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does RLS slow your app down?
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://supabase.com/docs/guides/troubleshooting/rls-performance-and-best-practices-Z5Jjwv" rel="noopener noreferrer"&gt;Supabase's published RLS performance tests&lt;/a&gt;, run against a 100,000-row table, show how large the difference is.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Index added on the policy's &lt;code&gt;user_id&lt;/code&gt; column&lt;/td&gt;
&lt;td&gt;171ms&lt;/td&gt;
&lt;td&gt;under 0.1ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;auth.uid()&lt;/code&gt; wrapped as &lt;code&gt;(select auth.uid())&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;179ms&lt;/td&gt;
&lt;td&gt;9ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Table join moved into a &lt;code&gt;security definer&lt;/code&gt; function&lt;/td&gt;
&lt;td&gt;11,000ms&lt;/td&gt;
&lt;td&gt;7ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Role named with &lt;code&gt;to authenticated&lt;/code&gt;, anon request&lt;/td&gt;
&lt;td&gt;170ms&lt;/td&gt;
&lt;td&gt;under 0.1ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Wrapping a helper call in a subselect lets the planner run it once per statement instead of once per row. It works for &lt;code&gt;auth.uid()&lt;/code&gt;, &lt;code&gt;auth.jwt()&lt;/code&gt; and &lt;code&gt;security definer&lt;/code&gt; functions, as long as the result does not depend on the row being checked.&lt;/p&gt;

&lt;p&gt;Keep the filter in your client query as well as in the policy. Calling &lt;code&gt;.eq('owner_id', userId)&lt;/code&gt; alongside the policy gives the planner something to work with, and leaves RLS as the guarantee rather than the filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common RLS mistakes
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Enabling RLS and stopping there. Without policies the table is closed, the app breaks, and the usual fix reopens it to everyone.&lt;/li&gt;
&lt;li&gt;Writing a &lt;code&gt;using ( true )&lt;/code&gt; 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.&lt;/li&gt;
&lt;li&gt;Omitting &lt;code&gt;with check&lt;/code&gt; on insert and update, which lets a user create a row in, or move a row into, someone else's account.&lt;/li&gt;
&lt;li&gt;Leaving the default grants in place after adding policies, so &lt;code&gt;anon&lt;/code&gt; keeps an insert path nobody tested.&lt;/li&gt;
&lt;li&gt;Testing on an empty table. Zero rows returned proves nothing until another user's row exists to be leaked.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Frequently asked questions
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Is it safe to expose the Supabase anon key in my front-end code?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need RLS if everything goes through my own backend?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why does my update succeed but change nothing?
&lt;/h3&gt;

&lt;p&gt;Almost always a missing select policy, or a &lt;code&gt;using&lt;/code&gt; clause that matches no rows. A &lt;code&gt;using&lt;/code&gt; clause that filters every row out returns success with zero rows changed rather than an error. Add &lt;code&gt;returning&lt;/code&gt; to the statement and you will see that it affected nothing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Can I let Lovable write the policies for me?
&lt;/h3&gt;

&lt;p&gt;Use it for a first draft, then verify the result yourself. Generated policies often omit the &lt;code&gt;to authenticated&lt;/code&gt; clause, skip &lt;code&gt;with check&lt;/code&gt;, or leave the default grants untouched. The curl test takes a minute and tells you whether the SQL did what the explanation claimed.&lt;/p&gt;

&lt;h3&gt;
  
  
  How long does retrofitting RLS onto a live app take?
&lt;/h3&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting your tables locked down
&lt;/h2&gt;

&lt;p&gt;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 &lt;a href="https://algosmiths.com/blog/post/lovable-preview-vs-production" rel="noopener noreferrer"&gt;nine fixes for a Lovable app that breaks in production&lt;/a&gt; covers.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://algosmiths.com/services/backend-api" rel="noopener noreferrer"&gt;backend and API engineering work&lt;/a&gt; is the place to start.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://algosmiths.com/blog/post/supabase-row-level-security" rel="noopener noreferrer"&gt;AlgoSmiths blog&lt;/a&gt;. AlgoSmiths builds web, mobile and AI products on fixed-price contracts, and takes apps built with Lovable, Bolt or Cursor to production. &lt;a href="https://algosmiths.com/services" rel="noopener noreferrer"&gt;See our services&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>database</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
