<?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: Marco Gundlach</title>
    <description>The latest articles on DEV Community by Marco Gundlach (@mgundlach).</description>
    <link>https://dev.to/mgundlach</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%2F3983165%2F7eabbe25-f93a-4cb8-a585-76393fc52c68.png</url>
      <title>DEV Community: Marco Gundlach</title>
      <link>https://dev.to/mgundlach</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/mgundlach"/>
    <language>en</language>
    <item>
      <title>I Review Vibe-Coded Apps for a Living. The Same Supabase RLS Mistake Is in Almost Every One.</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Tue, 11 Aug 2026 05:30:00 +0000</pubDate>
      <link>https://dev.to/mgundlach/i-review-vibe-coded-apps-for-a-living-the-same-supabase-rls-mistake-is-in-almost-every-one-244a</link>
      <guid>https://dev.to/mgundlach/i-review-vibe-coded-apps-for-a-living-the-same-supabase-rls-mistake-is-in-almost-every-one-244a</guid>
      <description>&lt;p&gt;I train non-developers to build real applications with tools like Lovable, and part of my job is reviewing what they've built before anyone deploys it near actual user data. After a year of these reviews, I can tell you the security story of the vibe coding era in one sentence:&lt;/p&gt;

&lt;p&gt;The frontend looks done, so everyone assumes the backend is.&lt;/p&gt;

&lt;p&gt;The numbers back up what I see in training rooms. Researchers who scanned Lovable-generated apps found critical row-level security flaws in roughly 10 percent of them. A broader scan of 1,400+ vibe-coded production apps found security issues in about two thirds, including several hundred exposed secrets. This isn't a corner-case problem. It's the default outcome when the tooling optimizes for visible progress and the security layer is invisible.&lt;/p&gt;

&lt;p&gt;So here's the deep dive I wish every &lt;a href="https://marcogundlach.de/" rel="noopener noreferrer"&gt;AI-app builder&lt;/a&gt; would read before connecting a database. It's Supabase-specific because that's what the popular tools scaffold, but the mindset transfers.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model most people are missing
&lt;/h2&gt;

&lt;p&gt;Supabase isn't a backend hiding behind your API routes. It exposes your Postgres database directly to the browser via PostgREST. Your frontend talks to the database. That &lt;code&gt;anon&lt;/code&gt; key sitting in your client bundle? It's public by design. Anyone can open DevTools, copy it, and fire requests at your database from &lt;code&gt;curl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Read that again if you're new here, because everything else follows from it: &lt;strong&gt;there is no server between your users and your tables.&lt;/strong&gt; Row Level Security is not an optional hardening step. It's the only wall that exists.&lt;/p&gt;

&lt;p&gt;This is also why the AI tools get it wrong so often. The generated frontend code politely filters data:&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="c1"&gt;// This is a UI feature, not security&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;data&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;invoices&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;user_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;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks correct. Works in the demo. And any user can drop the &lt;code&gt;.eq()&lt;/code&gt; filter in their own request and read every invoice in the table, unless the database itself refuses. The filter runs in the attacker's browser. The attacker is under no obligation to keep it.&lt;/p&gt;

&lt;h2&gt;
  
  
  First: find out how exposed you are right now
&lt;/h2&gt;

&lt;p&gt;Run this in the Supabase SQL editor. It lists every table in your public schema with RLS switched off:&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;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&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="k"&gt;and&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="n"&gt;rowsecurity&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every row this returns is a table that anyone with your (public) anon key can potentially read and write in full. In the reviews I do, this query almost never comes back empty on the first run. Common reason: the AI created tables through the SQL editor or a migration, and while Supabase enables RLS on tables created through its UI, generated migrations sometimes skip it. Nobody notices, because the app works. Of course it works. Everything is allowed.&lt;/p&gt;

&lt;p&gt;While you're there, check what policies actually exist:&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;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&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="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The five failure patterns I keep finding
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. RLS enabled, policy says yes to everyone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;AI assistants, when asked to "fix" access errors, love generating 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;"Enable read access for all users"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&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;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&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;using (true)&lt;/code&gt; means every row, every requester. Sometimes that's genuinely right (a public blog posts table). On an invoices table it's a data breach with a checkmark next to it. Grep your policies for &lt;code&gt;true&lt;/code&gt; and interrogate each one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. SELECT is locked down, writes are wide open.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Policies are per-command. A policy &lt;code&gt;for select&lt;/code&gt; does nothing for inserts, updates or deletes. I've reviewed apps where reading was properly restricted and any authenticated user could &lt;code&gt;update&lt;/code&gt; any other user's rows, because nobody wrote the other three policies. If this query shows only &lt;code&gt;SELECT&lt;/code&gt; in the &lt;code&gt;cmd&lt;/code&gt; column for a table users write to, you have a problem:&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;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;count&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;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&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="k"&gt;group&lt;/span&gt; &lt;span class="k"&gt;by&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;cmd&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Missing WITH CHECK.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;using&lt;/code&gt; governs which existing rows you can see or touch. &lt;code&gt;with check&lt;/code&gt; governs what a row is allowed to look like after an insert or update. Skip it, and a user can insert rows with someone else's &lt;code&gt;user_id&lt;/code&gt;, or update their own row and reassign it. The complete set for a standard user-owned table 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;alter&lt;/span&gt; &lt;span class="k"&gt;table&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="n"&gt;enable&lt;/span&gt; &lt;span class="k"&gt;row&lt;/span&gt; &lt;span class="k"&gt;level&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"read own"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&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;using&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;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"insert own"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&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;with&lt;/span&gt; &lt;span class="k"&gt;check&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;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"update own"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&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;using&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;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="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;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"delete own"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;delete&lt;/span&gt; &lt;span class="k"&gt;using&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;Side note on the &lt;code&gt;(select auth.uid())&lt;/code&gt; wrapping instead of bare &lt;code&gt;auth.uid()&lt;/code&gt;: Postgres can then evaluate it once per query instead of once per row. On large tables this is the difference between fine and mysteriously slow.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Views that quietly bypass everything.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This one bites even experienced developers. Views in Postgres execute with the permissions of their creator by default, not the caller. Your table has beautiful RLS, then the AI generates a convenient &lt;code&gt;invoice_summary&lt;/code&gt; view, and that view serves everyone's data to anyone. On Postgres 15+, fix it 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;alter&lt;/span&gt; &lt;span class="k"&gt;view&lt;/span&gt; &lt;span class="n"&gt;invoice_summary&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;security_invoker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Audit every view the tool created for you. There are usually more than you remember approving.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. The service role key in the wrong place.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;service_role&lt;/code&gt; key bypasses RLS entirely. That's its purpose, for trusted server-side jobs. I've found it in client-side code, in committed &lt;code&gt;.env&lt;/code&gt; files pushed to public repos, and once, memorably, pasted into a prompt that ended up in a shared chat log. If that key leaks, your policies are decoration. Rotate it if you're even slightly unsure where it's been.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test like an attacker, not like a user
&lt;/h2&gt;

&lt;p&gt;The single highest-value habit: test your API with the anon key from outside your app. No UI, no client library, just raw requests:&lt;br&gt;
&lt;/p&gt;

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

&lt;/div&gt;



&lt;p&gt;Then again with a logged-in user's JWT of a &lt;em&gt;different&lt;/em&gt; account, trying to read and write rows that aren't theirs. Empty results and permission errors are what success looks like. In my trainings this exercise takes 20 minutes and produces more genuine understanding than two hours of me talking, because watching your own app hand over someone else's data via curl is an experience nobody forgets.&lt;/p&gt;

&lt;p&gt;One warning for the AI-assisted crowd: when your assistant hits an RLS permission error during development, it will frequently propose "solving" it by loosening the policy or disabling RLS. That suggestion reliably makes the error go away. So would removing the front door of your house. Treat every AI-suggested policy change as hostile until you've read it yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable summary
&lt;/h2&gt;

&lt;p&gt;None of this is advanced. Four policies per table, careful views, keys in the right place, and a curl test. A competent reviewer covers a typical vibe-coded app in an hour or two.&lt;/p&gt;

&lt;p&gt;That's exactly why the current statistics annoy me. We're not facing some unsolvable frontier problem. We're facing a tooling generation that ships the visible 90 percent brilliantly and leaves the invisible 10 percent to people who've never heard the phrase "row level security." The tools will get better at this, some are already adding checks. Until then, the fix is an afternoon of SQL and a healthy distrust of anything that works on the first try.&lt;/p&gt;

&lt;p&gt;If you've found other RLS footguns in generated apps, drop them in the comments. I'm collecting them for my trainings, and the weirdest one so far involved a policy checking &lt;code&gt;user_id = user_id&lt;/code&gt;. Which is, if you think about it, always true. The AI was very confident about it.&lt;/p&gt;

</description>
      <category>backend</category>
      <category>database</category>
      <category>developers</category>
      <category>security</category>
    </item>
    <item>
      <title>Six Months of Local LLMs on a DGX Spark: The Benchmarks Are Lying to You</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Fri, 07 Aug 2026 17:49:41 +0000</pubDate>
      <link>https://dev.to/mgundlach/six-months-of-local-llms-on-a-dgx-spark-the-benchmarks-are-lying-to-you-gfp</link>
      <guid>https://dev.to/mgundlach/six-months-of-local-llms-on-a-dgx-spark-the-benchmarks-are-lying-to-you-gfp</guid>
      <description>&lt;p&gt;Last winter I put NVIDIA's DGX Spark on my desk. GB10 Grace Blackwell chip, 128 GB of unified memory, about 4,700 euros, roughly the size of two stacked paperbacks. I run an automation consultancy, my clients are German and allergic to sending documents into US clouds, so "the model runs in my office" is a feature I can literally sell.&lt;/p&gt;

&lt;p&gt;This is the write-up I couldn't find before buying: what this box is actually good at, why the spec sheet misled me for an entire evening, and the config that turned it from a disappointment into the machine that now handles most of my production workloads.&lt;/p&gt;

&lt;p&gt;The number that matters isn't on the marketing page&lt;/p&gt;

&lt;p&gt;Here's the trap. The headline specs scream capacity: 128 GB unified memory, a petaflop of FP4 compute. Capacity determines what fits. It says nothing about what's usable.&lt;/p&gt;

&lt;p&gt;Token generation is memory-bandwidth-bound. For every single token, the engine streams essentially all active model weights through the chip. So your ceiling is simple division:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;text
max tokens/sec ≈ memory bandwidth / bytes of active weights

DGX Spark bandwidth: ~273 GB/s

Dense 70B @ Q4 (~42 GB):   273 / 42  ≈ 6.5 tok/s ceiling
Dense 70B @ FP8 (~70 GB):  273 / 70  ≈ 3.9 tok/s ceiling
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Real-world numbers land below the ceiling. NVIDIA's own Ollama benchmark shows about 2.7 tok/s for a 70B dense model at FP8 on this box. My first evening, I loaded exactly such a model, typed a prompt, and watched tokens drip out like a leaky faucet. For comparison, an M3 Ultra with its 819 GB/s does the same model at 25-30 tok/s. The Spark has triple the Mac's practical model capacity and a third of its bandwidth. Nobody puts that sentence in a product video.&lt;/p&gt;

&lt;p&gt;So is the box bad? No. I was holding it wrong, in two specific ways.&lt;/p&gt;

&lt;p&gt;Fix one: stop running dense models&lt;/p&gt;

&lt;p&gt;Mixture-of-experts models only activate a fraction of their parameters per token. The bandwidth equation only counts active bytes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;text
MoE, ~110B total / ~12B active @ Q4 (~7 GB active):
273 / 7 ≈ 39 tok/s ceiling
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Suddenly the box makes sense. The 128 GB holds the full expert set, which smaller machines simply cannot fit, while the per-token traffic stays small. Big MoE models are precisely the workload this hardware was shaped for, and the current open-weight generation (the larger Qwen MoE variants, gpt-oss-120b, and friends) delivers 30-80 tok/s here depending on model and engine. That's faster than anyone reads.&lt;/p&gt;

&lt;p&gt;The practical rule I now give everyone evaluating this class of hardware: never trust a tokens-per-second number without three qualifiers. Which model architecture (dense or MoE), which quantization, which inference engine. The same box produces a 2.7 and a 60 depending on those choices, and both numbers are honest.&lt;/p&gt;

&lt;p&gt;Fix two: don't use the default stack for the big stuff&lt;/p&gt;

&lt;p&gt;The convenience tools (Ollama out of the box) are wonderful for getting started and noticeably behind on this specific ARM+Blackwell platform. The optimized path, TensorRT-LLM or a recent llama.cpp built with CUDA support, plus NVFP4 quantized weights where available, is worth real percentages. NVIDIA has been shipping steady software updates for the platform since launch and the gains have been meaningful, which is a polite way of saying the launch-day software left performance on the table.&lt;/p&gt;

&lt;p&gt;My setup settled into three layers.&lt;/p&gt;

&lt;p&gt;Layer 1: llama.cpp servers, one per model. Compiled from source on the box (it runs DGX OS, an Ubuntu derivative, so this is unexciting). Each model gets a llama-server process exposing an OpenAI-compatible endpoint.&lt;/p&gt;

&lt;p&gt;Layer 2: llama-swap as the traffic cop. I don't want five models resident at once, and I don't want to SSH in to switch them. llama-swap is a small proxy that lazily starts and stops model servers based on the model field of incoming requests:&lt;/p&gt;

&lt;p&gt;`yaml&lt;/p&gt;

&lt;h1&gt;
  
  
  config.yaml (abridged)
&lt;/h1&gt;

&lt;p&gt;models:&lt;br&gt;
  "qwen-big":&lt;br&gt;
    cmd: &amp;gt;&lt;br&gt;
      /opt/llama.cpp/llama-server&lt;br&gt;
      -m /models/qwen3-moe-q4.gguf&lt;br&gt;
      --port ${PORT} -c 32768 -ngl 999&lt;br&gt;
    ttl: 900   # unload after 15 min idle&lt;/p&gt;

&lt;p&gt;"gemma-fast":&lt;br&gt;
    cmd: &amp;gt;&lt;br&gt;
      /opt/llama.cpp/llama-server&lt;br&gt;
      -m /models/gemma3-27b-q4.gguf&lt;br&gt;
      --port ${PORT} -c 16384 -ngl 999&lt;br&gt;
    ttl: 900&lt;/p&gt;

&lt;p&gt;Request model: "qwen-big" and the proxy spins it up, drains the old one, and routes. From the outside it behaves like one endpoint hosting many models, exactly like a cloud provider.&lt;/p&gt;

&lt;p&gt;Layer 3: a LiteLLM proxy in front of everything. This is the piece I'd defend in a knife fight. All my n8n workflows and scripts speak to one API. Behind it, LiteLLM routes by model name: local names go to the Spark, frontier names go to cloud APIs. Which means any workflow can be moved between local and cloud by changing one string, no code changes:&lt;br&gt;
`&lt;br&gt;
yaml&lt;br&gt;
model_list:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;model_name: local-workhorse
litellm_params:
  model: openai/qwen-big
  api_base: &lt;a href="http://spark.local:8080/v1" rel="noopener noreferrer"&gt;http://spark.local:8080/v1&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;model_name: heavy-thinking
litellm_params:
  model: anthropic/claude-sonnet-4-6``&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The migration path this enables is the actual killer feature. I prototype a workflow against a cloud model, and once the prompt is stable I point it at local-workhorse and see if quality holds. It does more often than I expected, roughly for everything that is extraction, classification, summarization or transformation. It doesn't for genuinely hard reasoning, and pretending otherwise would cost me clients.&lt;/p&gt;

&lt;p&gt;What actually runs on it in production&lt;/p&gt;

&lt;p&gt;Concretely, after six months of settling:&lt;/p&gt;

&lt;p&gt;A mid-size dense model (27B class) stays hot for latency-sensitive tasks: classification in my email pipelines, extraction from documents, anonymization before anything leaves the building. Effectively instant for single users.&lt;/p&gt;

&lt;p&gt;A large MoE model gets swapped in for quality-sensitive batch work: report generation, first-draft briefings, transcript summarization. Comfortable reading speed, zero marginal cost, and I've stopped hesitating before running experiments across thousands of documents, because the meter isn't running.&lt;/p&gt;

&lt;p&gt;An embedding model runs permanently for search across my own document archive. This workload is so light it's almost free, and it's the one where "the data never leaves the room" matters most to me.&lt;/p&gt;

&lt;p&gt;Monthly electricity for all this lands somewhere around a tank of fuel, and my cloud API bill dropped visibly. Full honesty though: at solo-consultant volume, the box amortizes over years, not months. I bought data sovereignty and free experimentation, and the cost savings are a side dish.&lt;/p&gt;

&lt;p&gt;Should you buy one?&lt;/p&gt;

&lt;p&gt;Buy it if: you need big-model capacity in a small quiet box, your workloads are MoE-shaped or batch-shaped, CUDA compatibility matters to you, or "local" is a compliance requirement you can invoice against.&lt;/p&gt;

&lt;p&gt;Skip it if: you mainly want fast dense 70B chat (a Mac Studio with fat bandwidth beats it embarrassingly), you've never touched Linux (it's Linux, fully, forever), or your real goal is learning, in which case an 8B model on your current laptop teaches you the same lessons for free.&lt;/p&gt;

&lt;p&gt;And whatever you buy: do the bandwidth division before the purchase, not after, on the first evening, with a sinking feeling, like a certain consultant I could name.&lt;/p&gt;

&lt;p&gt;Comments are open, and I'm specifically curious what tokens/sec others are getting on this platform with recent llama.cpp builds versus TensorRT-LLM. My numbers keep improving with every software update and I've half stopped trusting my own benchmarks.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>programming</category>
      <category>marcogundlach</category>
    </item>
    <item>
      <title>Your Business Automation Probably Doesn't Need an Agent Framework</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Fri, 07 Aug 2026 17:09:16 +0000</pubDate>
      <link>https://dev.to/mgundlach/your-business-automation-probably-doesnt-need-an-agent-framework-4bi2</link>
      <guid>https://dev.to/mgundlach/your-business-automation-probably-doesnt-need-an-agent-framework-4bi2</guid>
      <description>&lt;p&gt;I build AI automations for mid-sized companies for a living. Invoice routing, support triage, document extraction, lead qualification, that kind of thing. Unsexy stuff that saves real hours.&lt;/p&gt;

&lt;p&gt;In the last twelve months, almost every technical call I've had started with the same question: "Should we use LangGraph or CrewAI for this?"&lt;/p&gt;

&lt;p&gt;My answer is usually no. And I want to explain why, with actual code, because "agents are overhyped" hot takes without code are worthless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What clients ask for vs. what they need&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A typical request sounds like this: "We get 200 supplier emails a day. Someone reads each one, figures out if it's an invoice, a complaint, or a delivery note, and forwards it to the right department. Can an AI agent do this?"&lt;/p&gt;

&lt;p&gt;Notice what's happening here. The word "agent" has done so much marketing work that people now use it for anything involving an LLM. But look at the actual task:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Receive email&lt;/li&gt;
&lt;li&gt;Classify it&lt;/li&gt;
&lt;li&gt;Extract a few fields&lt;/li&gt;
&lt;li&gt;Route it&lt;/li&gt;
&lt;li&gt;Log everything&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;There is no planning. No dynamic tool selection. No multi-step reasoning where step 4 depends on what the model discovered in step 2. It's a pipeline. A boring, deterministic pipeline with exactly one non-deterministic component in the middle.&lt;/p&gt;

&lt;p&gt;Giving this task to an autonomous agent loop is like hiring a consultant to staple documents. It will get done, eventually, and you'll pay for every token of "thinking" along the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The pattern I ship instead&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Here's the architecture I've deployed at maybe a dozen companies now. n8n as the orchestrator (self-hosted, because German clients and data residency), a single LLM call for the classification, Postgres for state, and strict validation between the LLM and anything that touches production.&lt;/p&gt;

&lt;p&gt;The core of it is one API call with a forced JSON structure:&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="nx"&gt;javascript&lt;/span&gt;
&lt;span class="c1"&gt;// n8n Code node, or plain Node.js, doesn't matter&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.anthropic.com/v1/messages&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;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;x-api-key&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;process&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="nx"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;anthropic-version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;2023-06-01&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
  &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;max_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;system&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`You classify supplier emails for a logistics company.
Respond with JSON only, no markdown, matching exactly:
{
  "category": "invoice" | "complaint" | "delivery_note" | "other",
  "confidence": number between 0 and 1,
  "supplier_name": string or null,
  "reference_number": string or null,
  "summary": string, max 200 chars
}`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;messages&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="na"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;user&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;emailBody&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing clever. The interesting part is what happens after the call, because this is where most tutorials stop and most production incidents start.&lt;/p&gt;

&lt;p&gt;Validate like the model is a hostile junior dev&lt;/p&gt;

&lt;p&gt;The LLM output goes through a schema check before it touches anything:&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="nx"&gt;javascript&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;z&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="s2"&gt;zod&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;EmailClassification&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;object&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;category&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;enum&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;invoice&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;complaint&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;delivery_note&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;other&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
  &lt;span class="na"&gt;confidence&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&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="na"&gt;supplier_name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;reference_number&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;nullable&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="na"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parseClassification&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;// Models still occasionally wrap JSON in code fences,&lt;/span&gt;
  &lt;span class="c1"&gt;// even when you tell them not to. Strip defensively.&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cleaned&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/``&lt;/span&gt;&lt;span class="err"&gt;`
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;endraw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nx"&gt;json&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="s2"&gt;```/g, "").trim();
  const parsed = EmailClassification.safeParse(JSON.parse(cleaned));

  if (!parsed.success) {
    throw new ValidationError(parsed.error);
  }
  return parsed.data;
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And then the rule that has saved me more than any prompt engineering trick: a confidence threshold with a human fallback.&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="nx"&gt;javascript&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;confidence&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mf"&gt;0.85&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="nf"&gt;routeToHumanQueue&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;result&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="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;routeToDepartment&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;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;category&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In one deployment, about 7 percent of emails land in the human queue. The client was initially disappointed by that number. Three months later they told me it's their favorite feature, because the 93 percent that flow through automatically have been correct essentially every time, and the weird edge cases (a complaint written inside a forwarded invoice, in Turkish) get human eyes instead of silent misrouting.&lt;/p&gt;

&lt;p&gt;An autonomous agent would have handled that Turkish invoice-complaint hybrid too. Confidently. Wrongly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The two boring things that matter more than your framework&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Idempotency. Email webhooks fire twice. n8n retries on timeouts. Your workflow will process the same message multiple times unless you make it impossible:&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;sql&lt;/span&gt;
&lt;span class="k"&gt;INSERT&lt;/span&gt; &lt;span class="k"&gt;INTO&lt;/span&gt; &lt;span class="n"&gt;processed_emails&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;classification&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processed_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;DO&lt;/span&gt; &lt;span class="k"&gt;NOTHING&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Check the row count. Zero means you already handled it, exit early. I've seen a duplicate webhook forward the same invoice to accounting twice, and accounting paid it twice. That bug cost more than the entire automation project.&lt;/p&gt;

&lt;p&gt;Tracing. I log every LLM call to Langfuse: input, output, latency, token cost, and the final routing decision. Not for compliance theater. Because when the client calls and says "the system misrouted something on Tuesday," I need to see exactly what the model saw and said, in about 30 seconds. Without traces, every complaint becomes archaeology.&lt;/p&gt;

&lt;p&gt;Neither of these problems is solved by an agent framework. Both will hurt you regardless of which one you pick.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;So when do I actually reach for agents?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To be fair: they exist for a reason. I use agentic loops when the task genuinely needs runtime decisions about what to do next. Research tasks with unknown depth. Debugging workflows where the next step depends on what the last command returned. Coding assistants, obviously.&lt;/p&gt;

&lt;p&gt;My rule of thumb after two years of client work: if you can draw the workflow as a flowchart before writing any code, build the flowchart. Use the LLM as a smart function inside it. If you can't draw the flowchart because the path depends on what the model finds along the way, that's agent territory.&lt;/p&gt;

&lt;p&gt;Most business processes fall in the first bucket. Companies have spent decades standardizing them, that's the whole point of a process. The current funding wave (agent startups pulled in something like 1.8 billion dollars in July alone) is betting heavily on the second bucket. Fine. But don't let the investment thesis of venture capital decide your architecture.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this looks like in production numbers&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One concrete deployment, running since spring: supplier email triage for a company with around 140 employees.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;~200 emails/day, ~4,100/month processed&lt;/li&gt;
&lt;li&gt;93 percent fully automated, 7 percent to human review&lt;/li&gt;
&lt;li&gt;LLM cost: under 40 euros a month&lt;/li&gt;
&lt;li&gt;Infrastructure: one n8n instance and a Postgres database that were already there&lt;/li&gt;
&lt;li&gt;Time to production: 6 days, including two days of the client - arguing about what counts as a "complaint"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those two days of arguing were the real project, by the way. The code was the easy part. It always is.&lt;/p&gt;

&lt;p&gt;If you're building something similar and got stuck somewhere between the webhook and the validation layer, drop a comment. I read them all, and edge cases from other people's inboxes are my favorite genre of horror story.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>automation</category>
      <category>llm</category>
    </item>
    <item>
      <title>Your vibe-coded app already has a data leak (and the code compiles fine)</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Tue, 30 Jun 2026 15:51:58 +0000</pubDate>
      <link>https://dev.to/mgundlach/your-vibe-coded-app-already-has-a-data-leak-and-the-code-compiles-fine-1d78</link>
      <guid>https://dev.to/mgundlach/your-vibe-coded-app-already-has-a-data-leak-and-the-code-compiles-fine-1d78</guid>
      <description>&lt;p&gt;The first version always works. That is the trap.&lt;br&gt;
You describe a small internal tool, wait two minutes, and there it is, running in your browser with your real data in it. It feels like a magic trick. And because it works, you ship it. Why wouldn't you. It works.&lt;br&gt;
Here is the problem. "It works" and "it is safe" are two completely different claims, and vibe coding is very good at the first one while staying silent about the second.&lt;br&gt;
I build small and mid sized apps mostly through natural language, usually Next.js with Supabase behind it. This is the single most common security hole I see, in my own early drafts and in the projects people bring to my trainings. It shows up in almost every Supabase app that was built fast. It never shows up in the demo. It always shows up the moment real users arrive.&lt;br&gt;
Let me show you exactly what it looks like.&lt;br&gt;
What you asked for&lt;br&gt;
You are building a tiny internal dashboard. You prompt something reasonable:&lt;/p&gt;

&lt;p&gt;Add a page that lists our customers from Supabase. Table called customers with name, email, and mrr. Show them in a sortable table.&lt;/p&gt;

&lt;p&gt;The model happily gives you a clean client component.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;use client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;useEffect&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;useState&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;react&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createClient&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;@supabase/supabase-js&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;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;createClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;process&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="nx"&gt;NEXT_PUBLIC_SUPABASE_URL&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;process&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="nx"&gt;NEXT_PUBLIC_SUPABASE_ANON_KEY&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Customers&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="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;setRows&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;useState&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="kr"&gt;any&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="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="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;customers&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;then&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="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;setRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&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="c1"&gt;// ...render the table&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`

And earlier, when you set up the database, it handed you this SQL to paste into the Supabase SQL editor:
`&lt;/span&gt;&lt;span class="nx"&gt;create&lt;/span&gt; &lt;span class="nx"&gt;table&lt;/span&gt; &lt;span class="nf"&gt;customers &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="nx"&gt;uuid&lt;/span&gt; &lt;span class="nx"&gt;primary&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;gen_random_uuid&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;mrr&lt;/span&gt; &lt;span class="nx"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;created_at&lt;/span&gt; &lt;span class="nx"&gt;timestamptz&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="nf"&gt;now&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;You ran it. The page loads. The customers show up. Everything is green.&lt;br&gt;
Nothing here is broken in the "the code compiles" sense. The model did its job. It made one assumption that happens to be false for your setup, and it never said a word about it.&lt;br&gt;
Why it looks completely fine&lt;br&gt;
In the demo you are the only user. You are logged into your own machine, looking at your own test data. There is no second person on the other side of the app trying to do something you did not intend. So the dangerous behaviour simply never gets a chance to appear.&lt;br&gt;
This is the thing about security holes built this way. They are not loud. The app does not crash. There is no red error. The code does exactly what you asked, and what you asked happened to be unsafe.&lt;/p&gt;
&lt;h2&gt;
  
  
  The hole
&lt;/h2&gt;

&lt;p&gt;Two facts that the generated code never put next to each other.&lt;br&gt;
First, that NEXT_PUBLIC_SUPABASE_ANON_KEY is public on purpose. Next.js inlines any NEXT_PUBLIC_ variable straight into the JavaScript bundle that ships to the browser. So does the project URL. Anyone who opens your site can read both out of the network tab in about five seconds. That is by design. The anon key is not a secret.&lt;br&gt;
Second, the only thing that makes a public anon key safe in Supabase is Row Level Security. RLS is what decides which rows the anon key is actually allowed to touch. And on a table you created with raw create table SQL, RLS is off. No policies. No gate. The table is fully exposed through Supabase's auto-generated REST API.&lt;br&gt;
Put those two facts together and you get this. Your customer table, with names, emails, and revenue per account, is readable by anyone on the internet who can copy two values out of your own front end.&lt;br&gt;
They do not even need your app. Here is the entire attack.&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="n"&gt;curl&lt;/span&gt; &lt;span class="s1"&gt;'https://YOURPROJECT.supabase.co/rest/v1/customers?select=*'&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="nv"&gt;"apikey: THE_PUBLIC_ANON_KEY_FROM_YOUR_BUNDLE"&lt;/span&gt; &lt;span class="err"&gt;\&lt;/span&gt;
  &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt; &lt;span class="nv"&gt;"Authorization: Bearer THE_PUBLIC_ANON_KEY_FROM_YOUR_BUNDLE"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns your whole table as JSON. Your UI, your sorting, your nice components, none of it matters. The REST endpoint sits underneath all of that and it answers to anyone holding the public key. Which is everyone.&lt;br&gt;
And it is not just reads. With no policies, depending on your grants, the same key can often insert, update, and delete. So now it is not only a leak. It is a write surface.&lt;br&gt;
To be fair to Supabase, the dashboard does warn you. If you create a table through the Table Editor UI, RLS gets switched on by default and an "Unrestricted" badge yells at you when it is off. The security advisor flags it too. But here is the catch. When you vibe code, you do not click through the UI. You paste the SQL the model gave you straight into the SQL editor. That path skips the guardrails the dashboard built for exactly this moment.&lt;br&gt;
The tool tried to protect you. The workflow routed around the protection.&lt;/p&gt;
&lt;h2&gt;
  
  
  Why this keeps happening with AI specifically
&lt;/h2&gt;

&lt;p&gt;Models optimize for code that runs. RLS is invisible to "does it run". An app with wide open tables runs perfectly. It demos perfectly. The feedback loop the model is trained on, generate, see it work, move on, has no step in it where the missing policy ever surfaces.&lt;br&gt;
So unless you put security into the spec yourself, the model will quietly choose the version that works today and leaks tomorrow. Not out of malice. It just has no reason to pick otherwise, and "it works" is the only signal in the room.&lt;br&gt;
This is the general shape of the whole problem, by the way. The model fills every gap you leave with a plausible default. Most defaults are fine. Some of them ship your customer list to the public internet.&lt;/p&gt;
&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;Three layers, from "do this right now" to "do this from now on".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Turn RLS on for every table. No exceptions.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;alter table customers &lt;span class="nb"&gt;enable &lt;/span&gt;row level security&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that and reload your app. It will break. The table goes silent and your dashboard shows nothing. That is correct. That is RLS doing its job: deny by default. An empty table in your UI right now is infinitely better than a full table in someone's curl later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Add explicit policies that say who can see what.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Deny-by-default is only useful once you grant the narrow thing you actually want. Assuming each customer row belongs to a user, give the table an owner_id and scope reads to the logged-in user.&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;customers&lt;/span&gt; &lt;span class="k"&gt;add&lt;/span&gt; &lt;span class="k"&gt;column&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
  &lt;span class="k"&gt;references&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;users&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;default&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="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Read own customers"&lt;/span&gt;
&lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;customers&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;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now an anon request gets nothing, an authenticated user gets only their own rows, and the same curl from earlier returns an empty array. Test it yourself with the public key and confirm you get []. If you get rows, you are not done.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Keep sensitive work on the server, and keep the service role server-only.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The model defaulted to a client component, which is fine for plenty of internal tools once RLS is solid. But for anything sensitive, move the data access into a server component or a route handler so the query never runs in the browser at all. And if you use the service_role key, which bypasses RLS entirely, it lives in server-only environment variables and never, ever gets a NEXT_PUBLIC_ prefix. One slip there and you have handed out a master key.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tsx"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/customers/page.tsx  (server component, runs on the server only)&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;createServerClient&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;@/lib/supabase/server&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;Customers&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;supabase&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;createServerClient&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="na"&gt;data&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;rows&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;customers&lt;/span&gt;&lt;span class="dl"&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="c1"&gt;// RLS still applies, and nothing leaks into the client bundle&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Make the model do this for you&lt;/strong&gt;&lt;br&gt;
You should not have to remember this every time. Put the rule into the prompt once and let it carry.&lt;/p&gt;

&lt;p&gt;Any time you create a Supabase table, enable Row Level Security in the same step and write explicit policies scoped to the current user. Never expose a table through the anon key without RLS. Never put the service_role key in a NEXT_PUBLIC variable. If a table needs to be readable without auth, call it out explicitly and ask me first.&lt;/p&gt;

&lt;p&gt;That last sentence is the important one. It turns a silent default into a decision you get to make on purpose.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The actual lesson&lt;/strong&gt;&lt;br&gt;
The dangerous sentence in vibe coding is not "I don't know how this works". It is "well, it runs".&lt;br&gt;
Running tells you the syntax is valid. It tells you nothing about whether the thing is safe, or whether it does what you actually meant under conditions you did not test. Generated code is a draft. You own it the moment you ship it, which means you read it, and when you hit something you do not understand, you ask the model to explain it before you accept it. Those five minutes on the RLS line are the cheapest insurance you will ever buy.&lt;br&gt;
Vibe coding did not make this mistake more common because the tools are bad. It made it more common because it removed the friction that used to slow you down long enough to notice. The speed is real. So is the bill, if you are not watching.&lt;br&gt;
Go check your tables. Right now. I will wait.&lt;br&gt;
If you found one, you are not alone, and I would genuinely like to hear how it slipped through. The comments are open&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Vibe coding works until you try to change something</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Sun, 14 Jun 2026 19:16:12 +0000</pubDate>
      <link>https://dev.to/mgundlach/vibe-coding-works-until-you-try-to-change-something-776</link>
      <guid>https://dev.to/mgundlach/vibe-coding-works-until-you-try-to-change-something-776</guid>
      <description>&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgxicjfd1y7xblkrpm7i7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fgxicjfd1y7xblkrpm7i7.png" alt=" " width="800" height="336"&gt;&lt;/a&gt;&lt;br&gt;
The first version almost always works. That is the part nobody is ready for. You describe what you want in one sentence, wait two minutes, and a small tool is running in your browser. It feels like a trick.&lt;br&gt;
The second moment feels different. You want to change one small thing. The model cheerfully rewrites half the file. Now the part that worked a minute ago is broken, and you are not sure why. This is the exact spot where useful vibe coding splits off from throwaway code.&lt;br&gt;
I have spent the last year building small and mid sized apps mostly through natural language, usually on Next.js with Supabase behind it. Here is the short version of what I learned. The bottleneck is no longer typing code. It is how precisely you describe the thing and how carefully you read what comes back. Get that right and you end up with something you can still maintain six months later. Ignore it and you produce, in record time, the exact kind of system nobody wants to touch.&lt;/p&gt;

&lt;h2&gt;
  
  
  A spec beats a vibe
&lt;/h2&gt;

&lt;p&gt;The phrase "vibe coding" is a little misleading. It suggests you just vibe and code appears. In practice the opposite is true. The clearer your first prompt, the fewer rounds you need after it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A weak start looks like this:&lt;/strong&gt;&lt;br&gt;
Build me a dashboard for our sales numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A good start hands the model the context it would otherwise have to guess:&lt;/strong&gt;&lt;br&gt;
Add a page at /dashboard in our existing Next.js App Router project. Data comes from a Supabase table called "sales" with columns date, region, amount. Show a bar chart of total amount per region, and below it a sortable table. Reuse the components in our existing ui folder. Do not add new dependencies without asking first.&lt;/p&gt;

&lt;p&gt;The difference is not about being polite to the machine. The difference is that the second version makes decisions instead of leaving them open. Every decision you skip, the model makes for you, and it makes a slightly different one on every run.&lt;/p&gt;

&lt;p&gt;That last line matters more than it looks. "&lt;em&gt;Do not add new dependencies without asking&lt;/em&gt;" stops the model from quietly writing three extra packages into your package.json because they seemed handy at the time. Guardrails like that, written once into the prompt, save you hours later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Small steps, or pain
&lt;/h2&gt;

&lt;p&gt;The most common mistake is handing the model too much at once and then letting a big change run across existing code. These models love to rewrite more than you asked. Point one at a 400 line file with the task "change the date format" and you get back a 400 line file where the date format is correct and three other things are subtly broken.&lt;br&gt;
What helps is dull and it works anyway. Keep the units small. One function, one component, one clearly scoped task per run. And commit after every working state. Git is not a nice extra here. It is your seatbelt. When the next run breaks something, you roll it back in two seconds instead of playing detective.&lt;br&gt;
My loop now is almost always the same. Working state, commit, change one thing, test, commit. It sounds like more overhead. It is faster in the end, because I never land in the situation where I have to untangle a pile of mixed changes that all arrived together.&lt;/p&gt;

&lt;h2&gt;
  
  
  You own the code, so read it
&lt;/h2&gt;

&lt;p&gt;The most dangerous sentence in vibe coding is "well, it runs." Whether something runs tells you nothing about whether it does the right thing or whether it is safe.&lt;br&gt;
Here is a real one. Ask a model to load data from Supabase and it will often write the query straight into a client component and never mention Row Level Security. In a demo with test data this never shows up. The moment real users and real data arrive, you have shipped a data leak without noticing. The model did not do anything wrong in the "the code compiles" sense. It made an assumption that happens to be false in your setup.&lt;br&gt;
So treat generated code as a draft, not a finished product. You do not need to be able to write every line yourself from memory. You do need to read it and follow what it does. When you hit a spot you do not understand, ask the model to explain it before you accept it. Those five minutes are the best investment in the whole process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it pays off and where it does not
&lt;/h2&gt;

&lt;p&gt;After enough projects a pattern shows up for which jobs vibe coding actually saves time.&lt;br&gt;
It shines on internal tools with a contained scope. Configurators. Reports. Prototypes. Small automations. Anything with a clear task and a low blast radius if it goes wrong. A two week project regularly turns into an afternoon here, and without a drop in quality if you follow the points above.&lt;br&gt;
It gets miserable once a system is large, tightly connected, and business critical. The more moving parts, the harder it is to keep control through language alone. That does not mean you drop AI entirely on those systems. It means you keep an experienced developer in the loop who can judge the suggestions instead of accepting them blind.&lt;br&gt;
Knowing that line honestly is not a weakness. It is the difference between someone who uses vibe coding productively and someone who just buys themselves a new set of problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The actual shift
&lt;/h2&gt;

&lt;p&gt;If I had to reduce all of this to one thing, it is this. Vibe coding does not make programming faster. It changes who can build software at all. Someone who knows their problem cold but never learned to code can now build a working solution, as long as they pick up the handful of habits I described here.&lt;br&gt;
Write a clear spec. Work in small steps. Read the code you accept. Know where your own limit is. None of that is a secret and none of it is magic. It is a craft with a new tool in it. And like any tool, the result comes down to the hand holding it, not the tool.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>supabase</category>
    </item>
    <item>
      <title>Vibe Coding, das man in sechs Monaten noch anfassen kann</title>
      <dc:creator>Marco Gundlach</dc:creator>
      <pubDate>Sat, 13 Jun 2026 20:42:58 +0000</pubDate>
      <link>https://dev.to/mgundlach/vibe-coding-das-man-in-sechs-monaten-noch-anfassen-kann-b1j</link>
      <guid>https://dev.to/mgundlach/vibe-coding-das-man-in-sechs-monaten-noch-anfassen-kann-b1j</guid>
      <description>&lt;p&gt;Die erste Version funktioniert fast immer. Das ist der Teil, der alle überrascht, wenn sie mit &lt;em&gt;Vibe Coding&lt;/em&gt; anfangen. Du beschreibst in einem Satz, was du brauchst, und nach zwei Minuten läuft ein kleines Tool im Browser. Dieser Moment fühlt sich wie Magie an.&lt;/p&gt;

&lt;p&gt;Der zweite Moment fühlt sich anders an. Du willst eine Kleinigkeit ändern, das Modell schreibt großzügig die halbe Datei um, und plötzlich funktioniert die Stelle nicht mehr, die vorher lief. Genau hier trennt sich brauchbares Vibe Coding von Wegwerf-Code.&lt;/p&gt;

&lt;p&gt;Ich baue seit über einem Jahr kleine bis mittlere Anwendungen größtenteils über natürliche Sprache, meistens auf Next.js mit Supabase im Hintergrund. Was ich in dieser Zeit gelernt habe, lässt sich in einem Satz zusammenfassen: Der Engpass ist nicht mehr das Tippen von Code. Der Engpass ist, wie präzise du beschreibst und wie gründlich du gegenliest. Wer das verinnerlicht, bekommt Ergebnisse, die ein halbes Jahr später noch wartbar sind. Wer es ignoriert, produziert in Rekordzeit genau die Art von System, die später niemand mehr anfassen will.&lt;br&gt;
Spezifikation schlägt Stimmung&lt;br&gt;
Der Begriff "Vibe Coding" ist irreführend, weil er nahelegt, dass man einfach drauflos vibt. In der Praxis ist das Gegenteil der Fall. Je klarer der erste Prompt, desto weniger Runden brauchst du danach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Spezifikation schlägt Stimmung
&lt;/h2&gt;

&lt;p&gt;Der Begriff "Vibe Coding" ist irreführend, weil er nahelegt, dass man einfach drauflos vibt. In der Praxis ist das Gegenteil der Fall. Je klarer der erste Prompt, desto weniger Runden brauchst du danach.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ein schlechter Start sieht so aus:&lt;/strong&gt;&lt;br&gt;
Bau mir ein Dashboard für unsere Verkaufszahlen.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ein guter Start liefert dem Modell Kontext, den es sonst raten müsste:&lt;/strong&gt;&lt;br&gt;
Bau eine Seite unter /dashboard in unserem bestehenden Next.js App Router Projekt. Die Daten kommen aus einer Supabase Tabelle "sales" mit den Spalten date, region, amount. Zeige eine Summe pro Region als Balkendiagramm und darunter eine sortierbare Tabelle. Nutze die Komponenten aus unserem bestehenden ui Ordner. Keine neuen Abhängigkeiten ohne Rückfrage.&lt;/p&gt;

&lt;p&gt;Der Unterschied ist nicht Höflichkeit gegenüber der Maschine. Der Unterschied ist, dass die zweite Variante eine Entscheidung trifft, statt sie dem Modell zu überlassen. Jede Entscheidung, die du nicht triffst, trifft das Modell für dich, und zwar bei jedem Lauf anders.&lt;br&gt;
Der letzte Satz in dem Beispiel ist wichtiger, als er aussieht. "Keine neuen Abhängigkeiten ohne Rückfrage" verhindert, dass dir das Modell drei zusätzliche Pakete in die package.json schreibt, weil es gerade praktisch schien. Solche Leitplanken im Prompt sparen dir später Stunden.&lt;/p&gt;

&lt;h2&gt;
  
  
  Kleine Schritte, sonst Schmerz
&lt;/h2&gt;

&lt;p&gt;Der häufigste Fehler ist, dem Modell zu viel auf einmal zu geben und dann eine große Änderung über bestehenden Code laufen zu lassen. Generative Modelle neigen dazu, mehr umzubauen als nötig. Wenn du sie auf eine 400 Zeilen lange Datei loslässt mit dem Auftrag "ändere das Datumsformat", bekommst du zurück eine 400 Zeilen lange Datei, in der das Datumsformat stimmt und drei andere Dinge subtil kaputt sind.&lt;/p&gt;

&lt;p&gt;Was hilft, ist banal und wirkt trotzdem: Halte die Einheiten klein. Eine Funktion, eine Komponente, ein klar umrissener Auftrag pro Lauf. Und committe nach jedem funktionierenden Stand. Git ist beim Vibe Coding kein Nice to have, sondern dein Sicherheitsnetz. Wenn der nächste Lauf etwas zerbricht, machst du ihn in zwei Sekunden rückgängig, statt zu raten, was sich geändert hat.&lt;/p&gt;

&lt;p&gt;Ich arbeite inzwischen fast immer so: funktionierender Stand, commit, eine Sache ändern, testen, commit. Das klingt nach mehr Aufwand, ist aber unterm Strich schneller, weil ich nie mehr in der Situation lande, einen Haufen vermischter Änderungen entwirren zu müssen.&lt;/p&gt;

&lt;h2&gt;
  
  
  Du besitzt den Code, also lies ihn
&lt;/h2&gt;

&lt;p&gt;Der gefährlichste Satz beim Vibe Coding lautet "läuft ja". Ob etwas läuft, sagt nichts darüber aus, ob es das Richtige tut oder ob es sicher ist.&lt;/p&gt;

&lt;p&gt;Ein konkretes Beispiel aus dem Alltag: Bittet man ein Modell, Daten aus Supabase zu laden, schreibt es oft den Zugriff direkt in eine Client Komponente, ohne Row Level Security zu erwähnen. Im Demo Betrieb mit Testdaten fällt das nie auf. Sobald echte Nutzer und echte Daten dazukommen, hast du ein Datenleck gebaut, ohne es zu merken. Das Modell hat nichts falsch gemacht im Sinne von "der Code kompiliert nicht". Es hat nur eine Annahme getroffen, die in deinem Kontext nicht stimmt.&lt;/p&gt;

&lt;p&gt;Deshalb gilt: Generierter Code ist ein Entwurf, kein Endprodukt. Du musst ihn nicht Zeile für Zeile selbst hätten schreiben können, aber du musst ihn lesen und verstehen können. Wenn du an einer Stelle nicht weißt, was passiert, frag das Modell, es soll es erklären, bevor du es übernimmst. Diese fünf Minuten sind die beste Investition im ganzen Prozess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wo es sich lohnt und wo nicht
&lt;/h2&gt;

&lt;p&gt;Nach genug Projekten zeichnet sich ein klares Muster ab, für welche Aufgaben Vibe Coding wirklich Zeit spart.&lt;/p&gt;

&lt;p&gt;Es glänzt bei internen Werkzeugen mit überschaubarem Umfang: Konfiguratoren, Auswertungen, Prototypen, kleine Automatisierungen, Dinge mit klarer Aufgabe und begrenztem Schadensrisiko. Aus einem Projekt von zwei Wochen wird hier regelmäßig ein Nachmittag, und das ohne Qualitätsverlust, wenn man die Punkte oben beachtet.&lt;/p&gt;

&lt;p&gt;Es wird mühsam, sobald ein System groß, stark vernetzt und geschäftskritisch ist. Je mehr bewegliche Teile, desto schwerer ist es, allein über Sprache die Kontrolle zu behalten. Das heißt nicht, dass man dort gar keine KI nutzt. Es heißt, dass man dort einen erfahrenen Entwickler im Loop braucht, der die generierten Vorschläge einordnet, statt sie blind zu übernehmen.&lt;/p&gt;

&lt;p&gt;Diese Grenze ehrlich zu kennen, ist kein Eingeständnis von Schwäche. Es ist der Unterschied zwischen jemandem, der Vibe Coding produktiv einsetzt, und jemandem, der sich damit nur neue Probleme einkauft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Der eigentliche Hebel
&lt;/h2&gt;

&lt;p&gt;Wenn ich das Ganze auf eine Sache reduzieren müsste, dann diese: Vibe Coding macht nicht das Programmieren schneller, es verschiebt, wer überhaupt Software bauen kann. Eine Fachkraft, die ihr Problem genau kennt, aber nie programmieren gelernt hat, kann heute eine brauchbare Lösung bauen, vorausgesetzt, sie lernt die paar Disziplinen, die ich hier beschrieben habe.&lt;/p&gt;

&lt;p&gt;Klar spezifizieren. In kleinen Schritten arbeiten. Den Code lesen, den man übernimmt. Wissen, wo die eigene Grenze liegt. Das ist kein großes Geheimnis, und es ist auch keine Magie. Es ist Handwerk, nur mit einem neuen Werkzeug. Und wie bei jedem Werkzeug entscheidet nicht das Werkzeug über das Ergebnis, sondern die Hand, die es führt.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>supabase</category>
    </item>
  </channel>
</rss>
