<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[StacksMesh]]></title><description><![CDATA[StacksMesh]]></description><link>https://stacksmesh.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 24 Oct 2026 22:09:55 GMT</lastBuildDate><atom:link href="https://stacksmesh.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Supabase email links go to localhost: why confirmation and reset links break, and how to fix them]]></title><description><![CDATA[Supabase confirmation and reset emails open localhost:3000 because the Site URL is still the default. Set it, allow redirect URLs, test both flows.]]></description><link>https://stacksmesh.hashnode.dev/supabase-email-link-goes-to-localhost-fix</link><guid isPermaLink="true">https://stacksmesh.hashnode.dev/supabase-email-link-goes-to-localhost-fix</guid><category><![CDATA[supabase]]></category><category><![CDATA[authentication]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[webdev]]></category><category><![CDATA[lovable]]></category><dc:creator><![CDATA[Ahmed Hassan Yaseen]]></dc:creator><pubDate>Sat, 24 Oct 2026 11:11:22 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad46660d92c9186037281fd/b64f98a9-ce86-4f80-99b3-98e21df9c689.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Your app is live, someone signs up, and the confirmation email sends them to <code>localhost:3000</code>, which their browser can't reach ("This site can't be reached"). The password reset email does the same thing. The cause is one project setting, and the fix takes a few minutes.</p>
<h2>Where the link comes from</h2>
<p>Supabase builds the links in auth emails from your project's <strong>Site URL</strong>. New projects start with it set to <code>http://localhost:3000</code>. Three things send users there:</p>
<ul>
<li><strong>The Site URL was never changed.</strong> With no other instruction, every link falls back to it.</li>
<li><strong>The app redirects to an address Supabase doesn't allow.</strong> A redirect that isn't on the <strong>Redirect URLs</strong> list is dropped, and the Site URL is used instead.</li>
<li><strong>The email template links with <code>{{ .SiteURL }}</code>.</strong> A customised template that uses it ignores the redirect your code passed. For redirect flows the template should use <code>{{ .RedirectTo }}</code>.</li>
</ul>
<h2>Work out which one you have</h2>
<p>Hover the button in the email and read the address. Then open <strong>Authentication &gt; URL Configuration</strong> in the Supabase dashboard and compare it with the Site URL and the Redirect URLs list. Last, look at the calls in your code to <code>signUp</code>, <code>resetPasswordForEmail</code> and <code>signInWithOtp</code>, and note the redirect address each one passes.</p>
<h2>Fix the project settings</h2>
<p>Set the Site URL to the address your users actually use, such as <code>https://app.example.com</code>. Under Redirect URLs, add the exact pages your auth flows land on, for example <code>/auth/callback</code> and <code>/reset-password</code> on that domain. You can keep <code>http://localhost:3000/**</code> for local development, but in production the Supabase docs recommend exact paths over wildcards.</p>
<h2>Pass the redirect from your code</h2>
<p>When the redirect is built from the current origin, the same code works on previews and in production:</p>
<pre><code class="language-js">await supabase.auth.signUp({
  email,
  password,
  options: { emailRedirectTo: window.location.origin + '/auth/callback' },
});

await supabase.auth.resetPasswordForEmail(email, {
  redirectTo: window.location.origin + '/reset-password',
});
</code></pre>
<p>Each origin you run on still has to match an entry on the Redirect URLs list.</p>
<h2>Update custom templates</h2>
<p>If you edited the email templates, open <strong>Authentication &gt; Email Templates</strong> and swap <code>{{ .SiteURL }}</code> for <code>{{ .RedirectTo }}</code> wherever the flow passes a redirect address.</p>
<h2>Prove it's fixed</h2>
<ol>
<li>Sign up with an email address you haven't used before. The link should open your live site and sign you in.</li>
<li>Ask for a password reset. The link should open the reset page on your live domain.</li>
<li>If you have a preview or staging site, try once there too, so you know its pattern is allowed.</li>
</ol>
<h2>Keep it fixed</h2>
<p>Both settings belong to each Supabase project, so add them to your launch checklist for every new one. And set up a custom sending domain for auth emails before launch, so the emails land in inboxes in the first place.</p>
<p>Two questions I get: you don't need to redeploy after changing the Site URL, because new emails pick it up straight away (old emails keep their old links). For a mobile app, use a deep link such as <code>com.example.app://login-callback</code> as the redirect and add it to the Redirect URLs list.</p>
<p>Source: <a href="https://supabase.com/docs/guides/auth/redirect-urls">Supabase: Redirect URLs</a>.</p>
<hr />
<p>This fix first appeared on our site: <a href="https://stacksmesh.com/fix/supabase-confirmation-email-redirects-to-localhost?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0003">Supabase confirmation or reset email links to localhost</a>. If you want a quick outside look at your live app (exposed keys, security headers, what Google can read), the free <a href="https://stacksmesh.com/tools/launch-check?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0003">Launch Check</a> takes about a minute.</p>
<p><em>I wrote this with AI assistance and reviewed it myself.</em></p>
]]></content:encoded></item><item><title><![CDATA[Four checks to run before you launch a Lovable or Replit app (from 578 live apps)]]></title><description><![CDATA[Four checks for a Lovable or Replit app before launch: crawlable HTML, a sitemap, mobile speed and security headers, with figures from 578 live apps.]]></description><link>https://stacksmesh.hashnode.dev/four-checks-before-you-launch-a-lovable-or-replit-app</link><guid isPermaLink="true">https://stacksmesh.hashnode.dev/four-checks-before-you-launch-a-lovable-or-replit-app</guid><category><![CDATA[Web Development]]></category><category><![CDATA[Security]]></category><category><![CDATA[SEO]]></category><category><![CDATA[React]]></category><category><![CDATA[lovable]]></category><dc:creator><![CDATA[Ahmed Hassan Yaseen]]></dc:creator><pubDate>Tue, 20 Oct 2026 01:21:46 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad46660d92c9186037281fd/0594e1da-1257-45a8-acc2-857029281901.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>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.</p>
<p>The numbers come from our <a href="https://stacksmesh.com/resources/state-of-ai-built-apps-2026?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0016">State of AI-Built Apps 2026 report</a>: "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.</p>
<h2>1. Can Google and AI assistants read your pages?</h2>
<p>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."</p>
<p>Lovable and most Vite apps render in the browser. The HTML your server sends is an empty <code>&lt;div id="root"&gt;</code> 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" (<a href="https://developers.google.com/search/docs/crawling-indexing/javascript/javascript-seo-basics">Google Search Central: JavaScript SEO basics</a>).</p>
<p><strong>How to check:</strong> 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.</p>
<p><strong>How to fix:</strong> 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 <code>&lt;a href&gt;</code> links for navigation rather than click handlers, so crawlers can follow them. Your app screens behind a login can stay client-rendered.</p>
<h2>2. A sitemap Google can find</h2>
<p>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.</p>
<p><strong>How to check:</strong> open <code>https://your-domain.com/sitemap.xml</code> and <code>https://your-domain.com/robots.txt</code>.</p>
<p><strong>How to fix:</strong> write a <code>sitemap.xml</code> that lists every public URL and put it in Vite's <code>public/</code> folder. Vite copies that folder to the root of the build as-is (<a href="https://vite.dev/guide/assets">Vite: the public directory</a>). 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 <code>Sitemap: https://your-domain.com/sitemap.xml</code> to <code>robots.txt</code> (<a href="https://developers.google.com/search/docs/crawling-indexing/sitemaps/build-sitemap">Google: build and submit a sitemap</a>). Only list pages you want found, not dashboards or settings screens.</p>
<h2>3. How fast it loads on a phone</h2>
<p>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."</p>
<p>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" (<a href="https://web.dev/articles/lcp">web.dev: LCP</a>).</p>
<p><strong>How to check:</strong> run your home page through <a href="https://pagespeed.web.dev/">PageSpeed Insights</a> on the mobile tab. Its lab data comes from Lighthouse and simulates a page load (<a href="https://developers.google.com/speed/docs/insights/v5/about">PSI docs</a>); if your site has enough real visitors, it also shows field data.</p>
<p><strong>How to fix:</strong> 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 <code>React.lazy</code>, which defers a component's code "until it is rendered for the first time" (<a href="https://react.dev/reference/react/lazy">React docs</a>), 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 <code>fetchpriority="high"</code>, as web.dev recommends for the LCP image (<a href="https://web.dev/articles/optimize-lcp">web.dev: Optimize LCP</a>). Prerendering from check 1 helps here too, because the headline arrives with the HTML.</p>
<h2>4. Two security headers: Content-Security-Policy and frame protection</h2>
<p>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."</p>
<p>What they do:</p>
<ul>
<li>A <strong>Content-Security-Policy (CSP)</strong> 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 (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP">MDN: CSP guide</a>).</li>
<li><strong>Frame protection</strong> stops another site from loading your app inside an invisible iframe and tricking people into clicks (clickjacking). The modern way is the CSP directive <code>frame-ancestors</code> (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors">MDN: frame-ancestors</a>); <code>X-Frame-Options: DENY</code> is the older header that covers older browsers (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options">MDN: X-Frame-Options</a>).</li>
</ul>
<p><strong>How to check:</strong> run <code>curl -I https://your-domain.com</code> and look for <code>content-security-policy</code> and <code>x-frame-options</code> in the output. If neither is there, you're in the majority.</p>
<p><strong>How to fix:</strong> 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 <code>vercel.json</code> (<a href="https://vercel.com/docs/project-configuration/vercel-json">Vercel docs</a>); on Netlify, in a <code>_headers</code> file (<a href="https://docs.netlify.com/manage/routing/headers/">Netlify docs</a>). The frame headers are safe to switch on straight away if you don't embed your own app anywhere. Here's a Netlify <code>_headers</code> file you can put in <code>public/</code> so it lands at the root of the build:</p>
<pre><code>/*
  Content-Security-Policy: frame-ancestors 'none'
  X-Frame-Options: DENY
  X-Content-Type-Options: nosniff
  Referrer-Policy: strict-origin-when-cross-origin
</code></pre>
<p>The last two lines are the report's other cheap wins: <code>nosniff</code> stops the browser guessing file types (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Content-Type-Options">MDN</a>), and a Referrer-Policy keeps full page addresses from leaking to other sites (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy">MDN</a>).</p>
<p>A full CSP that limits scripts needs more care, because a wrong policy blocks your own Supabase calls or fonts. Ship it first as <code>Content-Security-Policy-Report-Only</code>, which reports violations without blocking anything (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy-Report-Only">MDN</a>), use the app for a day, fix what the console reports, then enforce it. One catch: <code>frame-ancestors</code> doesn't work in a <code>&lt;meta&gt;</code> tag, so clickjacking protection always needs a real response header.</p>
<h2>The short version</h2>
<p>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.</p>
<p>If you'd rather not run each check by hand, our free <a href="https://stacksmesh.com/tools/launch-check?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0016">Launch Check</a> 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.</p>
<p><em>I wrote this with AI assistance and reviewed it myself.</em></p>
]]></content:encoded></item><item><title><![CDATA[Supabase "infinite recursion detected in policy": why your team policy loops and how to fix it]]></title><description><![CDATA[Fix Supabase error 42P17: move the membership lookup into a security definer function in a private schema, and keep roles out of tables users can edit]]></description><link>https://stacksmesh.hashnode.dev/supabase-infinite-recursion-detected-in-policy-why-your-team-policy-loops-and-how-to-fix-it</link><guid isPermaLink="true">https://stacksmesh.hashnode.dev/supabase-infinite-recursion-detected-in-policy-why-your-team-policy-loops-and-how-to-fix-it</guid><category><![CDATA[supabase]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Security]]></category><category><![CDATA[database]]></category><category><![CDATA[lovable]]></category><dc:creator><![CDATA[Ahmed Hassan Yaseen]]></dc:creator><pubDate>Tue, 20 Oct 2026 00:15:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad46660d92c9186037281fd/76c39811-0b89-4148-acae-362e395f84e6.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>You add teams to your Supabase app, write a policy so members can see their own team, and every query on the table starts failing with <code>infinite recursion detected in policy for relation</code> (code 42P17). The policy is asking a question that needs the policy to answer it. Here is how I untangle it.</p>
<h2>What causes the loop</h2>
<p>There are three common shapes:</p>
<ul>
<li><strong>The membership table checks itself.</strong> The policy on <code>team_members</code> decides whether you are a member by selecting from <code>team_members</code>. That select runs the same policy, which runs the same select.</li>
<li><strong>Two tables check each other.</strong> The <code>projects</code> policy reads <code>team_members</code>, and the <code>team_members</code> policy reads <code>projects</code>. Neither can be evaluated first.</li>
<li><strong>The roles table guards itself.</strong> The <code>profiles</code> policy reads <code>profiles.role</code> to work out who is an admin.</li>
</ul>
<h2>Track it down</h2>
<p>The relation named in the error is the table whose policy loops. Open every policy on it and look for a subquery that selects from that same table. If a subquery reads another table, open that table's policies too: two tables that reference each other loop just like one.</p>
<h2>The fix: a security definer lookup</h2>
<p>Move the "is this user a member?" question into a small function that runs as its owner, so it can read <code>team_members</code> without triggering the policy:</p>
<ol>
<li>Put it in a <code>private</code> schema that is not listed under Exposed schemas in the API settings.</li>
<li>Declare it <code>security definer</code>, set <code>search_path = ''</code>, and schema-qualify every name.</li>
<li>Have the policy call the function instead of running its own subquery.</li>
</ol>
<pre><code class="language-sql">create schema if not exists private;

create or replace function private.is_team_member(team uuid)
returns boolean
language sql
security definer
set search_path = ''
stable
as $$
  select exists (
    select 1 from public.team_members m
    where m.team_id = team and m.user_id = (select auth.uid())
  );
$$;

drop policy if exists "Members can read their team" on public.team_members;
create policy "Members can read their team"
on public.team_members for select
to authenticated
using ( private.is_team_member(team_id) );
</code></pre>
<h2>Roles: keep them where users can't write</h2>
<p>If the loop comes from an admin check, the bigger problem is that the role sits in a column the user can update. Store roles in a table only the server writes, or in the user's <code>app_metadata</code>, which users cannot change. Read them through the same kind of function.</p>
<h2>Test it</h2>
<ul>
<li>As a signed-in member, the query that failed now returns rows.</li>
<li>As a user who isn't a member, it returns no rows and no error.</li>
<li>The <code>private</code> schema is absent from the API's exposed schemas, so nobody can call the function directly.</li>
</ul>
<h2>Keep it from coming back</h2>
<p>Sketch which policy reads which table before writing them, because any cycle will loop. Then give each cross-table question (is member, is admin) its own small function.</p>
<h2>FAQ</h2>
<ul>
<li><strong>Is security definer safe here?</strong> Yes, if the function lives in an unexposed schema, has an empty search path, qualifies every name and returns only true or false. Keep it small, since it runs as its owner.</li>
<li><strong>Can I turn RLS off on the membership table instead?</strong> You can, but then anyone with your publishable key can list every team and its members. The function keeps the table protected.</li>
</ul>
<p>Source: <a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase: Row Level Security (security definer functions)</a>.</p>
<hr />
<p>This is a cross-post of <a href="https://stacksmesh.com/fix/supabase-infinite-recursion-detected-in-policy?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0003">my original fix on stacksmesh.com</a>. To check what anyone can see from outside your live app (exposed keys, security headers, what Google reads), run the free <a href="https://stacksmesh.com/tools/launch-check?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0003">Launch Check</a>.</p>
<p><em>I wrote this with AI assistance and reviewed it myself.</em></p>
]]></content:encoded></item><item><title><![CDATA[Add a Content-Security-Policy to your AI-built app on Vercel or Netlify, step by step]]></title><description><![CDATA[Add a Content-Security-Policy and clickjacking protection to a Vite or React app on Vercel or Netlify: a starting policy, report-only testing, then…]]></description><link>https://stacksmesh.hashnode.dev/add-a-content-security-policy-to-your-ai-built-app-on-vercel-or-netlify-step-by-step</link><guid isPermaLink="true">https://stacksmesh.hashnode.dev/add-a-content-security-policy-to-your-ai-built-app-on-vercel-or-netlify-step-by-step</guid><category><![CDATA[Security]]></category><category><![CDATA[Web Development]]></category><category><![CDATA[vite]]></category><category><![CDATA[supabase]]></category><category><![CDATA[lovable]]></category><dc:creator><![CDATA[Ahmed Hassan Yaseen]]></dc:creator><pubDate>Mon, 19 Oct 2026 07:12:40 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad46660d92c9186037281fd/dab6ecf0-d6a6-4f64-8bea-745dbc66552c.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>When we check AI-built apps, two response headers are missing far more often than not: a Content-Security-Policy and framing protection. Both take a few lines in your host config. Here is the order I add them in for a Vite or React app on Vercel, Netlify or Lovable's own hosting, so nothing breaks along the way.</p>
<h2>Why this matters for AI-built apps</h2>
<p>The <a href="https://stacksmesh.com/resources/state-of-ai-built-apps-2026?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0019">State of AI-Built Apps 2026 report</a> put numbers on it. "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." The biggest gap: "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."</p>
<p>A missing header breaks nothing you can see, which is why it's so easy to ship without one. And a Lovable or Vite build is a folder of static files, so no change to your React code adds these headers. They come from whatever serves the files.</p>
<p>Two headers close most of that gap:</p>
<ul>
<li><p><code>Content-Security-Policy</code> <strong>(CSP)</strong> tells the browser which sources your page may load scripts, styles, images and network requests from. MDN's <a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP">CSP guide</a> explains it as a defence against cross-site scripting: injected code that runs as if it were your own.</p>
</li>
<li><p><strong>Framing protection.</strong> The CSP <code>frame-ancestors</code> directive controls which sites may embed your page in an iframe. MDN calls it "an effective protection against clickjacking attacks" and says that unless you need your site to be embeddable, you should set it to <code>'none'</code>. <code>X-Frame-Options: DENY</code> is the older header that does the same job in older browsers (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options">MDN: X-Frame-Options</a>).</p>
</li>
</ul>
<h2>Step 1: write a starting policy</h2>
<p>Before writing anything, open developer tools on your live site, go to the Network tab, reload and use the main features. Write down every domain the app talks to: your Supabase project, font hosts, analytics, payments, image hosts. Those go in the policy.</p>
<p>For a typical Vite + React app on Supabase, I start here. Replace <code>YOUR-PROJECT</code> with your Supabase project reference:</p>
<pre><code class="language-plaintext">default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https:;
font-src 'self' data:;
connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co;
frame-ancestors 'none';
base-uri 'self';
form-action 'self';
object-src 'none'
</code></pre>
<p>What each line is doing:</p>
<ul>
<li><p><code>default-src 'self'</code>: anything not listed below may only come from your own domain.</p>
</li>
<li><p><code>script-src 'self'</code>: scripts only from your own files. MDN warns against <code>'unsafe-inline'</code> for scripts because "it defeats much of the purpose of having a CSP".</p>
</li>
<li><p><code>style-src 'self' 'unsafe-inline'</code>: many UI libraries add style tags at runtime and stop working without <code>'unsafe-inline'</code>. Inline styles carry far less risk than inline scripts, which this policy still blocks. If your app works without it in report-only mode, remove it.</p>
</li>
<li><p><code>img-src</code> and <code>font-src</code>: Vite inlines small images and fonts as <code>data:</code> URIs, so allow <code>data:</code> there. Never allow <code>data:</code> in <code>script-src</code>.</p>
</li>
<li><p><code>connect-src</code>: where <code>fetch</code> and WebSocket calls may go. Your Supabase API is <code>https://&lt;ref&gt;.supabase.co</code>, and Supabase Realtime connects over <code>wss://&lt;ref&gt;.supabase.co/realtime/v1/websocket</code> (<a href="https://supabase.com/docs/guides/realtime/protocol">Supabase: Realtime protocol</a>). MDN notes that <code>'self'</code> does not cover WebSocket schemes in every browser (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/connect-src">MDN: connect-src</a>), so list the <code>wss://</code> host explicitly.</p>
</li>
<li><p><code>frame-ancestors 'none'</code>: nobody may frame your app. If you embed your own pages, use <code>'self'</code> instead.</p>
</li>
<li><p><code>base-uri 'self'</code>, <code>form-action 'self'</code> and <code>object-src 'none'</code>: an injected <code>&lt;base&gt;</code> tag can't change where relative URLs point, forms only submit to your own domain, and <code>&lt;object&gt;</code>/<code>&lt;embed&gt;</code> are blocked.</p>
</li>
</ul>
<p>Then add each other domain from your Network list to the matching rule. Google Fonts, for example, needs <code>https://fonts.googleapis.com</code> in <code>style-src</code> and <code>https://fonts.gstatic.com</code> in <code>font-src</code>. Payment and analytics providers publish the domains their scripts need. Add only the hosts you actually use.</p>
<h2>Step 2: ship it in report-only mode first</h2>
<p>A wrong policy breaks your app, so don't enforce it on day one. The <code>Content-Security-Policy-Report-Only</code> header (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy-Report-Only">MDN reference</a>) exists for exactly this: in MDN's words, "The policy is not enforced, but any violations are sent to the reporting endpoint." Deploy the report-only version and use every screen for a few days with the browser console open: sign in, save data, upload, pay, and anything realtime. Each report names the rule and the blocked address. Each one is either a host you forgot or something you don't want loading at all.</p>
<p><strong>On Vercel</strong>, add a <code>headers</code> block to <code>vercel.json</code> in the project root and redeploy. The <code>/(.*)</code> source matches every path, as in <a href="https://vercel.com/docs/project-configuration/vercel-json">Vercel's own example</a>. If the file already has the rewrite that serves <code>index.html</code> for your app routes, keep both keys in the same file. Each header value must stay on one line:</p>
<pre><code class="language-json">{
  "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy-Report-Only",
          "value": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'"
        },
        { "key": "X-Frame-Options", "value": "DENY" }
      ]
    }
  ]
}
</code></pre>
<p><strong>On Netlify</strong>, create a <code>_headers</code> file. It has to end up in the folder you deploy (<a href="https://docs.netlify.com/manage/routing/headers/">Netlify: custom headers</a>). In a Vite project, files in <code>public/</code> are "copied to the root of the dist directory as-is" (<a href="https://vite.dev/guide/assets">Vite: the public directory</a>), so put it at <code>public/_headers</code>, with each header on its own indented line under the path:</p>
<pre><code class="language-plaintext">/*
  Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self' https://YOUR-PROJECT.supabase.co wss://YOUR-PROJECT.supabase.co; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'
  X-Frame-Options: DENY
</code></pre>
<p>Netlify's docs add one limit worth knowing: custom headers aren't applied to proxied content or to URLs handled by a function or edge function, such as server-side rendered pages.</p>
<p><strong>On Lovable's own hosting</strong>, put the headers in front of the app or host the build yourself. If your domain already runs through Cloudflare, a <a href="https://developers.cloudflare.com/rules/transform/response-header-modification/">Response Header Transform Rule</a> can set the same headers on every response. Otherwise, Lovable's Git sync keeps the code in a repository you own so you can "deploy anywhere" (<a href="https://docs.lovable.dev/integrations/git-sync-overview">Lovable: Git sync</a>), for example with the Vercel or Netlify setup above. Either way, confirm with <code>curl -I https://your-domain.com</code> that the headers reach your custom domain.</p>
<h2>Step 3: enforce it</h2>
<p>When the console stays clean, rename the header from <code>Content-Security-Policy-Report-Only</code> to <code>Content-Security-Policy</code> and deploy again. Keep <code>X-Frame-Options: DENY</code> next to it for older browsers; it costs nothing.</p>
<p>Then test the important flows once more: sign-in, data loading, file upload, checkout and anything realtime. A quick framing test helps too: a page on another origin that loads your site in an iframe should show the frame blocked.</p>
<h2>What if my host won't let me set headers?</h2>
<p>MDN says you can also deliver a CSP in a <code>&lt;meta http-equiv="Content-Security-Policy"&gt;</code> tag, and that this suits "a client-side-rendered single page app which has only static resources". It has two catches. A report-only policy cannot be delivered in a <code>&lt;meta&gt;</code> element (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CSP">MDN: CSP guide</a>), and <code>frame-ancestors</code> is not supported in <code>&lt;meta&gt;</code> (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy/frame-ancestors">MDN: frame-ancestors</a>). Setting <code>X-Frame-Options</code> in a meta tag "has no effect" (<a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/X-Frame-Options">MDN: X-Frame-Options</a>). So a meta tag can restrict scripts and requests, but clickjacking protection needs a real response header.</p>
<h2>Common mistakes I'd avoid</h2>
<ul>
<li><p><strong>Adding</strong> <code>'unsafe-inline'</code> <strong>or</strong> <code>'unsafe-eval'</code> <strong>to</strong> <code>script-src</code><strong>, or a bare</strong> <code>*</code> <strong>anywhere, to make an error go away.</strong> That switches most of the protection off. Find the inline script and move it into a file instead.</p>
</li>
<li><p><strong>Forgetting the</strong> <code>wss://</code> <strong>host.</strong> The browser blocks the Realtime connection.</p>
</li>
<li><p><strong>Setting</strong> <code>frame-ancestors 'none'</code> <strong>when you really do embed the app</strong>, for example inside your own marketing site. List that site's origin instead of <code>'none'</code>.</p>
</li>
<li><p><strong>Only checking the home page.</strong> Policies break on the screens that load third-party code: payments, maps, support chat.</p>
</li>
<li><p><strong>Adding a new service without updating the policy.</strong> When you add analytics, chat or payments, add its domains to the policy in the same change, and keep <code>vercel.json</code> or <code>public/_headers</code> in the repository so every deploy carries the headers.</p>
</li>
</ul>
<h2>Check it from outside</h2>
<p>Once it's live, run <code>curl -I</code> on your home page and on a deep link and look for <code>content-security-policy</code> and <code>x-frame-options</code>. Or run the free <a href="https://stacksmesh.com/tools/launch-check?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0019">Launch Check</a> on your URL: it checks exposed keys, security headers, what Google and AI assistants see, speed and accessibility, in about a minute, with no sign-up.</p>
<p><em>I wrote this with AI assistance and reviewed it myself.</em></p>
]]></content:encoded></item><item><title><![CDATA[Exposed OpenAI or Gemini API key in your app? Fix it in three steps]]></title><description><![CDATA[Move the call into a Supabase Edge Function, rotate the key, and prove the browser never sees it]]></description><link>https://stacksmesh.hashnode.dev/exposed-openai-or-gemini-api-key-in-your-app-fix-it-in-three-steps</link><guid isPermaLink="true">https://stacksmesh.hashnode.dev/exposed-openai-or-gemini-api-key-in-your-app-fix-it-in-three-steps</guid><category><![CDATA[Security]]></category><category><![CDATA[supabase]]></category><category><![CDATA[openai]]></category><category><![CDATA[JavaScript]]></category><category><![CDATA[lovable]]></category><dc:creator><![CDATA[Ahmed Hassan Yaseen]]></dc:creator><pubDate>Mon, 19 Oct 2026 06:16:43 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6ad46660d92c9186037281fd/c965c2b1-aeea-4536-ba41-6f67a9f5f317.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Shipping an AI feature fast often means the OpenAI or Gemini call sits right in the frontend. That works in preview, but the key then travels inside your JavaScript bundle, and anyone who opens it can run requests on your bill. Here's the order of work I use to close the hole: secret first, server-side call second, new key last.</p>
<h2>How a key leaks into the bundle</h2>
<ol>
<li><p><strong>Direct calls from React.</strong> The builder wires the provider into a component so the demo works, and the key loads with every page.</p>
</li>
<li><p><strong>Public env prefixes.</strong> Vite inlines every <code>VITE_</code> variable at build time, and Next.js inlines <code>NEXT_PUBLIC_</code>. A key stored under either is public.</p>
</li>
<li><p><code>dangerouslyAllowBrowser</code><strong>.</strong> OpenAI's JavaScript library only runs in a browser with this flag set, and its own docs say it exposes your secret credentials.</p>
</li>
<li><p><strong>Pasted keys.</strong> A key dropped into the builder's chat or a settings file can be written into frontend code.</p>
</li>
</ol>
<h2>Diagnose before you change anything</h2>
<ul>
<li><p>Run the free Launch Check against the live app. It scans the JavaScript your site sends for OpenAI, Anthropic and Gemini keys and never shows the key itself.</p>
</li>
<li><p>In the Network tab, trigger the AI feature. Requests to <code>api.openai.com</code> or <code>generativelanguage.googleapis.com</code> coming from the browser confirm the leak.</p>
</li>
<li><p>Search the codebase for <code>sk-proj-</code>, <code>sk-ant-</code>, <code>AIza</code>, <code>dangerouslyAllowBrowser</code> and key-holding <code>VITE_</code> variables.</p>
</li>
<li><p>Look at the provider's usage page for traffic or spend you don't recognise.</p>
</li>
</ul>
<h2>Step 1: put the call behind an Edge Function</h2>
<p>Save the key as a secret, not as a <code>VITE_</code> variable. In Lovable, that's More &gt; Cloud &gt; Secrets. For your own Supabase project, run <code>supabase secrets set OPENAI_API_KEY=...</code>.</p>
<p>The function should accept signed-in users only, check its input, and read the key from <code>Deno.env.get</code>. Gemini works the same way through the <code>generateContent</code> endpoint, with the key in the <code>x-goog-api-key</code> header. The page then uses <code>supabase.functions.invoke</code>, and the key and provider SDK come out of the frontend entirely.</p>
<pre><code class="language-ts">// supabase/functions/ai-reply/index.ts
// The key lives in Edge Function secrets, never in the app.
import { withSupabase } from 'npm:@supabase/server@1';

export default {
  // auth: 'user' refuses callers who are not signed in.
  // CORS and preflight requests are handled for you.
  fetch: withSupabase({ auth: 'user' }, async (req) =&gt; {
    const { prompt } = await req.json();
    if (typeof prompt !== 'string' || prompt.length &gt; 4000) {
      return Response.json({ error: 'Invalid prompt' }, { status: 400 });
    }

    const res = await fetch('https://api.openai.com/v1/responses', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${Deno.env.get('OPENAI_API_KEY')}`,
        'Content-Type': 'application/json',
      },
      // Use any model your project has access to.
      body: JSON.stringify({ model: 'gpt-5-mini', input: prompt }),
    });
    if (!res.ok) {
      return Response.json({ error: 'AI request failed' }, { status: 502 });
    }
    return Response.json(await res.json());
  }),
};

// In the page: no key and no OpenAI SDK, just the function.
const { data, error } = await supabase.functions.invoke('ai-reply', {
  body: { prompt },
});
</code></pre>
<h2>Step 2: rotate the leaked key</h2>
<p>Wait until the function is live, or the replacement leaks too.</p>
<ul>
<li><p><strong>OpenAI:</strong> new key on the project's API keys page, store it as the server secret, delete the leaked key.</p>
</li>
<li><p><strong>Gemini:</strong> new key in Google AI Studio (since 28 May 2026, keys created there are auth keys limited to the Gemini API by default), store it, delete the old one, and restrict any key you keep to the Gemini API.</p>
</li>
<li><p>Deletion can't be undone, so move every function and server to the new key before you delete.</p>
</li>
</ul>
<h2>Step 3: contain the cost in the meantime</h2>
<ul>
<li><p>A budget alert in the provider's billing settings tells you about unexpected spend the same day.</p>
</li>
<li><p>A per-user request limit in the function, such as a daily count in a table, stops one account draining the budget.</p>
</li>
<li><p>Record unfamiliar usage and its dates in case you need to take it up with the provider.</p>
</li>
</ul>
<h2>Verify</h2>
<ul>
<li><p>The Launch Check finds no AI keys.</p>
</li>
<li><p>DevTools shows the browser calling your function, not the provider.</p>
</li>
<li><p>A signed-out call to the function is refused.</p>
</li>
<li><p>The leaked key shows as deleted in the provider dashboard.</p>
</li>
</ul>
<h2>Prevent a repeat</h2>
<p>Keep provider keys in server secrets only, never in a public env prefix and never with <code>dangerouslyAllowBrowser</code>. Ask your builder for AI features "through an Edge Function, with the key stored in Secrets" from the very first prompt. Turn on GitHub secret scanning and rerun the Launch Check after each publish.</p>
<h2>FAQ</h2>
<ul>
<li><p><strong>Does an environment variable hide the key?</strong> Not in frontend code: <code>VITE_</code> values are written into the JavaScript at build time. Only a server or Edge Function keeps them secret.</p>
</li>
<li><p><strong>Is a domain-restricted Gemini key enough?</strong> Not for a key that can spend money. The restriction checks the Referer header, which a script outside a browser can set to anything; Google recommends calling the Gemini API from your server.</p>
</li>
<li><p><strong>Is Lovable's built-in AI affected?</strong> Not by design. It uses <code>LOVABLE_API_KEY</code>, a secret only Edge Functions can read, with a Rotate action in Secrets. The risk is a key you add and call from the page yourself.</p>
</li>
<li><p><strong>Will OpenAI or Google kill a leaked key for me?</strong> Don't rely on it. Until they act, every request with your key is billed to you.</p>
</li>
</ul>
<hr />
<p>I first published this fix on <a href="https://stacksmesh.com/fix/ai-api-key-exposed-in-frontend?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0002">stacksmesh.com</a>, where every official source is linked. To scan your own app for exposed keys, try the free <a href="https://stacksmesh.com/tools/launch-check?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=BR-0002">Launch Check</a>.</p>
<p>I wrote this with AI assistance and reviewed it myself.</p>
]]></content:encoded></item><item><title><![CDATA[Fixing "new row violates row-level security policy" in Supabase]]></title><description><![CDATA[If your app shows "new row violates row-level security policy for table" (Postgres code 42501), the short answer: Postgres refused the row because no…]]></description><link>https://stacksmesh.hashnode.dev/fixing-new-row-violates-row-level-security-policy-in-supabase</link><guid isPermaLink="true">https://stacksmesh.hashnode.dev/fixing-new-row-violates-row-level-security-policy-in-supabase</guid><category><![CDATA[supabase]]></category><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Security]]></category><dc:creator><![CDATA[Ahmed Hassan Yaseen]]></dc:creator><pubDate>Sun, 18 Oct 2026 07:11:24 GMT</pubDate><content:encoded><![CDATA[<p>If your app shows <strong>"new row violates row-level security policy for table"</strong> (Postgres code <code>42501</code>), the short answer:</p>
<blockquote>
<p>Postgres refused the row because no policy's WITH CHECK allows it for the role making the request. Add an INSERT policy (and UPDATE plus SELECT policies for upserts) that matches how your app writes, for the authenticated role, and make sure the user is actually signed in.</p>
</blockquote>
<p>This comes up constantly in apps built with AI builders like Lovable and Bolt, because they often create a read policy and forget the write side. Below: how to tell which case you are in, the policies that fix it, and how to prove the fix with a test, following the Supabase row-level security guide.</p>
<h2>Why it happens</h2>
<ul>
<li><strong>There is no INSERT policy.</strong> With row-level security on and no INSERT policy, every insert fails. AI builders often create a SELECT policy and forget the write side.</li>
<li><strong>The policy compares the wrong column.</strong> The check reads (select auth.uid()) = user_id, but the app inserts the row without user_id, or with a different column such as owner_id or created_by.</li>
<li><strong>The request is not signed in.</strong> For a request without a session, auth.uid() is null, so auth.uid() = user_id is never true. A page that loads before the session is restored writes as anon.</li>
<li><strong>An upsert needs more than INSERT.</strong> An upsert may update an existing row, which needs an UPDATE policy with both USING and WITH CHECK, and a SELECT policy so the row can be found.</li>
<li><strong>The role is missing a grant.</strong> A missing table grant raises the same code 42501 before any policy runs.</li>
</ul>
<h2>Find the cause in five minutes</h2>
<ol>
<li>Open the browser console, run the action again and copy the full error, including the table name.</li>
<li>In the Supabase dashboard, open Authentication &gt; Policies and list the policies on that table, per operation.</li>
<li>Log the session just before the write: if supabase.auth.getSession() returns no session, the request runs as anon.</li>
<li>Compare the columns the app sends with the columns the policy reads. A missing user_id is the most common mismatch.</li>
<li>If the app uses upsert, check for UPDATE and SELECT policies as well as INSERT.</li>
</ol>
<h2>The fixes</h2>
<h3>Add an INSERT policy for signed-in users that owns the row</h3>
<ol>
<li>Make sure the table has a user_id column that references auth.users.</li>
<li>Create the policy for the authenticated role only.</li>
<li>Send user_id with every insert, or give the column a default of auth.uid().</li>
</ol>
<pre><code class="language-sql">alter table public.projects enable row level security;

create policy "Users can create their own projects"
on public.projects for insert
to authenticated
with check ( (select auth.uid()) = user_id );

-- Optional: fill user_id on the server so the app cannot forget it
alter table public.projects alter column user_id set default auth.uid();
</code></pre>
<h3>Cover upserts and updates</h3>
<ol>
<li>Add a SELECT policy so the existing row can be found.</li>
<li>Add an UPDATE policy with USING (which rows may change) and WITH CHECK (what they may become).</li>
</ol>
<pre><code class="language-sql">create policy "Users can read their own projects"
on public.projects for select
to authenticated
using ( (select auth.uid()) = user_id );

create policy "Users can update their own projects"
on public.projects for update
to authenticated
using ( (select auth.uid()) = user_id )
with check ( (select auth.uid()) = user_id );
</code></pre>
<h3>Wait for the session before writing</h3>
<ol>
<li>Write only after the auth state is known; disable the save button until a user exists.</li>
<li>Never fix this by switching the write to a secret or service_role key in the browser: that bypasses every policy for everyone.</li>
</ol>
<h3>Test the policies in SQL before you ship</h3>
<ol>
<li>In the SQL editor (or a pgTAP test), switch to the role and the user inside a transaction, as the Supabase guide recommends.</li>
<li>Try the write as the owner, as another user and as anon, and expect 42501 for the last two.</li>
<li>Keep it as a test: supabase test new projects_rls.test, then run supabase test db before every release. Prove an allowed write by checking the row exists afterwards, not only that no error was raised.</li>
</ol>
<pre><code class="language-sql">-- Owner: must succeed
begin;
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
insert into public.projects (name, user_id)
values ('My project', '11111111-1111-1111-1111-111111111111');
rollback;

-- Same user writing a row for someone else: must fail with 42501
begin;
set local role authenticated;
set local request.jwt.claim.sub = '11111111-1111-1111-1111-111111111111';
insert into public.projects (name, user_id)
values ('Not mine', '22222222-2222-2222-2222-222222222222');
rollback;
</code></pre>
<h2>Check that it is really fixed</h2>
<ul>
<li>Signed in as user A, insert a row: it succeeds.</li>
<li>Signed in as user A, try to insert a row with user B's user_id: it must fail with 42501.</li>
<li>Signed out, try the same insert: it must fail.</li>
<li>Run the Supabase security advisor (Database &gt; Advisors) and confirm the table shows no warnings.</li>
</ul>
<h2>Keep it from coming back</h2>
<ul>
<li>Write one policy per operation and name the role on each (to authenticated).</li>
<li>Keep a short test per table: owner can write, other user cannot, anonymous cannot.</li>
<li>Index the column your policies filter on (user_id) so policies stay fast as data grows.</li>
<li>Write (select auth.uid()) rather than auth.uid(): Postgres then runs it once per statement instead of once per row, which matters on large tables.</li>
</ul>
<hr />
<p><em>Originally published in the <a href="https://stacksmesh.com/fix/supabase-new-row-violates-row-level-security-policy">StacksMesh fix library</a>, which covers the errors AI-built apps hit most, each checked against the official docs. To check a live app for exposed keys and missing headers, the <a href="https://stacksmesh.com/tools/launch-check">Launch Check</a> is free and needs no sign-up.</em></p>
<p>Sources: <a href="https://supabase.com/docs/guides/database/postgres/row-level-security">Supabase: Row Level Security</a>, <a href="https://supabase.com/docs/guides/api/api-keys">Supabase: API keys</a>.</p>
]]></content:encoded></item></channel></rss>