<?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: Ahmed Hassan Yaseen</title>
    <description>The latest articles on DEV Community by Ahmed Hassan Yaseen (@ahmed_hassanyaseen_1b7ab).</description>
    <link>https://dev.to/ahmed_hassanyaseen_1b7ab</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%2F4191596%2Fd71d6466-2f95-48e8-a258-753e7da9e9da.png</url>
      <title>DEV Community: Ahmed Hassan Yaseen</title>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ahmed_hassanyaseen_1b7ab"/>
    <language>en</language>
    <item>
      <title>Supabase Realtime not firing? 7 checks when postgres_changes sends nothing</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Sat, 07 Nov 2026 11:18:15 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/supabase-realtime-not-firing-7-checks-when-postgreschanges-sends-nothing-1oa6</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/supabase-realtime-not-firing-7-checks-when-postgreschanges-sends-nothing-1oa6</guid>
      <description>&lt;p&gt;You subscribe to a table, the status callback says &lt;code&gt;SUBSCRIBED&lt;/code&gt;, a row changes in the database, and your app hears nothing. No error in the console, no event, just silence.&lt;/p&gt;

&lt;p&gt;Supabase's own troubleshooting guide describes exactly this and says the cause is almost always one of a handful of things. Below are the checks in the order I'd run them, with the SQL and code for each. Everything here is taken from the Supabase &lt;a href="https://supabase.com/docs/guides/realtime/postgres-changes" rel="noopener noreferrer"&gt;Postgres Changes docs&lt;/a&gt;, their &lt;a href="https://supabase.com/kb/troubleshooting/realtime-postgres-changes-troubleshooting" rel="noopener noreferrer"&gt;Postgres Changes troubleshooting guide&lt;/a&gt;, and their &lt;a href="https://supabase.com/kb/troubleshooting/realtime-messages-not-arriving" rel="noopener noreferrer"&gt;messages not arriving guide&lt;/a&gt;, all read on 7 November 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, a working baseline
&lt;/h2&gt;

&lt;p&gt;This is the shape of a subscription that should fire for every change on one table:&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="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;todos-changes&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;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres_changes&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="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;todos&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="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Change received:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;payload&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;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Subscription status:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;status&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 from the docs: the channel name can be any string except &lt;code&gt;realtime&lt;/code&gt;, and &lt;code&gt;event&lt;/code&gt; is one of &lt;code&gt;INSERT&lt;/code&gt;, &lt;code&gt;UPDATE&lt;/code&gt;, &lt;code&gt;DELETE&lt;/code&gt; or &lt;code&gt;*&lt;/code&gt;. Keep this bare version handy. If your real subscription has filters, you'll strip back to this later.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Is the table in the &lt;code&gt;supabase_realtime&lt;/code&gt; publication?
&lt;/h2&gt;

&lt;p&gt;Realtime doesn't watch every table by default. A table has to be added to a publication called &lt;code&gt;supabase_realtime&lt;/code&gt;, and creating a table and enabling Realtime on it are two separate steps. That's easy to miss when a table was created by a migration or by an AI builder.&lt;/p&gt;

&lt;p&gt;Check what's in the publication:&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;select&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_publication_tables&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;pubname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'supabase_realtime'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your table isn't listed, add it:&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="n"&gt;publication&lt;/span&gt; &lt;span class="n"&gt;supabase_realtime&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;todos&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can also toggle it on in the dashboard under Database, then Publications.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Is Row Level Security quietly hiding the row?
&lt;/h2&gt;

&lt;p&gt;Supabase calls this the most common cause by a wide margin, and the one that wastes the most time, because nothing errors.&lt;/p&gt;

&lt;p&gt;Realtime enforces RLS as the subscribing client's role, the same way a normal query would. &lt;code&gt;SUBSCRIBED&lt;/code&gt; only means the WebSocket connected. It says nothing about whether that user is allowed to see the row that changed.&lt;/p&gt;

&lt;p&gt;The quickest test is to run a normal select with the same client and the same signed-in user:&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="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="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;todos&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;select&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;*&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;eq&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;idOfTheRowThatChanged&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If &lt;code&gt;data&lt;/code&gt; is empty, that's your answer. Don't test this in the SQL Editor as &lt;code&gt;postgres&lt;/code&gt;: that role bypasses RLS, so the row will always show up there.&lt;/p&gt;

&lt;p&gt;The fix is a select policy that matches who should see the row. For a table where each row belongs to one user, it looks like this:&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 can read their own todos"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;todos&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;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two traps to know about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UPDATE events need both states to pass.&lt;/strong&gt; Supabase's guide notes that RLS generally has to allow both the old and the new row. If a policy only allows &lt;code&gt;status = 'active'&lt;/code&gt; and an update flips the row to &lt;code&gt;archived&lt;/code&gt;, the event can fail to arrive.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DELETE events are not filtered by RLS at all.&lt;/strong&gt; The docs explain that Postgres has no way to check access to a row that's already gone. Keep that in mind before you put anything sensitive in a delete payload.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3. Tables outside &lt;code&gt;public&lt;/code&gt; need a grant
&lt;/h2&gt;

&lt;p&gt;Postgres Changes works out of the box for tables in the &lt;code&gt;public&lt;/code&gt; schema. If your table lives in another schema, the docs say you also have to grant &lt;code&gt;select&lt;/code&gt; on it to the role in the user's access token, for example:&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;grant&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;my_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;todos&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;There's no dashboard toggle for that grant. Supabase also strongly recommends turning on RLS and writing policies for those tables, because otherwise any role you grant can read the whole table.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Check the subscription itself, character by character
&lt;/h2&gt;

&lt;p&gt;Once the publication and RLS are ruled out, look for small mismatches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The table name matches exactly, including case.&lt;/li&gt;
&lt;li&gt;The schema is right (&lt;code&gt;public&lt;/code&gt; unless you use a custom one).&lt;/li&gt;
&lt;li&gt;The filter uses Realtime's syntax: &lt;code&gt;room_id=eq.abc123&lt;/code&gt;, not &lt;code&gt;room_id = abc123&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Filters are &lt;code&gt;column=operator.value&lt;/code&gt; expressions that Realtime evaluates on the server, so a filter that doesn't match means the event never leaves the database. Several conditions can be joined with commas, which works as AND; OR isn't supported.&lt;/p&gt;

&lt;p&gt;Then log the status callback instead of assuming it worked. &lt;code&gt;SUBSCRIBED&lt;/code&gt; means connected, &lt;code&gt;CHANNEL_ERROR&lt;/code&gt; means read the error payload, &lt;code&gt;CLOSED&lt;/code&gt; means the channel was shut down, and &lt;code&gt;TIMED_OUT&lt;/code&gt; points to a connection problem. A channel that errors and a channel that connects but never fires are different problems, so work out which one you have.&lt;/p&gt;

&lt;p&gt;If nothing turns up, Supabase's advice is to strip the subscription to the bare minimum: no filter and &lt;code&gt;event: '*'&lt;/code&gt;. If that still never fires, it's a publication or RLS problem, not your filter.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Stale subscriptions in React and Next.js
&lt;/h2&gt;

&lt;p&gt;If you see duplicate events, or events for the wrong room, a re-rendered component is probably leaving an old channel running. Clean it up when the component unmounts or the parameter changes:&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="nf"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;channel&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;supabase&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;channel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`room:&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;roomId&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;postgres_changes&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="na"&gt;event&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;UPDATE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;public&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;table&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;messages&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`room_id=eq.&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;roomId&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="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;new&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;span class="nf"&gt;subscribe&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;supabase&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;removeChannel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;channel&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;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;roomId&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;removeChannel&lt;/code&gt; line in the cleanup is the part that usually goes missing.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. Writing straight after &lt;code&gt;SUBSCRIBED&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;This one bites automated tests most. Supabase confirms a short gap between the client reporting &lt;code&gt;SUBSCRIBED&lt;/code&gt; and the server being ready to stream changes, so a write made in that gap can be missed. Their guide shows listening for the &lt;code&gt;system&lt;/code&gt; message that says the &lt;code&gt;postgres_changes&lt;/code&gt; extension is ready, and only then writing. A one-second delay after &lt;code&gt;SUBSCRIBED&lt;/code&gt; is the weaker fallback they mention.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Events arrive, but &lt;code&gt;payload.old&lt;/code&gt; is empty
&lt;/h2&gt;

&lt;p&gt;If events do come through but &lt;code&gt;payload.old&lt;/code&gt; only has the primary key, that's replica identity, not delivery. By default Postgres sends only the primary key as the old row for &lt;code&gt;UPDATE&lt;/code&gt; and &lt;code&gt;DELETE&lt;/code&gt;. To get the previous values:&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="n"&gt;todos&lt;/span&gt; &lt;span class="n"&gt;replica&lt;/span&gt; &lt;span class="k"&gt;identity&lt;/span&gt; &lt;span class="k"&gt;full&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two caveats from the docs. On a table with RLS turned on, &lt;code&gt;payload.old&lt;/code&gt; still contains only the primary key, even with &lt;code&gt;replica identity full&lt;/code&gt;, so don't build logic that compares old and new values on a policy-protected table. And you can only filter DELETE events when the table's replica identity is &lt;code&gt;full&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Still nothing?
&lt;/h2&gt;

&lt;p&gt;Check that Realtime is enabled for the project, read the Realtime logs in the dashboard, and rule out a firewall or proxy that blocks WebSockets. Supabase's guide also lists an outdated &lt;code&gt;supabase-js&lt;/code&gt; as a possible cause, so check you're on a recent version.&lt;/p&gt;

&lt;p&gt;Finally, design for the odd missed event. Supabase says plainly that Realtime doesn't guarantee every message is delivered, because network blips and reconnects happen. Treat an event as a signal to re-fetch the data, not as the only source of truth.&lt;/p&gt;

&lt;p&gt;Most of the silent cases come back to the same two things: the table was never added to the publication, or a policy that looked fine hides the row from the user who's listening. If you want a second pair of eyes on your policies and grants, I wrote up how to test them as a signed-out visitor and as two different users here: &lt;a href="https://stacksmesh.com/fix/supabase-anon-key-exposed?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=PO-0076" rel="noopener noreferrer"&gt;Is the Supabase anon key safe to expose?&lt;/a&gt;. And if you're about to launch, our free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=PO-0076" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt; looks at headers, exposed keys and more, with no sign-up.&lt;/p&gt;

&lt;p&gt;I wrote this with AI assistance and reviewed it myself.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Vite env variable undefined in production? Set VITE_ before the build</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Sat, 07 Nov 2026 10:14:14 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/vite-env-variable-undefined-in-production-set-vite-before-the-build-3d90</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/vite-env-variable-undefined-in-production-set-vite-before-the-build-3d90</guid>
      <description>&lt;p&gt;Your production app throws one of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;import.meta.env.VITE_SUPABASE_URL is undefined
supabaseUrl is required
Cannot read properties of undefined
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vite replaces &lt;code&gt;import.meta.env&lt;/code&gt; values when the app is built, and only variables that start with &lt;code&gt;VITE_&lt;/code&gt; reach browser code. Set those &lt;code&gt;VITE_&lt;/code&gt; variables on the host &lt;strong&gt;before&lt;/strong&gt; the build runs, then rebuild. Keep anything secret out of &lt;code&gt;VITE_&lt;/code&gt; variables entirely.&lt;/p&gt;

&lt;p&gt;I see this constantly in Lovable and Bolt apps the first time they leave the builder and land on Vercel, Netlify, or Cloudflare.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The variable was added after the build.&lt;/strong&gt; Values are written into the bundle at build time, so adding a variable on the host does nothing until you rebuild.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing &lt;code&gt;VITE_&lt;/code&gt; prefix.&lt;/strong&gt; Variables without that prefix are not exposed to client code, so they read as &lt;code&gt;undefined&lt;/code&gt; in the browser. Vite's docs: variables prefixed with &lt;code&gt;VITE_&lt;/code&gt; are exposed in client-side source code after bundling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set for the wrong environment.&lt;/strong&gt; The variable exists for preview builds but not production, or the other way round.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;.env&lt;/code&gt; file was not deployed.&lt;/strong&gt; A &lt;code&gt;.env.local&lt;/code&gt; file is meant to be git-ignored, so a host building from the repository never sees it.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Find the cause
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Search the code for &lt;code&gt;import.meta.env&lt;/code&gt; and list every variable it reads.&lt;/li&gt;
&lt;li&gt;In the host's settings (Vercel, Netlify, Cloudflare), check each one exists for the &lt;strong&gt;production&lt;/strong&gt; environment with the exact name.&lt;/li&gt;
&lt;li&gt;Check the date of the last build against when the variables were added.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix 1: set the variables before building
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Add each &lt;code&gt;VITE_&lt;/code&gt; variable in the host's environment settings for production.&lt;/li&gt;
&lt;li&gt;Trigger a new deployment so the build reads them.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;VITE_SUPABASE_URL&lt;/span&gt;=&lt;span class="n"&gt;https&lt;/span&gt;://&lt;span class="n"&gt;your&lt;/span&gt;-&lt;span class="n"&gt;project&lt;/span&gt;.&lt;span class="n"&gt;supabase&lt;/span&gt;.&lt;span class="n"&gt;co&lt;/span&gt;
&lt;span class="n"&gt;VITE_SUPABASE_PUBLISHABLE_KEY&lt;/span&gt;=&lt;span class="n"&gt;sb_publishable_&lt;/span&gt;...
&lt;span class="c"&gt;# never a secret key in a VITE_ variable
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix 2: move secrets to the server
&lt;/h2&gt;

&lt;p&gt;Anything secret (a Stripe secret key, a Supabase secret key, a third-party API key) belongs in a server or edge function's environment, not in a &lt;code&gt;VITE_&lt;/code&gt; variable. Vite compiles &lt;code&gt;VITE_*&lt;/code&gt; values into the code the browser downloads, so anyone can read them. Vite's own docs say the same: do not put sensitive information in &lt;code&gt;VITE_*&lt;/code&gt; variables.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the fix worked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Open the production site: the feature that needed the variable works.&lt;/li&gt;
&lt;li&gt;Run a free Launch Check: &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003&lt;/a&gt; — no secret keys found in the bundle.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stop it coming back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Keep a &lt;code&gt;.env.example&lt;/code&gt; file listing every variable the app needs, without values.&lt;/li&gt;
&lt;li&gt;Fail the build when a required variable is missing, instead of failing in the browser.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does it work on Lovable and not on my host?&lt;/strong&gt; Lovable sets the values for its own builds. A new host needs them set again, before its first build.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Are &lt;code&gt;VITE_&lt;/code&gt; variables hidden from users?&lt;/strong&gt; No. They are in the downloaded JavaScript.&lt;/p&gt;

&lt;p&gt;Full write-up with the same steps: &lt;a href="https://stacksmesh.com/fix/vite-env-variable-undefined-in-production?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;https://stacksmesh.com/fix/vite-env-variable-undefined-in-production?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I wrote this with AI assistance and reviewed it myself.&lt;/p&gt;

</description>
      <category>vite</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>devops</category>
    </item>
    <item>
      <title>Supabase Storage upload fails with "new row violates row-level security policy"? Here's the fix</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Mon, 02 Nov 2026 00:06:52 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/supabase-storage-upload-fails-with-new-row-violates-row-level-security-policy-heres-the-fix-4aec</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/supabase-storage-upload-fails-with-new-row-violates-row-level-security-policy-heres-the-fix-4aec</guid>
      <description>&lt;p&gt;Your app tries to upload a file to Supabase Storage, and the request fails with one of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;new row violates row-level security policy
StorageApiError: new row violates row-level security policy
Unauthorized
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Supabase Storage refuses every upload until the &lt;code&gt;storage.objects&lt;/code&gt; table has policies that allow it. The fix: add an INSERT policy for the bucket, scoped to the user's own folder, and add SELECT and UPDATE policies if the app overwrites files with &lt;code&gt;upsert&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I see this one a lot in apps built with Lovable and Bolt on Supabase, usually the first time someone adds an avatar or document upload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No policy on &lt;code&gt;storage.objects&lt;/code&gt;.&lt;/strong&gt; Supabase's docs say it plainly: "By default Storage does not allow any uploads to buckets without RLS policies." A new bucket starts closed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Upsert needs more than INSERT.&lt;/strong&gt; Overwriting an existing file (&lt;code&gt;upsert: true&lt;/code&gt;) also needs SELECT and UPDATE policies.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The path doesn't match the policy.&lt;/strong&gt; The policy expects files in a folder named after the user, but the app uploads to the bucket root or to a different folder.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The upload runs signed out.&lt;/strong&gt; A policy that compares against the user's id fails for a request with no session.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Find the cause
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Copy the exact error and note the bucket and the path the app uploads to.&lt;/li&gt;
&lt;li&gt;In the Supabase dashboard, open Storage &amp;gt; Policies and list the policies for that bucket by operation.&lt;/li&gt;
&lt;li&gt;Check whether the upload call passes &lt;code&gt;upsert: true&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Confirm a session exists at the moment of upload.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix 1: let users upload into their own folder
&lt;/h2&gt;

&lt;p&gt;Upload files to a path that starts with the user's id, for example &lt;code&gt;avatars/&amp;lt;user-id&amp;gt;/photo.png&lt;/code&gt;. Then create an INSERT policy for that bucket that checks the first folder against the signed-in user, plus a SELECT policy so they can read their own files:&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 to 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;'avatars'&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;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'sub'&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;'avatars'&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;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;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'sub'&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;sub&lt;/code&gt; claim in the JWT is the signed-in user's id, which is what Supabase's own examples use to match a user to their folder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: allow overwrites only where needed
&lt;/h2&gt;

&lt;p&gt;If the app replaces files, add an UPDATE policy with the same folder check, alongside the SELECT policy:&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 overwrite files in 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;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="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&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;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'sub'&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&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;jwt&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt;&lt;span class="s1"&gt;'sub'&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;If it doesn't need to replace files, upload with a new file name each time and leave &lt;code&gt;upsert&lt;/code&gt; off. Fewer policies, fewer surprises.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the fix worked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Signed in as user A, upload a file: it succeeds and lands in A's folder.&lt;/li&gt;
&lt;li&gt;Still as user A, try to upload into B's folder: it fails.&lt;/li&gt;
&lt;li&gt;Signed out, try to upload: it fails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three matter. A policy that lets everything through also makes the first test pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stop it coming back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Decide per bucket whether files are public or private before you build the upload screen.&lt;/li&gt;
&lt;li&gt;Never upload from the browser with a secret or service key. It bypasses every policy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should the bucket be public?&lt;/strong&gt; Only for files anyone may see, such as product images. Profile documents, invoices and user uploads belong in a private bucket with policies.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why the JWT &lt;code&gt;sub&lt;/code&gt; claim?&lt;/strong&gt; It is the signed-in user's id, which Supabase's own examples use to match a user to their folder.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://supabase.com/docs/guides/storage/security/access-control" rel="noopener noreferrer"&gt;Supabase: Storage access control&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;I first published this fix on our site: &lt;a href="https://stacksmesh.com/fix/supabase-storage-upload-row-level-security?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Supabase Storage upload "new row violates RLS": fix with SQL&lt;/a&gt;. To check your live app for exposed keys, missing security headers and broken basics in about a minute, run the free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>How much does it cost to fix a Lovable or Base44 app?</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Sun, 01 Nov 2026 00:12:13 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/how-much-does-it-cost-to-fix-a-lovable-or-base44-app-11lc</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/how-much-does-it-cost-to-fix-a-lovable-or-base44-app-11lc</guid>
      <description>&lt;p&gt;"How much will it cost to fix my app?" is the question I get most from founders who built with Lovable or Base44. The honest answer is "it depends", which isn't useful on its own. So here are the ranges from the prices we publish: what finding the problems costs (nothing, to start), what one fix and a handful of fixes come to on a worked example, what a launch-ready app costs, and when a rebuild is cheaper than fixing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Finding out is free.&lt;/strong&gt; The Launch Check reads a live app in about a minute with no sign-up, and the AI audit reads the code: three audits per account, with a price range for every fix it finds.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a worked example&lt;/strong&gt; (a small Lovable booking app with five findings), the cheapest single fix comes to $200–$225 and the dearest to $875–$1,625. All five together come to $1,175–$2,175, far less than the five fixed one at a time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Making the whole app ready for paying customers&lt;/strong&gt; is App Launch Readiness, from $2,499. App Store submission starts at $499 per store, and monthly support for a Base44 app is $399.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A rebuild is cheaper&lt;/strong&gt; when the fixes touch most of the app, the data model is wrong at the root, or the first version is small: a Starter MVP on Base44 or Lovable starts at $1,499, a code-owned MVP Build at $9,999.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  It depends on the app, so look first
&lt;/h2&gt;

&lt;p&gt;What a fix costs depends on what is broken, how much of the app it touches and how many things you fix at once. An honest price starts with a look at the app, and that look should cost nothing. These are the prices we publish (USD, starting prices, fixed in writing after review, as of 23 October 2026), so you can place your app on the scale before you talk to anyone:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What you need&lt;/th&gt;
&lt;th&gt;Published price&lt;/th&gt;
&lt;th&gt;What it covers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Find out what is wrong&lt;/td&gt;
&lt;td&gt;Free&lt;/td&gt;
&lt;td&gt;The Launch Check on a live app, no sign-up. Three AI code audits per account, every finding tied to a file and line, with a price range per fix; further audits from $19.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A written assessment or proposal&lt;/td&gt;
&lt;td&gt;From $150&lt;/td&gt;
&lt;td&gt;A focused review, or the scope and fixed price on paper. Credited in full when the work starts; the first conversation is free.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One fix, or a handful&lt;/td&gt;
&lt;td&gt;Priced from the audit&lt;/td&gt;
&lt;td&gt;The fix plan gives the range; an engineer confirms the scope and gives one fixed price in writing.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The whole app ready for paying customers&lt;/td&gt;
&lt;td&gt;From $2,499&lt;/td&gt;
&lt;td&gt;App Launch Readiness: review and priorities, stability and agreed fixes, broken workflows repaired and tested, launch checks, release and handover.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Published on the App Store or Google Play&lt;/td&gt;
&lt;td&gt;From $499 per store; both from $799&lt;/td&gt;
&lt;td&gt;Review-guideline fixes, the listing, and resubmission until approved, in your developer accounts. Apple's and Google's fees are separate.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fixes and improvements every month&lt;/td&gt;
&lt;td&gt;$399 a month&lt;/td&gt;
&lt;td&gt;App Partner: one existing Base44 app, 8 team hours a month.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Moving to accounts you own&lt;/td&gt;
&lt;td&gt;From $1,999 (Lovable) or $2,999 (Base44)&lt;/td&gt;
&lt;td&gt;Code, data, users, files and access rules moved and tested, with a cut-over and rollback plan.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rebuilding the first version&lt;/td&gt;
&lt;td&gt;From $1,499 (Starter MVP) or $9,999 (MVP Build)&lt;/td&gt;
&lt;td&gt;A Starter MVP is rebuilt on Base44 or Lovable around one core journey; an MVP Build is engineered in code you own.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  A worked example: five findings, priced
&lt;/h2&gt;

&lt;p&gt;Our free AI audit page has a fix planner you can try on example findings from a small fitness-studio booking app built with Lovable. It's an example, not a client. Each total is what the planner shows for that selection, worked out the way every report does it: learning the project, the fix itself, testing and release. They're AI estimates, not quotes.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Fixing&lt;/th&gt;
&lt;th&gt;What goes wrong today&lt;/th&gt;
&lt;th&gt;Example total&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;The price comes from the browser (High)&lt;/td&gt;
&lt;td&gt;Anyone can book a class for $1.&lt;/td&gt;
&lt;td&gt;$875–$1,625&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bookings work without signing in (High)&lt;/td&gt;
&lt;td&gt;Strangers can fill your classes.&lt;/td&gt;
&lt;td&gt;$575–$1,050&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A retried payment books twice (Medium)&lt;/td&gt;
&lt;td&gt;One payment, two bookings.&lt;/td&gt;
&lt;td&gt;$325–$600&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Class names are not escaped in emails (Medium)&lt;/td&gt;
&lt;td&gt;A crafted name can inject HTML.&lt;/td&gt;
&lt;td&gt;$250–$450, includes project onboarding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A class can be booked past capacity (Low)&lt;/td&gt;
&lt;td&gt;Two people get the last spot.&lt;/td&gt;
&lt;td&gt;$200–$225, includes project onboarding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;The two High findings together&lt;/td&gt;
&lt;td&gt;Payments and sign-in&lt;/td&gt;
&lt;td&gt;$1,000–$1,850&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;All five together&lt;/td&gt;
&lt;td&gt;Everything above&lt;/td&gt;
&lt;td&gt;$1,175–$2,175&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three things stand out:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Batches cost less.&lt;/strong&gt; Add up the five single totals and you get far more than the all-five total, because learning the project, testing and release are shared across the fixes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Money and access cost the most.&lt;/strong&gt; The two High findings are most of the bill, because payments and access rules have to be tested as each kind of user and in payment test mode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Small fixes are small.&lt;/strong&gt; An isolated fix, like escaping one email template, comes to a few hundred dollars in all.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What drives the cost
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;How much of the app the problem touches.&lt;/strong&gt; A missing check in one place is one fix. The same missing check on every screen is still one fix, applied and tested everywhere, and we price a repeated pattern once.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Money and personal data.&lt;/strong&gt; Payments, webhooks, sign-in and access rules need more testing than a layout change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The size of the app.&lt;/strong&gt; Every estimate includes the hours to learn the project, scaled to its screens, data and integrations.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;How many fixes you do at once.&lt;/strong&gt; Shared work is paid for once per batch, not once per fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether the app has tests.&lt;/strong&gt; Without them, every fix needs more checking of the journeys around it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's not in the price&lt;/strong&gt; unless the proposal says so: hosting, platform subscriptions and credits, AI usage, paid tools and taxes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  When a rebuild is cheaper than fixing
&lt;/h2&gt;

&lt;p&gt;Compare the audit's total for everything you'd fix with the price of building the same first version again. When the two come close, price the rebuild too: you get a clean foundation for the same money. Rebuild when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the fixes touch most of the app, not a few journeys;&lt;/li&gt;
&lt;li&gt;the data model is wrong at the root (the same data written from many places, or business rules copied into every screen);&lt;/li&gt;
&lt;li&gt;the first version is small: one audience and one core journey, with few real users or records to move;&lt;/li&gt;
&lt;li&gt;you need something the platform can't give you, like your own backend or hosting. Then compare a migration with a rebuild.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixing is cheaper when the problems sit in a few workflows, you can say what should happen and show where it fails, the platform supports the feature and it was just configured or built wrongly, and paying users and real data are already in the app.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to get your own number
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Run the free Launch Check on the live app: about a minute, no sign-up.&lt;/li&gt;
&lt;li&gt;Run the free AI audit on the code: paste a GitHub link, connect GitHub or upload a ZIP. Tick the findings you want fixed and the fix plan shows the range.&lt;/li&gt;
&lt;li&gt;Send the plan to us. An engineer confirms the scope and gives one fixed price in writing before any work starts.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Whoever you hire, ask for written acceptance checks. Ours come with every fixed-price project, and for 30 days after handover, if something we fixed breaks again without anyone changing it, we fix it free.&lt;/p&gt;




&lt;p&gt;I first published this on our site, with the latest prices: &lt;a href="https://stacksmesh.com/resources/cost-to-fix-a-lovable-or-base44-app?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=PO-0063" rel="noopener noreferrer"&gt;How much does it cost to fix a Lovable or Base44 app?&lt;/a&gt;. To get a price range for your own app, run the &lt;a href="https://stacksmesh.com/free-ai-audit?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=PO-0063" rel="noopener noreferrer"&gt;free AI audit&lt;/a&gt; on your code.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>startup</category>
      <category>nocode</category>
      <category>ai</category>
    </item>
    <item>
      <title>What "production-ready" means for an AI-built app, in 32 checks</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Sat, 31 Oct 2026 11:08:06 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/what-production-ready-means-for-an-ai-built-app-in-32-checks-477l</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/what-production-ready-means-for-an-ai-built-app-in-32-checks-477l</guid>
      <description>&lt;p&gt;AI app builders make the demo path work in hours. I see it every week: a founder opens a Lovable, Base44, Bolt or Replit app, signs up, clicks through the main flow, and it works. The hard question comes after that. In the words of the standard we published: "What decides whether an app can carry a business is everything the demo never tested: who can read which records, what happens when a payment webhook fails, whether a secret key shipped to the browser, and whether the owner can run the app without the builder."&lt;/p&gt;

&lt;p&gt;"Production-ready" gets used loosely, so we wrote it down. This post walks through what it means, one check from each area, and how to use the list before you launch or hire someone.&lt;/p&gt;

&lt;h2&gt;
  
  
  The definition in one sentence
&lt;/h2&gt;

&lt;p&gt;Here is the definition the standard starts from:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"A production-ready app is one real customers and the business can rely on: it shows each person only their data, takes payments correctly, survives failures, and is owned by the business that runs it."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That turns into 32 checks in ten areas. The security checks are mapped to the &lt;a href="https://top10.owasp.org/2025" rel="noopener noreferrer"&gt;OWASP Top 10:2025&lt;/a&gt;, and every check says how it is verified.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ten areas, one check from each
&lt;/h2&gt;

&lt;p&gt;I picked the check from each area that I find fails most often or matters most. The wording below is quoted from the standard.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Access to data (OWASP A01 Broken Access Control).&lt;/strong&gt; The standard calls this "the most common serious failure in AI-built apps: rules that let one user read or change another user's records." PR-01 passes when "every table holding personal or business data has access rules for create, read, update and delete." On Supabase that means Row Level Security with a policy for each operation, not just one for reads.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Secrets and configuration.&lt;/strong&gt; PR-05: "No secret API keys (for example a Supabase secret or service-role key, Stripe secret key) in code sent to the browser." A publishable key in the frontend is fine; a secret key there is not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Sign-in (OWASP A07).&lt;/strong&gt; PR-10: "Sign-up, email confirmation, password reset and social sign-in complete on the production domain, with redirect addresses configured." In the apps we check, this is often the one that breaks when an app moves from its preview URL to its own domain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Payments (OWASP A08).&lt;/strong&gt; PR-13: "Payment webhooks verify the provider's signature before acting." Its neighbour PR-14 matters just as much: "Each payment grants access or credit exactly once, even on retries or page refreshes."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Inputs, integrations and AI features.&lt;/strong&gt; PR-19 is new territory for many apps: "Prompts cannot make an AI feature act beyond the signed-in user's permissions, and AI spend has a limit."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Dependencies (OWASP A03).&lt;/strong&gt; PR-20: "A lockfile is committed, and every package the AI added exists and is the intended one." Before you trust a package an AI added, look it up in the registry and confirm it's the one you meant.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. Reliability.&lt;/strong&gt; PR-24 is short: "Backups exist, and a restore has been tested." A backup nobody has restored is a hope, not a backup.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. Speed and search (Google Core Web Vitals).&lt;/strong&gt; PR-25: "Largest Contentful Paint is 2.5 seconds or less on mobile." That threshold comes from &lt;a href="https://web.dev/articles/vitals" rel="noopener noreferrer"&gt;Google's Core Web Vitals guidance&lt;/a&gt;. PR-27 covers the basics search engines and AI assistants need: "Each public page has its own title, description and canonical address; robots.txt and a sitemap exist; key content is in the HTML."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Store readiness.&lt;/strong&gt; PR-29: "A privacy policy, in-app account deletion where accounts exist, explained permissions and real content, with no placeholder screens." The standard lists &lt;a href="https://developer.apple.com/app-store/review/guidelines/" rel="noopener noreferrer"&gt;Apple's App Review Guidelines&lt;/a&gt; as its source for this area.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10. Ownership.&lt;/strong&gt; The standard puts it plainly: "An app the business depends on must be one the business controls." PR-31: "Database, domain, hosting, payment and email accounts belong to the business, not to a freelancer."&lt;/p&gt;

&lt;p&gt;The other 22 checks fill in each area: tested rules (PR-02), server-side role checks (PR-03), HTTPS and security headers (PR-06, PR-07), rate-limited sign-in (PR-12), reconciled payment records (PR-15), alerts when something fails (PR-23), and an exportable data path with a short runbook (PR-32), among others.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "pass or fail" wording matters
&lt;/h2&gt;

&lt;p&gt;The part of the standard I care about most is this line: "Every check is specific enough to pass or fail, and every check names how it is verified, so two people checking the same app reach the same answer."&lt;/p&gt;

&lt;p&gt;"Make it secure" can't be checked. "Payment webhooks verify the provider's signature before acting" can. Each check names one of four ways it is verified:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Automatically by the free Launch Check&lt;/strong&gt;, which reads your live app the way a browser sees it. That covers PR-05 to PR-09 (secrets, HTTPS, headers, exposed files, cookies) and PR-25 to PR-28 (speed, layout shift, findability, accessibility).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By the free AI audit of the code&lt;/strong&gt;, for checks that need the source: access rules, server-side role checks, webhook verification, input validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By the App Store readiness check&lt;/strong&gt;, for store billing (PR-16) and review readiness (PR-29).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;By an engineer&lt;/strong&gt;, for things only a person can test: rules tried as another user and as a signed-out visitor (PR-02), sessions ending when they should (PR-11), a restore that actually works (PR-24), accounts in the company's name (PR-31).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That split is honest about what an automated tool can and can't tell you. A scanner can see a missing Content-Security-Policy header. It can't see whether your &lt;code&gt;orders&lt;/code&gt; table lets user A read user B's rows. You need both kinds of check.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to use it
&lt;/h2&gt;

&lt;p&gt;The standard suggests four uses, and they match how I'd use it myself:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before launch:&lt;/strong&gt; "run the free Launch Check for the automatic checks, then the free AI audit for the code checks."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;With a developer:&lt;/strong&gt; "attach the check numbers to the scope as acceptance criteria." Instead of "fix the security", the scope says "PR-01, PR-03, PR-13 pass", and both sides know what done looks like.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Comparing offers:&lt;/strong&gt; "ask each provider which checks their work covers and how they prove it."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;After launch:&lt;/strong&gt; "re-run the automatic checks after every publish; most regressions are a header, a key or a rule changed by a later prompt."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That last one is easy to skip. With AI builders, every new prompt can touch code you thought was finished. A rule that passed last week can fail after an unrelated change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where to start
&lt;/h2&gt;

&lt;p&gt;If you have an app live today, start with the checks a machine can run in a minute, then work down the list. The full standard, with all 32 checks, the OWASP mapping and how each is verified, is here: &lt;a href="https://stacksmesh.com/resources/production-ready-standard?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=PO-0058" rel="noopener noreferrer"&gt;The Production-Ready Standard for AI-built apps&lt;/a&gt;. It's free to use, cite and check against.&lt;/p&gt;

&lt;p&gt;For the automatic checks, our free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=PO-0058" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt; runs them on your live app in about a minute, no sign-up.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>supabase</category>
      <category>nocode</category>
    </item>
    <item>
      <title>Stripe webhook "No signatures found matching the expected signature"? Here's the fix</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Tue, 27 Oct 2026 00:13:43 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/stripe-webhook-no-signatures-found-matching-the-expected-signature-heres-the-fix-4d93</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/stripe-webhook-no-signatures-found-matching-the-expected-signature-heres-the-fix-4d93</guid>
      <description>&lt;p&gt;Stripe sends a webhook to your endpoint, and the delivery fails with one of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No signatures found matching the expected signature for payload
Webhook signature verification failed
SubtleCryptoProvider cannot be used in a synchronous context
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripe signs the exact bytes it sends. If your code parses the JSON before verifying, or uses the wrong signing secret, the signature no longer matches. The fix: read the raw body as text, verify it with &lt;code&gt;constructEventAsync&lt;/code&gt; and the endpoint's own &lt;code&gt;whsec_&lt;/code&gt; secret, and only then parse the event.&lt;/p&gt;

&lt;p&gt;I see this one a lot in apps built with Lovable, Bolt and Base44 on Supabase, usually when payments move from the Stripe CLI to a real endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The body was parsed before verification.&lt;/strong&gt; Calling &lt;code&gt;req.json()&lt;/code&gt; and re-stringifying changes whitespace and key order, so the signature no longer matches.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The wrong signing secret.&lt;/strong&gt; Each endpoint has its own &lt;code&gt;whsec_&lt;/code&gt; secret, test and live mode differ, and the Stripe CLI uses another one for local forwarding.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Synchronous verification in Deno.&lt;/strong&gt; Edge Functions run on Deno and need &lt;code&gt;constructEventAsync&lt;/code&gt; with the SubtleCrypto provider; the synchronous method throws.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A proxy or platform rewrote the request.&lt;/strong&gt; A layer in between dropped the &lt;code&gt;Stripe-Signature&lt;/code&gt; header or changed the body.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Find the cause
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In Stripe, open the endpoint under Webhooks and read the failed delivery's response body.&lt;/li&gt;
&lt;li&gt;Confirm which mode (test or live) the event came from, and which secret the function reads.&lt;/li&gt;
&lt;li&gt;Log whether the &lt;code&gt;Stripe-Signature&lt;/code&gt; header arrives at all.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix 1: verify the raw body in a Supabase Edge Function
&lt;/h2&gt;

&lt;p&gt;Store the endpoint's signing secret as a function secret (for example &lt;code&gt;STRIPE_WEBHOOK_SIGNING_SECRET&lt;/code&gt;). Read the body with &lt;code&gt;req.text()&lt;/code&gt;, verify it with &lt;code&gt;constructEventAsync&lt;/code&gt; and the SubtleCrypto provider, then handle the event.&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="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm:stripe@^22&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_API_KEY&lt;/span&gt;&lt;span class="dl"&gt;'&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cryptoProvider&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createSubtleCryptoProvider&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Stripe-Signature&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="c1"&gt;// the exact bytes Stripe signed&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;event&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;event&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;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;webhooks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;constructEventAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;signature&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;STRIPE_WEBHOOK_SIGNING_SECRET&lt;/span&gt;&lt;span class="dl"&gt;'&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="kc"&gt;undefined&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;cryptoProvider&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="kd"&gt;as &lt;/span&gt;&lt;span class="nb"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="c1"&gt;// handle event.type here, and make it safe to receive twice&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;received&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&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;The important line is &lt;code&gt;await req.text()&lt;/code&gt;. Stripe's docs are blunt about it: verification needs the raw request body, and any manipulation of that body makes it fail.&lt;/p&gt;

&lt;p&gt;Deploy the function with JWT verification off. Stripe proves itself with the signature, not with a Supabase token. Supabase's own example deploys it with &lt;code&gt;supabase functions deploy --no-verify-jwt stripe-webhooks&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: match secrets to environments
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use the live endpoint's secret in production and the test endpoint's secret in staging. Stripe generates a unique secret for each endpoint, and the test and live secrets are different.&lt;/li&gt;
&lt;li&gt;When forwarding with the Stripe CLI, use the secret that &lt;code&gt;stripe listen&lt;/code&gt; prints.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check the fix worked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Send a test event from the Stripe dashboard: the delivery shows 200.&lt;/li&gt;
&lt;li&gt;Change one character of the secret: the delivery fails with 400. That proves the check is real.&lt;/li&gt;
&lt;li&gt;Pay with a test card end to end and confirm the order or subscription updates in your database.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stop it coming back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Handle each event idempotently (store the event id), because Stripe retries.&lt;/li&gt;
&lt;li&gt;Reconcile with the Stripe API every day, so a missed webhook never leaves an order unpaid in your system.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Can I skip signature verification?&lt;/strong&gt; No. Without it, anyone can post a fake "payment succeeded" event to your endpoint and unlock paid features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why did it work locally?&lt;/strong&gt; The Stripe CLI uses its own signing secret. Production needs the secret of the endpoint registered in the dashboard.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://docs.stripe.com/webhooks#verify-events" rel="noopener noreferrer"&gt;Stripe: Check the webhook signatures&lt;/a&gt; and &lt;a href="https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/stripe-webhooks/index.ts" rel="noopener noreferrer"&gt;Supabase example: Stripe webhooks in an Edge Function&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;I first published this fix on our site: &lt;a href="https://stacksmesh.com/fix/stripe-webhook-signature-verification-failed?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Fix "No signatures found matching the expected signature" from Stripe&lt;/a&gt;. To check your live app for exposed keys, missing security headers and broken basics in about a minute, run the free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>supabase</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Is the Supabase anon key safe in your frontend? What actually protects your data</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Mon, 26 Oct 2026 01:13:11 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/is-the-supabase-anon-key-safe-in-your-frontend-what-actually-protects-your-data-127e</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/is-the-supabase-anon-key-safe-in-your-frontend-what-actually-protects-your-data-127e</guid>
      <description>&lt;p&gt;If you've built an app with Lovable, Bolt or any other tool that sits on Supabase, open your live site, view the JavaScript it sends, and search for &lt;code&gt;supabase.co&lt;/code&gt;. Right next to it you'll find a key. The first time founders see this, the question is always the same: is my database key just sitting in public?&lt;/p&gt;

&lt;p&gt;Short answer: that key is meant to be public. What it can reach is decided by your Row Level Security policies, and that's where things go wrong. Here's what the key does, which key must never ship, and three checks that tell you whether your data is actually protected.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which key is in your JavaScript?
&lt;/h2&gt;

&lt;p&gt;Supabase has two kinds of API keys, and the &lt;a href="https://supabase.com/docs/guides/api/api-keys" rel="noopener noreferrer"&gt;Supabase API keys guide&lt;/a&gt; picks between them by asking where the code runs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Publishable key&lt;/strong&gt; (&lt;code&gt;sb_publishable_...&lt;/code&gt;, or the legacy &lt;code&gt;anon&lt;/code&gt; key): for "anything you ship: browser, mobile app, CLI, script". The docs call it "safe to expose online", because "anyone can read it, so it only reaches what Row Level Security allows."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secret key&lt;/strong&gt; (&lt;code&gt;sb_secret_...&lt;/code&gt;, or the legacy &lt;code&gt;service_role&lt;/code&gt; key): for "anything you control: server, Edge Function, cron job". It "bypasses Row Level Security, so it must never leave your control."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the key you found is a long string starting with &lt;code&gt;eyJ&lt;/code&gt;, it's one of the legacy JWT keys. Supabase says it is "deprecating the anon and service_role keys by the end of 2026" in favour of the new publishable and secret keys, and both systems keep working side by side until you switch the old ones off. A lot of AI-generated code still uses the legacy names, so you'll see &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; in plenty of projects for a while yet.&lt;/p&gt;

&lt;p&gt;So the key itself isn't the problem. The question is what anyone holding it is allowed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the public key can do depends on your policies
&lt;/h2&gt;

&lt;p&gt;Every request that uses the publishable key runs as one of two Postgres roles: &lt;code&gt;anon&lt;/code&gt; when nobody is signed in, and &lt;code&gt;authenticated&lt;/code&gt; when a user has signed in through Supabase Auth. Your Row Level Security (RLS) policies are rules attached to those roles, and Postgres applies them to every query.&lt;/p&gt;

&lt;p&gt;The Supabase RLS guide is blunt about what happens without them: "A table in an exposed schema without RLS is readable and writable by any role with a grant on it" (&lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase: Row Level Security&lt;/a&gt;). In other words, if a table in your &lt;code&gt;public&lt;/code&gt; schema has RLS switched off, anyone who copies the key out of your JavaScript can read it, and possibly change it, with a few lines of code.&lt;/p&gt;

&lt;p&gt;This isn't theoretical. In 2025 a security researcher checked 1,645 apps from Lovable's public showcase and found 170 whose Supabase tables could be read or changed with the public key, because RLS was missing or too open. It was filed as &lt;a href="https://nvd.nist.gov/vuln/detail/CVE-2025-48757" rel="noopener noreferrer"&gt;CVE-2025-48757&lt;/a&gt;. Lovable disputes the CVE, saying each app owner is responsible for their data, and has since added a security scan before publishing.&lt;/p&gt;

&lt;p&gt;In the apps we check, the gaps usually come from one of three places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A table created by SQL or a migration&lt;/strong&gt;, where RLS was never switched on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A policy that says "anyone"&lt;/strong&gt;, often added to make an error go away, like &lt;code&gt;using (true)&lt;/code&gt; on a table that holds personal data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The secret key in the browser&lt;/strong&gt;, put there by a quick fix so a failing insert would work. That one skips every policy you wrote.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Check 1: is RLS on for every table?
&lt;/h2&gt;

&lt;p&gt;In the Supabase dashboard, open the Table Editor and look at each table in the &lt;code&gt;public&lt;/code&gt; schema, or run this in the SQL editor:&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;select&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_tables&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'public'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Any row with &lt;code&gt;rowsecurity = false&lt;/code&gt; is a table the public key can reach without rules. Turn it on:&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;orders&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once RLS is enabled, the docs say "no data is accessible through the API when using a publishable key, until you create policies." So expect parts of your app to go quiet until you add the policies it needs. That's the safe direction to fail in.&lt;/p&gt;

&lt;p&gt;The guide adds one more step that's easy to miss: "On projects that still grant anon and authenticated by default, revoke those grants. Adding policies doesn't remove them." Then grant back only what each role actually does in your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 2: do your policies limit rows to their owner?
&lt;/h2&gt;

&lt;p&gt;RLS being on isn't enough if the policy lets everyone through. Look for policies on tables with user data that use &lt;code&gt;true&lt;/code&gt;, or that are granted to &lt;code&gt;anon&lt;/code&gt; when only signed-in users should see the data. A typical owner-only read policy from the Supabase docs looks like this:&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 can view their own profile."&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;profiles&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;user_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;auth.uid()&lt;/code&gt; returns the ID of the signed-in user making the request, so each person only gets rows that carry their own ID. Do the same for insert, update and delete, each in its own policy, so a user can't write rows under someone else's ID.&lt;/p&gt;

&lt;p&gt;Then test it the way an attacker would, as three different people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Signed out&lt;/strong&gt;, using only the publishable key: you should get no private rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As user A&lt;/strong&gt;: you see A's rows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;As user B&lt;/strong&gt;: you don't see A's rows, and you can't update them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;An empty result for the wrong user is what you want. One thing that trips people up: the docs note that a missing grant returns a permission error, while a policy that matches no rows returns an empty list. If a request fails that your policy should allow, check the grant before you change the policy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check 3: is a secret key anywhere it shouldn't be?
&lt;/h2&gt;

&lt;p&gt;Search the JavaScript your live site sends, and your repository including its history, for &lt;code&gt;sb_secret_&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt;. Also check your environment variables: any variable prefixed for the browser, such as &lt;code&gt;VITE_&lt;/code&gt; or &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;, ends up in the build that ships to every visitor. Supabase's own example puts it in a comment: "Server-only. Never prefix these, or your bundler will ship the key."&lt;/p&gt;

&lt;p&gt;The new secret keys add a safety net: "A secret key doesn't work in a browser", because Supabase returns 401 Unauthorized when the request comes from one. But the docs are clear that it isn't protection once a key has leaked: "an attacker can still use the key from other tools."&lt;/p&gt;

&lt;p&gt;If you find one, the order matters. Supabase's advice is "Fix the root cause of the leak before you rotate anything." Move the work that needed elevated rights into an Edge Function or server route that checks who's calling, give the browser only the publishable key, then create a new secret key, switch every component to it, and only then delete the leaked one. If the leaked key is the legacy service_role key, you can't delete it: deactivate it in the API Keys settings instead (that can be undone). Deleting a secret key can't be undone. I wrote up that sequence step by step in our fix for &lt;a href="https://stacksmesh.com/fix/supabase-service-role-key-exposed?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=PO-0048" rel="noopener noreferrer"&gt;an exposed service_role or secret key&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;The key in your JavaScript is supposed to be there. What keeps your data safe is RLS on every table, policies that limit each row to the people who should see it, and a secret key that never leaves your server. Supabase's own checklist adds one habit: review your policies regularly and run the Security Advisor in the dashboard, which "checks for common security problems with the built-in Postgres roles."&lt;/p&gt;

&lt;p&gt;If you'd like a quick outside look first, our free &lt;a href="https://stacksmesh.com/tools/lovable-security-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=PO-0048" rel="noopener noreferrer"&gt;Lovable security check&lt;/a&gt; reads your live app in about a minute, no sign-up, and flags a Supabase service key or other secrets in your public JavaScript and missing security headers. It only sees what a browser sees, so the RLS tests above are still yours to run.&lt;/p&gt;

&lt;p&gt;Full checklist with SQL and tests: &lt;a href="https://stacksmesh.com/fix/supabase-anon-key-exposed?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0024" rel="noopener noreferrer"&gt;stacksmesh.com/fix/supabase-anon-key-exposed&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>webdev</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Supabase Edge Function blocked by CORS policy? Here's the fix</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Mon, 26 Oct 2026 00:08:03 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/supabase-edge-function-blocked-by-cors-policy-heres-the-fix-5d6p</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/supabase-edge-function-blocked-by-cors-policy-heres-the-fix-5d6p</guid>
      <description>&lt;p&gt;You call a Supabase Edge Function from your app and the browser console shows one of these:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;has been blocked by CORS policy
Response to preflight request doesn't pass access control check
No 'Access-Control-Allow-Origin' header is present on the requested resource
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Before your real request, the browser sends an &lt;code&gt;OPTIONS&lt;/code&gt; preflight. Either the function doesn't answer it, or it answers without the CORS headers. The fix: answer &lt;code&gt;OPTIONS&lt;/code&gt; with the headers, and attach the same headers to every response, errors included.&lt;/p&gt;

&lt;p&gt;I see this one a lot in apps built with Lovable and Bolt on Supabase, usually the first time a function is called from the browser instead of from the dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;OPTIONS isn't handled.&lt;/strong&gt; The function treats the preflight like a normal call, fails to parse a body, and returns an error without headers.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors skip the headers.&lt;/strong&gt; The success path returns CORS headers, but a thrown error or a 400 doesn't, so the browser reports CORS instead of the real error.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A header isn't allowed.&lt;/strong&gt; The client sends a header (for example &lt;code&gt;x-client-info&lt;/code&gt; or a trace header) that's missing from &lt;code&gt;Access-Control-Allow-Headers&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The function crashed.&lt;/strong&gt; A function that fails to boot returns no headers at all, and the CORS message hides the crash.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Find the cause
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In the browser's network tab, find the &lt;code&gt;OPTIONS&lt;/code&gt; request and check its status and headers.&lt;/li&gt;
&lt;li&gt;Open the function's logs in the Supabase dashboard. A crash or a thrown error shows up there, not in the browser.&lt;/li&gt;
&lt;li&gt;Call the function with curl. If it fails there too, the problem is the function, not CORS.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix 1: use the SDK's headers and answer the preflight
&lt;/h2&gt;

&lt;p&gt;With supabase-js 2.95.0 or later, you can import the CORS headers from the SDK, so they stay in sync with the headers the client sends. Return them on the &lt;code&gt;OPTIONS&lt;/code&gt; response and on every other response, then redeploy.&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="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;npm:@supabase/supabase-js@^2/cors&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="nx"&gt;Deno&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OPTIONS&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="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;name&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;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hello &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch &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="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&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="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Bad request&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;corsHeaders&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;The &lt;code&gt;catch&lt;/code&gt; branch is the part people skip. Without headers on the error response, a simple bad request looks like a CORS failure in the browser.&lt;/p&gt;

&lt;p&gt;The Supabase docs also describe a &lt;code&gt;withSupabase&lt;/code&gt; wrapper that handles CORS and the preflight for you, if you'd rather not add the headers by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 2: narrow the allowed origin before launch
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; is fine for public functions. For functions that act on a signed-in user, return your own domain instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the fix worked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;In the network tab, the &lt;code&gt;OPTIONS&lt;/code&gt; request returns 200 with &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Trigger an error on purpose (send a bad body). The browser should now show your error message, not a CORS error.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stop it coming back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Wrap every function in one helper that adds the headers, so a new function can't forget them.&lt;/li&gt;
&lt;li&gt;Read the function logs whenever the browser reports CORS. It's often a crash in disguise.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does it work with curl but not in the browser?&lt;/strong&gt; curl doesn't enforce CORS and sends no preflight. Only browsers check the headers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do I need CORS for webhooks?&lt;/strong&gt; No. Webhooks come from servers (Stripe, for example), which ignore CORS. They need signature checks instead.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://supabase.com/docs/guides/functions/cors" rel="noopener noreferrer"&gt;Supabase: CORS support for Edge Functions&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;I first published this fix on our site: &lt;a href="https://stacksmesh.com/fix/supabase-edge-function-cors-error?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Fix CORS errors when calling a Supabase Edge Function&lt;/a&gt;. To check your live app for exposed keys, missing security headers and broken basics in about a minute, run the free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>deno</category>
    </item>
    <item>
      <title>Google sign-in fails with redirect_uri_mismatch on Supabase? Here's the fix</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Sun, 25 Oct 2026 00:08:17 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/google-sign-in-fails-with-redirecturimismatch-on-supabase-heres-the-fix-a0e</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/google-sign-in-fails-with-redirecturimismatch-on-supabase-heres-the-fix-a0e</guid>
      <description>&lt;p&gt;You add "Sign in with Google" to your app, click the button, and Google shows one of these instead of the account picker:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error 400: redirect_uri_mismatch
Access blocked: This app's request is invalid
Unable to exchange external code
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Google only sends users back to addresses listed in your OAuth client. With Supabase, that address is your project's auth callback, not your app. So the fix is: add the callback URL shown on the Supabase Google provider page to &lt;strong&gt;Authorized redirect URIs&lt;/strong&gt;, add your site to &lt;strong&gt;Authorized JavaScript origins&lt;/strong&gt;, and allow your app's return page in Supabase.&lt;/p&gt;

&lt;p&gt;I see this one often in apps built with Lovable and Bolt on Supabase, usually the first time someone sets up Google sign-in for the live domain.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Supabase callback is missing in Google Cloud.&lt;/strong&gt; Authorized redirect URIs lists your app's address instead of the Supabase callback (&lt;code&gt;https://&amp;lt;project-ref&amp;gt;.supabase.co/auth/v1/callback&lt;/code&gt;, or your custom auth domain).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You edited the wrong OAuth client.&lt;/strong&gt; The client ID and secret in Supabase belong to a different Google Cloud client than the one you changed, or the client type isn't Web application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The return page isn't allowed in Supabase.&lt;/strong&gt; After Google, Supabase sends the user to your &lt;code&gt;redirectTo&lt;/code&gt; address. If that address isn't on the Redirect URLs list, users land on the Site URL instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The audience is limited.&lt;/strong&gt; While the Google consent screen is set to testing or internal users, anyone outside the allowed list is blocked.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Find the cause
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;On Google's error page, click &lt;strong&gt;error details&lt;/strong&gt; and copy the &lt;code&gt;redirect_uri&lt;/code&gt; value it shows.&lt;/li&gt;
&lt;li&gt;In Supabase, open &lt;strong&gt;Authentication &amp;gt; Providers &amp;gt; Google&lt;/strong&gt; and copy the callback URL shown there.&lt;/li&gt;
&lt;li&gt;In Google Cloud, open &lt;strong&gt;Google Auth Platform &amp;gt; Clients&lt;/strong&gt;, open the client whose ID matches the one in Supabase, and compare the two.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix 1: correct the Google Cloud client
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Under &lt;strong&gt;Authorized redirect URIs&lt;/strong&gt;, add the exact callback URL from the Supabase Google provider page.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Authorized JavaScript origins&lt;/strong&gt;, add your site's origin only, for example &lt;code&gt;https://app.example.com&lt;/code&gt;, with no path.&lt;/li&gt;
&lt;li&gt;Save, and make sure this client's ID and secret are the ones in the Supabase Google provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix 2: allow the page users come back to
&lt;/h2&gt;

&lt;p&gt;Add your return page (for example &lt;code&gt;https://app.example.com/auth/callback&lt;/code&gt;) to &lt;strong&gt;Redirect URLs&lt;/strong&gt; in Supabase, then pass it from the app:&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="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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signInWithOAuth&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;provider&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;google&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/callback&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Fix 3: open sign-in to everyone
&lt;/h2&gt;

&lt;p&gt;In Google Auth Platform, set the audience to external and publish the app when you're ready for public users. Brand verification can take a few business days, so don't leave it to launch day.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the fix worked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sign in with a Google account that has never used the app. It should come back to your callback page, signed in.&lt;/li&gt;
&lt;li&gt;Sign out, then sign in again from a private window and from a phone.&lt;/li&gt;
&lt;li&gt;Remove any localhost origins from the production client.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stop it coming back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Use one Google client per environment (development and production), so a change for one can't break the live app.&lt;/li&gt;
&lt;li&gt;Before launch, set up a custom auth domain so the Google screen shows your domain instead of supabase.co.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Should the redirect URI be my app or Supabase?&lt;/strong&gt; Supabase. Google sends the user to Supabase's callback, and Supabase then sends them on to your app's &lt;code&gt;redirectTo&lt;/code&gt; address.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How long do Google changes take?&lt;/strong&gt; Usually a few minutes. Test in a private window so an old session doesn't hide the result.&lt;/p&gt;

&lt;p&gt;Sources: &lt;a href="https://supabase.com/docs/guides/auth/social-login/auth-google" rel="noopener noreferrer"&gt;Supabase: Login with Google&lt;/a&gt; and &lt;a href="https://supabase.com/docs/guides/auth/redirect-urls" rel="noopener noreferrer"&gt;Supabase: Redirect URLs&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;I first published this fix on our site: &lt;a href="https://stacksmesh.com/fix/supabase-google-sign-in-redirect-uri-mismatch?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Fix Google sign-in redirect_uri_mismatch with Supabase&lt;/a&gt;. To check your live app for exposed keys, missing security headers and broken basics in about a minute, run the free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>auth</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Supabase confirmation or reset email opens localhost:3000? Here's the fix</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Sat, 24 Oct 2026 11:07:25 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/supabase-confirmation-or-reset-email-opens-localhost3000-heres-the-fix-5c82</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/supabase-confirmation-or-reset-email-opens-localhost3000-heres-the-fix-5c82</guid>
      <description>&lt;p&gt;A user signs up for your live app, clicks the confirmation link, and the browser says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;localhost:3000 refused to connect
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Password reset emails do the same. Nothing is wrong with your deploy. Supabase builds email links from the project's &lt;strong&gt;Site URL&lt;/strong&gt;, and a new project starts with that set to &lt;code&gt;http://localhost:3000&lt;/code&gt;. Set the Site URL to your live domain, add every address you redirect to under &lt;strong&gt;Redirect URLs&lt;/strong&gt;, and pass a redirect address from the app that is on that list.&lt;/p&gt;

&lt;p&gt;I see this a lot in apps built with Lovable, Bolt and Replit on Supabase, usually right after the first real users arrive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The Site URL is still the default.&lt;/strong&gt; Every confirmation and reset link falls back to it, so they all point at &lt;code&gt;localhost:3000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The redirect address isn't allowed.&lt;/strong&gt; If the app asks Supabase to redirect somewhere that isn't on the Redirect URLs list, Supabase uses the Site URL instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A customised email template uses &lt;code&gt;{{ .SiteURL }}&lt;/code&gt;.&lt;/strong&gt; That ignores the redirect you passed. Templates for redirect flows should use &lt;code&gt;{{ .RedirectTo }}&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Find the cause
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Open the email and hover the button. Note the exact address it opens.&lt;/li&gt;
&lt;li&gt;In the Supabase dashboard, open &lt;strong&gt;Authentication &amp;gt; URL Configuration&lt;/strong&gt; and read the Site URL and the Redirect URLs list.&lt;/li&gt;
&lt;li&gt;Find where your app calls &lt;code&gt;signUp&lt;/code&gt;, &lt;code&gt;resetPasswordForEmail&lt;/code&gt; or &lt;code&gt;signInWithOtp&lt;/code&gt; and check which redirect address it passes, if any.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix 1: set the Site URL and the allow list
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Set &lt;strong&gt;Site URL&lt;/strong&gt; to your production address, for example &lt;code&gt;https://app.example.com&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Add the exact pages you redirect to, such as &lt;code&gt;https://app.example.com/auth/callback&lt;/code&gt; and &lt;code&gt;https://app.example.com/reset-password&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Keep &lt;code&gt;http://localhost:3000/**&lt;/code&gt; only if you still develop locally. Supabase recommends exact paths in production rather than wildcards.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fix 2: pass the redirect address from the app
&lt;/h2&gt;

&lt;p&gt;Build the address from the current site, so previews and production both work:&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="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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resetPasswordForEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;redirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/reset-password&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="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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;signUp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;emailRedirectTo&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;window&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/auth/callback&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Whatever &lt;code&gt;window.location.origin&lt;/code&gt; resolves to on each environment has to be on the Redirect URLs list, or Supabase falls back to the Site URL again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix 3: check customised email templates
&lt;/h2&gt;

&lt;p&gt;In &lt;strong&gt;Authentication &amp;gt; Email Templates&lt;/strong&gt;, replace &lt;code&gt;{{ .SiteURL }}&lt;/code&gt; with &lt;code&gt;{{ .RedirectTo }}&lt;/code&gt; in the templates whose flow passes a redirect address.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check the fix worked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Sign up with a fresh email address: the confirmation link opens your live site and signs the user in.&lt;/li&gt;
&lt;li&gt;Request a password reset: the link opens your reset page on the live domain.&lt;/li&gt;
&lt;li&gt;If you use a preview or staging address, repeat once there to confirm its pattern is allowed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stop it coming back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Put the Site URL and Redirect URLs on your launch checklist. Both are set per project, so a new project starts at the default again.&lt;/li&gt;
&lt;li&gt;Set up a custom sending domain for auth emails before launch so they reach inboxes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Do I need to redeploy after changing the Site URL?&lt;/strong&gt; No. It's a project setting and new emails use it right away. Emails already sent keep their old links, so ask affected users to request a new one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What about a mobile app?&lt;/strong&gt; Use a deep-link address such as &lt;code&gt;com.example.app://login-callback&lt;/code&gt; as the redirect and add it to the Redirect URLs list.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://supabase.com/docs/guides/auth/redirect-urls" rel="noopener noreferrer"&gt;Supabase: Redirect URLs&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;I first published this fix on our site: &lt;a href="https://stacksmesh.com/fix/supabase-confirmation-email-redirects-to-localhost?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Supabase confirmation or reset email links to localhost: the fix&lt;/a&gt;. To check your live app for exposed keys, missing security headers and broken basics in about a minute, run the free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>auth</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Before you launch a Lovable or Replit app, check these four things</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Tue, 20 Oct 2026 01:19:10 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/before-you-launch-a-lovable-or-replit-app-check-these-four-things-5fk0</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/before-you-launch-a-lovable-or-replit-app-check-these-four-things-5fk0</guid>
      <description>&lt;p&gt;Most of the AI-built apps I look at before launch basically work. What they're missing is the stuff you can't see from inside the app: pages Google can't read, a phone load time nobody tested, and headers the browser never mentions. We recently put numbers on how common that is, and these are the four checks I'd run on any Lovable or Replit app before telling people about it.&lt;/p&gt;

&lt;p&gt;The numbers come from our &lt;a href="https://stacksmesh.com/resources/state-of-ai-built-apps-2026?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0016" rel="noopener noreferrer"&gt;State of AI-Built Apps 2026 report&lt;/a&gt;: "On 18 October 2026 we ran a passive check on every app listed in two public showcases: 533 from madewithlovable.com and 224 from the Replit community showcase. 578 of the 757 were still online." A passive check means we only read what any visitor's browser receives: the home page, its response headers, the JavaScript it sends, robots.txt and the sitemap. It can't see your database rules, sign-in or payments, so a clean result here isn't a security audit. But these four gaps are easy to find and usually cheap to close.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Can Google and AI assistants read your pages?
&lt;/h2&gt;

&lt;p&gt;From the report: "Most of these apps are hard for search engines and AI assistants to read: 76% of the Lovable apps send crawlers an almost empty HTML page, and about half of all apps have no sitemap."&lt;/p&gt;

&lt;p&gt;Lovable and most Vite apps render in the browser. The HTML your server sends is an empty &lt;code&gt;&amp;lt;div id="root"&amp;gt;&lt;/code&gt; plus a script, and the words appear only after JavaScript runs. Google says it handles JavaScript apps in three phases (crawling, rendering and indexing) and that Googlebot queues pages for rendering, so there can be a delay before your content is seen. The same page adds that server-side or pre-rendering "is still a great idea because it makes your website faster for users and crawlers, and not all bots can run JavaScript" (&lt;a href="https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics" rel="noopener noreferrer"&gt;Google Search Central: JavaScript SEO basics&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt; open your key pages with view-source (not the inspector, which shows the page after JavaScript). Search for a sentence from your hero section. If it isn't in the source, a crawler that doesn't run JavaScript sees a blank page.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to fix:&lt;/strong&gt; you don't need to rewrite the app. Prerender the pages that should be found (home, pricing, features, blog), give every route its own title and description, and use real &lt;code&gt;&amp;lt;a href&amp;gt;&lt;/code&gt; links for navigation rather than click handlers, so crawlers can follow them. Your app screens behind a login can stay client-rendered.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. A sitemap Google can find
&lt;/h2&gt;

&lt;p&gt;About half the apps had none, and it's the easiest fix on this list. A sitemap tells Google which pages exist, which matters most for new and deep pages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt; open &lt;code&gt;https://your-domain.com/sitemap.xml&lt;/code&gt; and &lt;code&gt;https://your-domain.com/robots.txt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to fix:&lt;/strong&gt; write a &lt;code&gt;sitemap.xml&lt;/code&gt; that lists every public URL and put it in Vite's &lt;code&gt;public/&lt;/code&gt; folder. Vite copies that folder to the root of the build as-is (&lt;a href="https://vite.dev/guide/assets" rel="noopener noreferrer"&gt;Vite: the public directory&lt;/a&gt;). A single sitemap can hold up to 50,000 URLs or 50MB uncompressed. Then tell Google where it is in two ways: submit it in Search Console, and add a line like &lt;code&gt;Sitemap: https://your-domain.com/sitemap.xml&lt;/code&gt; to &lt;code&gt;robots.txt&lt;/code&gt; (&lt;a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap" rel="noopener noreferrer"&gt;Google: build and submit a sitemap&lt;/a&gt;). Only list pages you want found, not dashboards or settings screens.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. How fast it loads on a phone
&lt;/h2&gt;

&lt;p&gt;From the report: "In a sample of 139 apps tested for mobile speed, the main content took a median of 5.0 seconds to appear, and 87% were slower than the 2.5 seconds Google treats as good."&lt;/p&gt;

&lt;p&gt;That measure is Largest Contentful Paint (LCP): how long until the biggest thing on screen, usually your hero image or headline, shows up. web.dev puts it plainly: "A good LCP value is 2.5 seconds or less" (&lt;a href="https://web.dev/articles/lcp" rel="noopener noreferrer"&gt;web.dev: LCP&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt; run your home page through &lt;a href="https://pagespeed.web.dev/" rel="noopener noreferrer"&gt;PageSpeed Insights&lt;/a&gt; on the mobile tab. Its lab data comes from Lighthouse and simulates a page load (&lt;a href="https://developers.google.com/speed/docs/insights/v5/about" rel="noopener noreferrer"&gt;PSI docs&lt;/a&gt;); if your site has enough real visitors, it also shows field data.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to fix:&lt;/strong&gt; in the report, the usual causes were JavaScript the page doesn't use and resources that block rendering. In a Vite app that often means one big bundle that has to download before anything appears. Split heavy routes with &lt;code&gt;React.lazy&lt;/code&gt;, which defers a component's code "until it is rendered for the first time" (&lt;a href="https://react.dev/reference/react/lazy" rel="noopener noreferrer"&gt;React docs&lt;/a&gt;), so the landing page doesn't wait for the admin dashboard. Make the hero image discoverable in the HTML, sized for phones, and give it &lt;code&gt;fetchpriority="high"&lt;/code&gt;, as web.dev recommends for the LCP image (&lt;a href="https://web.dev/articles/optimize-lcp" rel="noopener noreferrer"&gt;web.dev: Optimize LCP&lt;/a&gt;). Prerendering from check 1 helps here too, because the headline arrives with the HTML.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Two security headers: Content-Security-Policy and frame protection
&lt;/h2&gt;

&lt;p&gt;From the report: "Security headers are the most common gap. 93% of the live Lovable apps and 79% of the live Replit apps send no Content-Security-Policy, and 91% and 72% have no protection against being loaded inside another site's frame."&lt;/p&gt;

&lt;p&gt;What they do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;Content-Security-Policy (CSP)&lt;/strong&gt; tells the browser where your page may load scripts, styles, images and network requests from. MDN describes it as a defence against cross-site scripting, where injected code runs as if it were yours (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP" rel="noopener noreferrer"&gt;MDN: CSP guide&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frame protection&lt;/strong&gt; stops another site from loading your app inside an invisible iframe and tricking people into clicks (clickjacking). The modern way is the CSP directive &lt;code&gt;frame-ancestors&lt;/code&gt; (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors" rel="noopener noreferrer"&gt;MDN: frame-ancestors&lt;/a&gt;); &lt;code&gt;X-Frame-Options: DENY&lt;/code&gt; is the older header that covers older browsers (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options" rel="noopener noreferrer"&gt;MDN: X-Frame-Options&lt;/a&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;How to check:&lt;/strong&gt; run &lt;code&gt;curl -I https://your-domain.com&lt;/code&gt; and look for &lt;code&gt;content-security-policy&lt;/code&gt; and &lt;code&gt;x-frame-options&lt;/code&gt; in the output. If neither is there, you're in the majority.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to fix:&lt;/strong&gt; a Vite build is a folder of static files, so these headers come from your host, not your React code. On Vercel they go in &lt;code&gt;vercel.json&lt;/code&gt; (&lt;a href="https://vercel.com/docs/project-configuration/vercel-json" rel="noopener noreferrer"&gt;Vercel docs&lt;/a&gt;); on Netlify, in a &lt;code&gt;_headers&lt;/code&gt; file (&lt;a href="https://docs.netlify.com/manage/routing/headers/" rel="noopener noreferrer"&gt;Netlify docs&lt;/a&gt;). The frame headers are safe to switch on straight away if you don't embed your own app anywhere. Here's a Netlify &lt;code&gt;_headers&lt;/code&gt; file you can put in &lt;code&gt;public/&lt;/code&gt; so it lands at the root of the build:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;/*
  Content-Security-Policy: frame-ancestors 'none'
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The last two lines are the report's other cheap wins: &lt;code&gt;nosniff&lt;/code&gt; stops the browser guessing file types (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;), and a Referrer-Policy keeps full page addresses from leaking to other sites (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;A full CSP that limits scripts needs more care, because a wrong policy blocks your own Supabase calls or fonts. Ship it first as &lt;code&gt;Content-Security-Policy-Report-Only&lt;/code&gt;, which reports violations without blocking anything (&lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy-Report-Only" rel="noopener noreferrer"&gt;MDN&lt;/a&gt;), use the app for a day, fix what the console reports, then enforce it. One catch: &lt;code&gt;frame-ancestors&lt;/code&gt; doesn't work in a &lt;code&gt;&amp;lt;meta&amp;gt;&lt;/code&gt; tag, so clickjacking protection always needs a real response header.&lt;/p&gt;

&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;The report's own summary: "The fixes are mostly small: a few response headers, prerendering the pages that should be found, and a sitemap." In the apps we check, they're usually configuration, not a rebuild, and they're far cheaper to do before launch day than after.&lt;/p&gt;

&lt;p&gt;If you'd rather not run each check by hand, our free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0016" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt; runs these same checks on any app in about a minute, with no sign-up. It only sees what a visitor sees, so for data access rules, sign-in and payments, go through the full pre-launch list in our Production-Ready Standard (32 checks in ten areas) or have someone review the code.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>security</category>
      <category>seo</category>
      <category>react</category>
    </item>
    <item>
      <title>Fixing "infinite recursion detected in policy" in Supabase (42P17)</title>
      <dc:creator>Ahmed Hassan Yaseen</dc:creator>
      <pubDate>Tue, 20 Oct 2026 00:13:36 +0000</pubDate>
      <link>https://dev.to/ahmed_hassanyaseen_1b7ab/fixing-infinite-recursion-detected-in-policy-in-supabase-42p17-5e03</link>
      <guid>https://dev.to/ahmed_hassanyaseen_1b7ab/fixing-infinite-recursion-detected-in-policy-in-supabase-42p17-5e03</guid>
      <description>&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;infinite recursion detected in policy for relation
code: 42P17
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A row-level security policy on a table runs a query against the same table, or against a table whose policy queries back. To check the policy, Postgres needs the policy again, so it stops with 42P17. The fix I use: move the membership lookup into a security definer function in a schema the API does not expose, and call that function from the policy.&lt;/p&gt;

&lt;p&gt;It turns up in Lovable, Bolt and Supabase apps. In our work, team, organisation and role models are where AI builders most often leave data open or loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it happens
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A membership table checks itself.&lt;/strong&gt; A policy on &lt;code&gt;team_members&lt;/code&gt; asks "is this user a member of this team?" by selecting from &lt;code&gt;team_members&lt;/code&gt;, which triggers the same policy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two tables point at each other.&lt;/strong&gt; &lt;code&gt;projects&lt;/code&gt; checks &lt;code&gt;team_members&lt;/code&gt;, and &lt;code&gt;team_members&lt;/code&gt; checks &lt;code&gt;projects&lt;/code&gt;. Each policy needs the other first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A roles table guards itself.&lt;/strong&gt; A policy on &lt;code&gt;profiles&lt;/code&gt; reads &lt;code&gt;profiles.role&lt;/code&gt; to decide who is an admin.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Find the cause
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Note the relation named in the error: that is the table whose policy loops.&lt;/li&gt;
&lt;li&gt;Read every policy on that table and look for a subquery that selects from the same table.&lt;/li&gt;
&lt;li&gt;Follow any subquery to another table and read that table's policies. A cycle of two tables loops the same way.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Fix 1: look the membership up in a security definer function
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Create a &lt;code&gt;private&lt;/code&gt; schema that is not listed under &lt;strong&gt;Exposed schemas&lt;/strong&gt; in the API settings.&lt;/li&gt;
&lt;li&gt;Create the function as &lt;code&gt;security definer&lt;/code&gt; with an empty &lt;code&gt;search_path&lt;/code&gt;, and schema-qualify every name inside it.&lt;/li&gt;
&lt;li&gt;Replace the subquery in the policy with a call to the function.
&lt;/li&gt;
&lt;/ul&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;or&lt;/span&gt; &lt;span class="k"&gt;replace&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;is_team_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="nb"&gt;boolean&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="k"&gt;exists&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="mi"&gt;1&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="n"&gt;m&lt;/span&gt;
    &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;team&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&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="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;drop&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="nv"&gt;"Members can read their team"&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;team_members&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;"Members can read their team"&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;team_members&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;private&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;is_team_member&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team_id&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;h2&gt;
  
  
  Fix 2: keep admin flags out of a table users can edit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Store roles in a separate table that only the server writes, or in &lt;code&gt;app_metadata&lt;/code&gt; on the user, which users cannot change.&lt;/li&gt;
&lt;li&gt;Read the role through the same kind of function, never from a column the user can update.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Check the fix worked
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Run the failing query again as a signed-in member: it returns rows.&lt;/li&gt;
&lt;li&gt;Run it as a user who is not a member: it returns no rows, without errors.&lt;/li&gt;
&lt;li&gt;Confirm the &lt;code&gt;private&lt;/code&gt; schema is not in the API's exposed schemas, so the function cannot be called directly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Stop it coming back
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Draw which policy reads which table before you write them. Any cycle will loop.&lt;/li&gt;
&lt;li&gt;Put every cross-table check behind one small function per question (is member, is admin).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Quick answers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Is a security definer function safe?&lt;/strong&gt; Yes, when it lives in a schema the API does not expose, sets an empty search path, qualifies every name and returns only a yes or no. It runs as its owner, so keep it small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why not just disable RLS on the membership table?&lt;/strong&gt; Then anyone with your publishable key can list every team and its members. The function keeps the table protected.&lt;/p&gt;

&lt;p&gt;Source: &lt;a href="https://supabase.com/docs/guides/database/postgres/row-level-security" rel="noopener noreferrer"&gt;Supabase: Row Level Security (security definer functions)&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;I first published this fix on our site: &lt;a href="https://stacksmesh.com/fix/supabase-infinite-recursion-detected-in-policy?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Fix "infinite recursion detected in policy for relation" in Supabase&lt;/a&gt;. For the gaps anyone can see from outside your live app (exposed keys, security headers, what Google reads), run the free &lt;a href="https://stacksmesh.com/tools/launch-check?utm_source=devto&amp;amp;utm_medium=article&amp;amp;utm_campaign=BR-0003" rel="noopener noreferrer"&gt;Launch Check&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;I wrote this with AI assistance and reviewed it myself.&lt;/em&gt;&lt;/p&gt;

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