<?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: ShipSafeScan</title>
    <description>The latest articles on DEV Community by ShipSafeScan (@shipsafescan).</description>
    <link>https://dev.to/shipsafescan</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%2F4096838%2F62ef35b1-88e3-42d8-8baf-95248175cd6a.png</url>
      <title>DEV Community: ShipSafeScan</title>
      <link>https://dev.to/shipsafescan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/shipsafescan"/>
    <language>en</language>
    <item>
      <title>Your RLS is on and the table is still readable</title>
      <dc:creator>ShipSafeScan</dc:creator>
      <pubDate>Fri, 04 Sep 2026 08:43:22 +0000</pubDate>
      <link>https://dev.to/shipsafescan/your-rls-is-on-and-the-table-is-still-readable-1a7</link>
      <guid>https://dev.to/shipsafescan/your-rls-is-on-and-the-table-is-still-readable-1a7</guid>
      <description>&lt;p&gt;A while back I wrote up the pass I run over code an assistant wrote for me: secrets in git history, keys leaking into the client bundle, API routes with no guard, stale dependencies, wildcard CORS. Every one of those lives in your application code.&lt;/p&gt;

&lt;p&gt;There is a layer underneath that, and it fails differently. If you are on Supabase, or any Postgres with Row Level Security, your database is reachable from the browser by design. The anon key is meant to be public. The thing standing between a stranger and your &lt;code&gt;profiles&lt;/code&gt; table is not your login screen and not your API route. It is your RLS policies.&lt;/p&gt;

&lt;p&gt;The failure I keep running into is not "I forgot about RLS." It is "RLS is on, the dashboard shows a green shield, and the table is still readable by anyone." Those are not the same state, and the dashboard does not really distinguish them for you.&lt;/p&gt;

&lt;p&gt;One caveat before the commands: run these against a project you own. Pointing them at someone else's project is unauthorized access, and in most places that is a crime, not a code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one test that matters
&lt;/h2&gt;

&lt;p&gt;Forget reading the policy list for a minute. The question a stranger asks your database is very simple, so ask it the same way they would: with the public key, no session, from outside your app.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Project ref and anon key are both in your client bundle already.&lt;/span&gt;
&lt;span class="c"&gt;# This is exactly what an unauthenticated visitor can send.&lt;/span&gt;
curl &lt;span class="s2"&gt;"https://&amp;lt;project-ref&amp;gt;.supabase.co/rest/v1/profiles?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: &amp;lt;anon-key&amp;gt;"&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 &amp;lt;anon-key&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;[]&lt;/code&gt; with a 200. The table is protected and empty for this caller. Good.&lt;/li&gt;
&lt;li&gt;A permission error mentioning row level security. Also good.&lt;/li&gt;
&lt;li&gt;Rows. Your data is public, whatever the dashboard says.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run it for every table that holds anything user specific. It takes about ten seconds per table and it answers the real question, which is not "did I enable a feature" but "can a stranger read this."&lt;/p&gt;

&lt;p&gt;Then do the same for writes, because read policies and write policies are separate things and I have shipped tables where only one of them was covered:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Can an anonymous caller insert?&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://&amp;lt;project-ref&amp;gt;.supabase.co/rest/v1/profiles"&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: &amp;lt;anon-key&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;anon-key&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"display_name":"anon write test"}'&lt;/span&gt;

&lt;span class="c"&gt;# Can an anonymous caller delete someone else's row?&lt;/span&gt;
curl &lt;span class="nt"&gt;-X&lt;/span&gt; DELETE &lt;span class="s2"&gt;"https://&amp;lt;project-ref&amp;gt;.supabase.co/rest/v1/profiles?id=eq.&amp;lt;some-id&amp;gt;"&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: &amp;lt;anon-key&amp;gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Authorization: Bearer &amp;lt;anon-key&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Clean up any rows you create while testing.&lt;/p&gt;

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

&lt;p&gt;Four patterns account for most of what I find.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. RLS is off on that table.&lt;/strong&gt; A table created from the SQL editor does not get RLS just because your other tables have it. When RLS is off, policies on the table are irrelevant, and the anon role reads everything. Check the flag directly instead of trusting your memory:&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="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;rls_enabled&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;join&lt;/span&gt; &lt;span class="n"&gt;pg_namespace&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relnamespace&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nspname&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;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'r'&lt;/span&gt;
&lt;span class="k"&gt;order&lt;/span&gt; &lt;span class="k"&gt;by&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relrowsecurity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything with &lt;code&gt;rls_enabled = false&lt;/code&gt; is open to the anon key.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. A policy that allows everything.&lt;/strong&gt; This is the one that produces the green shield and the public table at the same time. When you are stuck and asking an assistant why your query returns nothing, the easiest fix to reach for is a policy that permits the read unconditionally. It works, the error goes away, and the door is now open.&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="c1"&gt;-- Enabled, has a policy, protects nothing.&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;"enable read access for all users"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;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;List what you actually have:&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;roles&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;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;Read the &lt;code&gt;qual&lt;/code&gt; column. If it says &lt;code&gt;true&lt;/code&gt; on a table with user data, that is your bug. The ownership version is what you usually meant:&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;"read own rows"&lt;/span&gt;
  &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;profiles&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;to&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Some commands are covered and others are not.&lt;/strong&gt; Policies are per command. A table can have a careful &lt;code&gt;select&lt;/code&gt; policy and nothing for &lt;code&gt;update&lt;/code&gt;, which means the read is locked down and the row is still editable. Group the &lt;code&gt;pg_policies&lt;/code&gt; output by table and check that every command you allow from the client has a policy behind it. Note that &lt;code&gt;using&lt;/code&gt; decides which existing rows a statement can touch, while &lt;code&gt;with check&lt;/code&gt; decides what a row is allowed to look like after an insert or update. Write policies generally need both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The service role key is in the client.&lt;/strong&gt; The service role key bypasses RLS completely, by design, so it belongs on a server and nowhere else. If it ends up in a component, an edge config, or anything with a public env prefix, every policy above stops mattering. In my own project the server client is a separate module with a comment at the top saying it must never be imported from client code, and the key is read from a non public env var. That comment has saved me at least once.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Should return nothing outside server-only files&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"SERVICE_ROLE&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;service_role"&lt;/span&gt; src/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"server&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;lib/db"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I do now
&lt;/h2&gt;

&lt;p&gt;Before anything with real users touches it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;List tables where &lt;code&gt;relrowsecurity&lt;/code&gt; is false. Enable RLS on every one that holds user data. Enabling RLS with no policies denies access, which is the safe direction to fail.&lt;/li&gt;
&lt;li&gt;Dump &lt;code&gt;pg_policies&lt;/code&gt; and read every &lt;code&gt;qual&lt;/code&gt;. Replace each &lt;code&gt;true&lt;/code&gt; with an ownership check.&lt;/li&gt;
&lt;li&gt;For every table, confirm there is a policy for each command the client is allowed to run, and that write policies carry &lt;code&gt;with check&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run the logged out curl against every table, read and write, and keep going until the only tables that answer are the ones meant to be public.&lt;/li&gt;
&lt;li&gt;Grep for the service role key outside server code.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The part I want to leave you with is step 4. Policy review is reading, and reading is where you see what you intended. The unauthenticated request is the only one of these that tells you what you actually shipped.&lt;/p&gt;

&lt;p&gt;If you want the application layer pass that goes with this, the earlier post covers secrets, bundles, route guards, and dependencies. I also put the checks I run most often into a scanner you can point at a public repo at &lt;a href="https://shipsafescan.com" rel="noopener noreferrer"&gt;https://shipsafescan.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>postgres</category>
      <category>webdev</category>
    </item>
    <item>
      <title>A 15-minute security pass for code your AI wrote</title>
      <dc:creator>ShipSafeScan</dc:creator>
      <pubDate>Sun, 30 Aug 2026 11:37:04 +0000</pubDate>
      <link>https://dev.to/shipsafescan/a-15-minute-security-pass-for-code-your-ai-wrote-8hk</link>
      <guid>https://dev.to/shipsafescan/a-15-minute-security-pass-for-code-your-ai-wrote-8hk</guid>
      <description>&lt;p&gt;When you're vibe-coding a side project, "working" and "safe to ship" feel like the same thing. They aren't. LLM-generated code is really good at making the happy path run, and really good at quietly skipping the boring parts that keep you out of trouble: auth checks, secret handling, dependency hygiene.&lt;/p&gt;

&lt;p&gt;I found this out going back through my own repos. I'd let an assistant scaffold a few apps, shipped them, and then went back to audit what I'd actually pushed. It wasn't pretty. Nothing exotic, just the same handful of mistakes over and over. So this is a field guide to those mistakes, with the exact commands I now run before I call something done. No tools required to follow along; everything here is grep, &lt;code&gt;npm&lt;/code&gt;, and reading your own diff.&lt;/p&gt;

&lt;p&gt;A quick caveat on how to read this: these are patterns I keep seeing in code that LLMs generate, described qualitatively from my own repos. I'm not putting a percentage on it. I don't have a representative sample, and neither does anyone who tells you "X% of AI code is insecure" without a citation. Treat it as a checklist, not a statistic.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Secrets that made it into git history
&lt;/h2&gt;

&lt;p&gt;The classic. You paste an API key into &lt;code&gt;.env&lt;/code&gt; to test something, later add &lt;code&gt;.env&lt;/code&gt; to &lt;code&gt;.gitignore&lt;/code&gt;, and feel safe. But if the file was ever committed &lt;em&gt;before&lt;/em&gt; you ignored it, the key is still sitting in your history forever.&lt;/p&gt;

&lt;p&gt;Check the working tree first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Obvious offenders in files you can see right now&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEn&lt;/span&gt; &lt;span class="s2"&gt;"(api[_-]?key|secret|token|password|BEGIN.*PRIVATE KEY)"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.{js,ts,jsx,tsx,py,env,json,yml,yaml}"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; node_modules
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then check history, because that's where keys hide:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Was .env ever committed at any point?&lt;/span&gt;
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--full-history&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"*.env"&lt;/span&gt; &lt;span class="s2"&gt;".env"&lt;/span&gt;

&lt;span class="c"&gt;# Look for likely key shapes across all commits (example: AWS-style, generic hex)&lt;/span&gt;
git rev-list &lt;span class="nt"&gt;--all&lt;/span&gt; | xargs git &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-nE&lt;/span&gt; &lt;span class="s2"&gt;"AKIA[0-9A-Z]{16}|[a-f0-9]{32,}"&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you find anything: rotate the key first (assume it's burned), &lt;em&gt;then&lt;/em&gt; worry about scrubbing history. Rotating is the part that actually protects you. History rewrites (&lt;code&gt;git filter-repo&lt;/code&gt;) are secondary and can wait.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Secrets shipped to the browser
&lt;/h2&gt;

&lt;p&gt;Frontend frameworks expose any env var with a public prefix to the client bundle. In Next.js that's &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt;; other frameworks have their own (&lt;code&gt;VITE_&lt;/code&gt;, &lt;code&gt;REACT_APP_&lt;/code&gt;, &lt;code&gt;PUBLIC_&lt;/code&gt;). An assistant wiring up a fetch call will happily reach for whatever variable is in scope, including a server key it should never expose.&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 ships your key to every visitor's browser. Anyone can View Source.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&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.example.com/data&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;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &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_SERVICE_KEY&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rule of thumb: a &lt;code&gt;PUBLIC&lt;/code&gt; prefix means "I am fine with the whole world reading this." Publishable/anon keys are designed for that. Service-role keys, private API keys, and anything that can write or read other users' data are not.&lt;/p&gt;

&lt;p&gt;Grep your client-side code for the prefix and eyeball every hit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"NEXT_PUBLIC_&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;VITE_&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;REACT_APP_"&lt;/span&gt; src/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"secret|service|admin|private"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anything sensitive belongs behind a server route (an API route / server action), where the browser never sees the raw value.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. API and admin routes with no auth guard
&lt;/h2&gt;

&lt;p&gt;This is the one that bites hardest. The UI hides the "Delete everything" button behind a login screen, so it &lt;em&gt;feels&lt;/em&gt; protected. But the button just calls &lt;code&gt;/api/admin/reset&lt;/code&gt;, and that endpoint is a public URL. If the route handler doesn't check who's calling, anyone with the URL can call it directly with &lt;code&gt;curl&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// app/api/admin/users/route.ts, looks fine, ships data to anyone&lt;/span&gt;
&lt;span class="k"&gt;export&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;GET&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;users&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;db&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="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;     &lt;span class="c1"&gt;// no auth check anywhere&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix is to check the session at the top of the handler, not just in the UI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&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;GET&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Request&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;session&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;getSession&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unauthorized&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;401&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;admin&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Forbidden&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;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;403&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;users&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;db&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="nf"&gt;findMany&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;users&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;To find the gaps, list your routes and ask one question per file: &lt;em&gt;"If a stranger hit this URL with no cookie, what happens?"&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Every API route in a Next.js app-router project&lt;/span&gt;
find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; ./node_modules &lt;span class="nt"&gt;-prune&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/api/*"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"route.*"&lt;/span&gt; &lt;span class="nt"&gt;-print&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then grep for the routes that &lt;em&gt;don't&lt;/em&gt; mention a session/auth helper, those are your suspects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rL&lt;/span&gt; &lt;span class="s2"&gt;"getSession&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;auth(&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;requireUser&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;verifyToken"&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;find &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt; &lt;span class="s2"&gt;"*/api/*"&lt;/span&gt; &lt;span class="nt"&gt;-name&lt;/span&gt; &lt;span class="s2"&gt;"route.*"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Dependencies with known vulnerabilities
&lt;/h2&gt;

&lt;p&gt;LLMs are trained on a snapshot of the world, so they tend to reach for versions and packages that were popular &lt;em&gt;at training time&lt;/em&gt;, sometimes pinned to something with a known CVE. You don't have to guess; the tooling already knows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Ships with npm, no install needed&lt;/span&gt;
npm audit

&lt;span class="c"&gt;# Just the serious stuff, and a quick automated pass at safe upgrades&lt;/span&gt;
npm audit &lt;span class="nt"&gt;--audit-level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;high
npm audit fix
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you want an ecosystem-agnostic view (or you're on pnpm/yarn and want a second opinion), OSV-Scanner reads your lockfile against the open OSV vulnerability database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# https://github.com/google/osv-scanner&lt;/span&gt;
osv-scanner &lt;span class="nt"&gt;--lockfile&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;package-lock.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two habits worth building: don't blind-run &lt;code&gt;npm audit fix --force&lt;/code&gt; (it can yank in breaking major versions), and re-run the audit on a schedule, because new CVEs get published against dependencies you already have.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. CORS, rate limits, and errors that overshare
&lt;/h2&gt;

&lt;p&gt;The quiet trio. None of these throws an error in dev, so they sail straight to prod.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CORS set to a wildcard&lt;/strong&gt; lets any website call your API from a user's browser:&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;// Reflects "allow everyone", fine for a truly public read-only API,&lt;/span&gt;
&lt;span class="c1"&gt;// a problem the moment the endpoint does anything user-specific.&lt;/span&gt;
&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Access-Control-Allow-Origin&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;*&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Scope it to the origins you actually serve instead of &lt;code&gt;*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No rate limiting&lt;/strong&gt; means a single script can hammer your login route, your signup, or your LLM-backed endpoint (that last one can also run up a real bill). Even a minimal per-IP limit in front of sensitive routes changes the economics for an attacker.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Errors that leak internals&lt;/strong&gt; hand attackers a map. Returning the raw exception is convenient in dev and a gift in prod:&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;// Don't send this to the client in production&lt;/span&gt;
&lt;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;stack&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&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;Log the full error server-side; return a generic message and a request ID to the client. Grep for the tell:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"err.stack&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;error.message&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;console.log(err"&lt;/span&gt; src/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The actual 5-minute / 15-minute pass
&lt;/h2&gt;

&lt;p&gt;Here's the routine distilled. You can literally paste these in order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5-minute triage&lt;/strong&gt; (do this every time before you push):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 1. Secrets in the working tree&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rEn&lt;/span&gt; &lt;span class="s2"&gt;"(api[_-]?key|secret|token|password)"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"*.{js,ts,py,env}"&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; node_modules
&lt;span class="c"&gt;# 2. Was .env ever committed?&lt;/span&gt;
git log &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="nt"&gt;--oneline&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"*.env"&lt;/span&gt; &lt;span class="s2"&gt;".env"&lt;/span&gt;
&lt;span class="c"&gt;# 3. Public-prefixed secrets leaking to the client&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; &lt;span class="s2"&gt;"NEXT_PUBLIC_&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;VITE_&lt;/span&gt;&lt;span class="se"&gt;\|&lt;/span&gt;&lt;span class="s2"&gt;REACT_APP_"&lt;/span&gt; src/ | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-iE&lt;/span&gt; &lt;span class="s2"&gt;"secret|service|admin"&lt;/span&gt;
&lt;span class="c"&gt;# 4. Known-vulnerable deps&lt;/span&gt;
npm audit &lt;span class="nt"&gt;--audit-level&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;high
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;15-minute pass&lt;/strong&gt; (do this before anything goes to real users):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Walk every file under &lt;code&gt;**/api/**&lt;/code&gt; and answer &lt;em&gt;"stranger, no cookie, what happens?"&lt;/em&gt; for each one. Add the &lt;code&gt;401/403&lt;/code&gt; guard where the answer is "they get data."&lt;/li&gt;
&lt;li&gt;Confirm no &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; on endpoints that return user-specific data.&lt;/li&gt;
&lt;li&gt;Put a basic rate limit in front of auth and any LLM/paid endpoint.&lt;/li&gt;
&lt;li&gt;Grep for &lt;code&gt;err.stack&lt;/code&gt; / raw error returns and swap them for generic messages + server-side logging.&lt;/li&gt;
&lt;li&gt;Rotate any key you found in steps 1-3. Assume anything committed is compromised.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole thing. It's not sophisticated, and that's the point, the failures in AI-generated code are usually boring omissions, so a boring checklist catches most of them.&lt;/p&gt;




&lt;p&gt;I ended up running this so often across my own projects that I got tired of doing it by hand, so I wrapped the repetitive parts (the secret grep, the dependency check, the "is this route guarded" pass) into a small scanner you point at a public GitHub repo: &lt;a href="https://shipsafescan.com" rel="noopener noreferrer"&gt;ShipSafeScan&lt;/a&gt;. It won't catch everything a careful read will, nothing does, but it's a fast first pass. Either way, the checklist above works on its own. Run it before you ship.&lt;/p&gt;

</description>
      <category>security</category>
      <category>ai</category>
      <category>webdev</category>
      <category>beginners</category>
    </item>
  </channel>
</rss>
