<?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: Jacob Perks</title>
    <description>The latest articles on DEV Community by Jacob Perks (@thunkle).</description>
    <link>https://dev.to/thunkle</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%2F4048291%2Fb6a78952-91ee-4d48-93b9-1cbf3a456937.jpg</url>
      <title>DEV Community: Jacob Perks</title>
      <link>https://dev.to/thunkle</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/thunkle"/>
    <language>en</language>
    <item>
      <title>Every Supabase key, what it does, and which ones can be public</title>
      <dc:creator>Jacob Perks</dc:creator>
      <pubDate>Fri, 07 Aug 2026 19:16:15 +0000</pubDate>
      <link>https://dev.to/thunkle/every-supabase-key-what-it-does-and-which-ones-can-be-public-3j0a</link>
      <guid>https://dev.to/thunkle/every-supabase-key-what-it-does-and-which-ones-can-be-public-3j0a</guid>
      <description>&lt;p&gt;I audit apps built with AI tools. Most of the exposed credentials I find are Supabase keys, and most of them are not a problem.&lt;/p&gt;

&lt;p&gt;That is the part people get wrong in both directions. Some panic about a key that was designed to be published. Others ship the one that grants full database access and never notice, because nothing breaks.&lt;/p&gt;

&lt;p&gt;There are four keys. Two of them are being retired.&lt;/p&gt;

&lt;h2&gt;
  
  
  The deadline
&lt;/h2&gt;

&lt;p&gt;Supabase is replacing the original &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; keys. Their &lt;a href="https://github.com/orgs/supabase/discussions/29260" rel="noopener noreferrer"&gt;own timeline&lt;/a&gt; is blunt about what happens at the end:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Legacy API keys will be deleted and removed from the Docs / Dashboard. You have to migrate to use the new API keys by this point or your app will break."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That is scheduled for late 2026, marked as to be confirmed.&lt;/p&gt;

&lt;p&gt;There is an earlier milestone that matters more:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Projects restored from 1st November 2025 will no longer be restored with the legacy API keys. New projects no longer have anon and service_role available for use."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So if your project was created after November 2025, you already have the new keys and there is nothing to do. If it was created before, you are on legacy keys and you have a deadline.&lt;/p&gt;

&lt;p&gt;Think about who that is. Nearly every app built during the 2025 rush of Lovable, Bolt and Replit projects predates that cutoff. Those apps are running legacy keys. Most of their owners have never opened the API settings page. The app stops working when the keys are deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four keys
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Browser safe&lt;/th&gt;
&lt;th&gt;What it grants&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Publishable&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sb_publishable_...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Low privilege. Access controlled by row level security through the &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;authenticated&lt;/code&gt; roles&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Secret&lt;/td&gt;
&lt;td&gt;&lt;code&gt;sb_secret_...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Full access to your project's data. Bypasses row level security&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;anon&lt;/code&gt; (legacy)&lt;/td&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Legacy version of the publishable key&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;service_role&lt;/code&gt; (legacy)&lt;/td&gt;
&lt;td&gt;JWT&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Legacy version of the secret key. Bypasses RLS via the &lt;code&gt;BYPASSRLS&lt;/code&gt; attribute&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Source: &lt;a href="https://supabase.com/docs/guides/getting-started/api-keys" rel="noopener noreferrer"&gt;Supabase, Understanding API keys&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two of these belong in your frontend. Two of them, if they reach a browser, mean your database is already compromised.&lt;/p&gt;

&lt;h2&gt;
  
  
  A public key is not a leak
&lt;/h2&gt;

&lt;p&gt;The publishable key is meant to be shipped. It sits in your JavaScript bundle. Anyone who opens developer tools can read it. That is the design.&lt;/p&gt;

&lt;p&gt;The key identifies your project. It is not what protects your data.&lt;/p&gt;

&lt;p&gt;Row level security is what protects your data. The key gets a request as far as the &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;authenticated&lt;/code&gt; role, and the policies you write on each table decide what those roles can read and write.&lt;/p&gt;

&lt;p&gt;So the key is exactly as safe as your policies are. If every table has policies, publishing it costs you nothing. If a table has RLS switched off, that same public key reads the whole table. So does anyone else who found it.&lt;/p&gt;

&lt;p&gt;This is the most common serious finding in the audits I do. Not a stolen credential. A published one, working as designed, in front of a table that was never given a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  The secret key: full database access, no policies
&lt;/h2&gt;

&lt;p&gt;The secret key bypasses row level security completely. It does not respect your policies because it is not supposed to. That is its job. It exists so your server can act on behalf of the system rather than a signed-in user.&lt;/p&gt;

&lt;p&gt;If it reaches a browser, every protection you wrote is gone at once. Not weakened. Gone. Anyone who reads it out of your bundle can read, change or delete any row in your database.&lt;/p&gt;

&lt;p&gt;There is no partial version of this finding. Treat the key as already compromised, because every visitor to your site has been able to read it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the format changed
&lt;/h2&gt;

&lt;p&gt;Here is the problem the new keys solve.&lt;/p&gt;

&lt;p&gt;The legacy &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt; keys are both JSON web tokens. Both are long strings of the same characters. Side by side, you cannot tell them apart by looking. The only thing separating a key that belongs in every visitor's browser from a key that grants full database access is one field inside the token.&lt;/p&gt;

&lt;p&gt;I built a security scanner for Framer sites, which is where I run into this constantly: Framer handles the front end, so anything with a database behind it has Supabase wired in from the browser, and the key is sitting in the page. The scanner decodes the token and branches on the role claim. &lt;code&gt;service_role&lt;/code&gt; is reported as critical. &lt;code&gt;anon&lt;/code&gt; is not reported at all, because reporting it would be wrong. A tool that flags your publishable key has not found anything. It has taught you that its output is noise, and the next thing you skip past will be the real one.&lt;/p&gt;

&lt;p&gt;The new format removes the ambiguity. &lt;code&gt;sb_publishable_&lt;/code&gt; and &lt;code&gt;sb_secret_&lt;/code&gt; are different at a glance, to a person and to a tool. Supabase also added browser detection, so a secret key used from a browser returns HTTP 401 instead of quietly working.&lt;/p&gt;

&lt;p&gt;Do not read that as a safety net. The check matches on the &lt;code&gt;User-Agent&lt;/code&gt; header, and Supabase's own docs say so plainly: it "does not mean that attackers will not use it with other tools." Anyone who copies your key out of a page and sends it from curl gets straight through. The 401 stops your own code from working by accident. It does not stop the person who found the key.&lt;/p&gt;

&lt;p&gt;That is a real improvement, and it is worth being clear about why. The old design made a catastrophic mistake and a harmless one look identical.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tell which one you shipped
&lt;/h2&gt;

&lt;p&gt;On the new keys, read the prefix. &lt;code&gt;sb_publishable_&lt;/code&gt; is fine in your frontend. &lt;code&gt;sb_secret_&lt;/code&gt; is not.&lt;/p&gt;

&lt;p&gt;On legacy keys, the prefix will not help. You have to decode the token and read the &lt;code&gt;role&lt;/code&gt; field.&lt;/p&gt;

&lt;p&gt;Do that locally, not in an online JWT decoder. If the key turns out to be &lt;code&gt;service_role&lt;/code&gt;, you have just pasted full access to your database into somebody else's website. In your browser console:&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;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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="mi"&gt;1&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;/-/g&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&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="nx"&gt;role&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It says &lt;code&gt;anon&lt;/code&gt; or &lt;code&gt;service_role&lt;/code&gt;. That is the whole test.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;split&lt;/code&gt; and the two &lt;code&gt;replace&lt;/code&gt; calls are not decoration. A JWT is three base64url segments joined by dots, so &lt;code&gt;atob&lt;/code&gt; on the whole string fails, and base64url uses &lt;code&gt;-&lt;/code&gt; and &lt;code&gt;_&lt;/code&gt; where &lt;code&gt;atob&lt;/code&gt; expects &lt;code&gt;+&lt;/code&gt; and &lt;code&gt;/&lt;/code&gt;. Plenty of tokens happen to contain neither character, which is why the naive one-liner works right up until the day it does not.&lt;/p&gt;

&lt;p&gt;Then check where each one is used. The publishable or &lt;code&gt;anon&lt;/code&gt; key belongs in client code. The secret or &lt;code&gt;service_role&lt;/code&gt; key belongs on a server, in an environment variable, never in a repository, and never in anything sent to a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  If the wrong key is already public
&lt;/h2&gt;

&lt;p&gt;This is where the two key systems stop being equivalent, and it is the strongest practical argument for migrating.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On the new keys, you replace it.&lt;/strong&gt; Create a second secret key in Settings, API Keys, move everything over to it, then delete the compromised one. The publishable key is untouched, nobody is signed out, and there is no downtime. Deleting a secret key is permanent, so do it in that order.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On legacy keys, you cannot.&lt;/strong&gt; Supabase's own troubleshooting documentation is explicit: "it is no longer possible to rotate the legacy anon, service and JWT secrets." There is no button. An exposed &lt;code&gt;service_role&lt;/code&gt; key stays exposed until you migrate off it.&lt;/p&gt;

&lt;p&gt;That is worth sitting with. If you are on legacy keys and your &lt;code&gt;service_role&lt;/code&gt; key is in a browser bundle today, migration is not a piece of housekeeping with a 2026 deadline. It is the only way to revoke the key, and it is this week's job.&lt;/p&gt;

&lt;p&gt;Historically the fix was to regenerate the project's JWT secret, which signed both legacy keys and therefore invalidated every session in one go. Supabase describes the effect as all current secrets being "immediately invalidated, and all connections using them will be severed." That route is closed now, and the replacement path is the new keys, which is the point of the whole exercise: individually revocable credentials instead of two tokens welded to one secret.&lt;/p&gt;

&lt;p&gt;Then check your row level security policies while you are there. An exposed &lt;code&gt;service_role&lt;/code&gt; key usually means nobody was thinking about database access rules at the time, so the policies are often missing too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Migrating off the legacy keys
&lt;/h2&gt;

&lt;p&gt;Supabase's &lt;a href="https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys" rel="noopener noreferrer"&gt;migration guide&lt;/a&gt; covers the process:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create the new keys in Settings, API Keys&lt;/li&gt;
&lt;li&gt;Replace the &lt;code&gt;anon&lt;/code&gt; key with the publishable key in client code&lt;/li&gt;
&lt;li&gt;Replace &lt;code&gt;service_role&lt;/code&gt; with the secret key on the server&lt;/li&gt;
&lt;li&gt;Update Edge Functions to read the new environment variables&lt;/li&gt;
&lt;li&gt;Confirm nothing still uses the old keys&lt;/li&gt;
&lt;li&gt;Deactivate the legacy keys&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Both sets work at the same time, so you can move gradually. And deactivating the legacy keys is reversible. If you miss a client, turn them back on. That matters more than it sounds, because fear of breaking production is the main reason this gets postponed.&lt;/p&gt;

&lt;p&gt;Five things in the migration are easy to get wrong, all documented:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Neither new key goes in an &lt;code&gt;Authorization: Bearer&lt;/code&gt; header. Send them on the &lt;code&gt;apikey&lt;/code&gt; header only. Many Supabase clients pass the key on both by default, and the platform then tries to parse it as a JWT and rejects the request with &lt;code&gt;Invalid JWT&lt;/code&gt;. That error message sends people looking in the wrong place for hours.&lt;/li&gt;
&lt;li&gt;Database Webhooks and &lt;code&gt;pg_net&lt;/code&gt; calls need the same change.&lt;/li&gt;
&lt;li&gt;Do not hardcode secret keys in SQL or webhook configuration. Use Vault.&lt;/li&gt;
&lt;li&gt;Edge Functions cannot verify the new keys in &lt;code&gt;Authorization&lt;/code&gt; headers, so set &lt;code&gt;verify_jwt = false&lt;/code&gt;. &lt;strong&gt;That makes the function publicly invokable by anyone with the URL&lt;/strong&gt;, so the authorisation check has to move into the function body. The docs say to "authorize the request in your own code", and this is the step people skip, because the function keeps working perfectly whether or not you do it.&lt;/li&gt;
&lt;li&gt;Public Realtime connections are limited to 24 hours unless the connection is upgraded with user-level authentication through Supabase Auth or a supported third-party provider.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What to do this week
&lt;/h2&gt;

&lt;p&gt;Open your Supabase dashboard and go to Settings, API Keys. If you see &lt;code&gt;anon&lt;/code&gt; and &lt;code&gt;service_role&lt;/code&gt;, your project predates November 2025 and you have a migration to do.&lt;/p&gt;

&lt;p&gt;While you are there, three checks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Confirm every table has row level security enabled, then look at the policies.&lt;/strong&gt; Enabled is not the same as protected. A table with a policy of &lt;code&gt;USING (true)&lt;/code&gt; is readable by anyone holding your public key, which is everyone, and the dashboard will still show RLS as on. The same goes for a table exposed through a &lt;code&gt;SECURITY DEFINER&lt;/code&gt; view, which runs as its owner and bypasses policies entirely. Neither shows up as a warning.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Search your frontend code and your deployed bundle for &lt;code&gt;service_role&lt;/code&gt; and &lt;code&gt;sb_secret_&lt;/code&gt;.&lt;/strong&gt; Search the built output, not just the source, because environment variables get inlined at build time and that is usually how it happens. If you find one, see the section above: replaceable on the new keys, not replaceable on legacy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Decode the key that is actually in your client bundle&lt;/strong&gt; and confirm the role says &lt;code&gt;anon&lt;/code&gt;. Not the one in your &lt;code&gt;.env&lt;/code&gt; file. The one that shipped.&lt;/p&gt;

&lt;p&gt;Those take a few minutes. They will not tell you whether the policies you wrote are correct, or whether a later change reopened something you had already closed. Finding an exposed key and knowing your access rules hold are different problems. On AI-built apps, the second is where most of the work is.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you would rather not check it yourself
&lt;/h2&gt;

&lt;p&gt;That second problem is most of what I do. I audit apps built with AI tools, work through the access rules account by account, and send back a report with every finding, where it lives and what to change. Fixed price, from $750. That is &lt;a href="https://thunkle.ai/services/security-audit" rel="noopener noreferrer"&gt;the audit we do&lt;/a&gt;, and you can &lt;a href="https://thunkle.ai/quote" rel="noopener noreferrer"&gt;request one here&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Sources&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://supabase.com/docs/guides/getting-started/api-keys" rel="noopener noreferrer"&gt;Understanding API keys&lt;/a&gt;, Supabase Docs&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://supabase.com/docs/guides/getting-started/migrating-to-new-api-keys" rel="noopener noreferrer"&gt;Migrating to publishable and secret API keys&lt;/a&gt;, Supabase Docs&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/orgs/supabase/discussions/29260" rel="noopener noreferrer"&gt;Upcoming changes to Supabase API Keys, Discussion #29260&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://supabase.com/docs/guides/troubleshooting/rotating-anon-service-and-jwt-secrets-1Jq6yd" rel="noopener noreferrer"&gt;Rotating Anon, Service, and JWT Secrets&lt;/a&gt;, Supabase Docs&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://thunkle.ai/blog/every-supabase-key-explained" rel="noopener noreferrer"&gt;thunkle.ai&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>security</category>
      <category>webdev</category>
      <category>ai</category>
    </item>
    <item>
      <title>What I scan for in Framer code components</title>
      <dc:creator>Jacob Perks</dc:creator>
      <pubDate>Mon, 27 Jul 2026 15:33:25 +0000</pubDate>
      <link>https://dev.to/thunkle/what-i-scan-for-in-framer-code-components-45la</link>
      <guid>https://dev.to/thunkle/what-i-scan-for-in-framer-code-components-45la</guid>
      <description>&lt;p&gt;Most of a Framer site is not code. The parts that are, are code components, and that is where the risk collects. A component that calls an API needs a key. A component that needs a library sometimes imports it from a URL. A component rendering content from elsewhere has to decide how much it trusts that content.&lt;/p&gt;

&lt;p&gt;I built a free plugin that reads them and reports what it finds. Here is what it looks for, and the parts that were harder than expected.&lt;/p&gt;

&lt;h2&gt;
  
  
  First, the thing that caught me out
&lt;/h2&gt;

&lt;p&gt;Framer has two things called code:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code components&lt;/strong&gt;, the &lt;code&gt;.tsx&lt;/code&gt; files under Assets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom code&lt;/strong&gt;, the named scripts under Settings with a placement dropdown&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same risk. A key in either ships to the browser. &lt;strong&gt;A plugin can only read the first.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The API has &lt;code&gt;getCustomCode()&lt;/code&gt;, which sounds like it returns the project's custom code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CustomCodeLocation&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headStart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;headEnd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bodyStart&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;bodyEnd&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="nx"&gt;CustomCode&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;CustomCodeLocation&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;disabled&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
    &lt;span class="na"&gt;html&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It returns whatever &lt;strong&gt;your own plugin&lt;/strong&gt; put there. I confirmed it by pasting a script into the Code panel of a real project, saving, and printing the counts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;headStart: 0 chars
headEnd:   0 chars      ← script visibly sitting in Settings → Code
bodyStart: 0 chars
bodyEnd:   0 chars

codeFiles: 1
  Test.tsx: 830 chars   ← Assets → Code, readable
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The docs say a plugin can detect custom code "set by your plugin", which read carefully is the entire answer. I went through every read method on the API, roughly fifty, and nothing returns user-authored custom code or site settings.&lt;/p&gt;

&lt;p&gt;So the scanner reads what it can:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&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;framer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getCodeFiles&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;for &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;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// Test.tsx 830&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;subscribeToCodeFiles&lt;/code&gt; and &lt;code&gt;subscribeToRedirects&lt;/code&gt; let the panel re-scan as the user edits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Credentials in components
&lt;/h2&gt;

&lt;p&gt;The most common real finding, for an ordinary reason: standing up a server to hold a key is a bigger job than the component that needs it, so the key goes in the file and stays there.&lt;/p&gt;

&lt;p&gt;Fifteen formats, weighted toward what people reach for when a component calls a model: OpenAI, Anthropic, Groq, Replicate, Hugging Face, Perplexity, alongside Stripe, AWS, Supabase, GitHub, SendGrid, Slack, Twilio, Resend and Convex.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The check that decides whether anyone trusts the tool.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Supabase issues two keys. Both are JWTs, both are long, and side by side you cannot tell them apart. The &lt;code&gt;anon&lt;/code&gt; key belongs in the browser and is protected by row-level security. The &lt;code&gt;service_role&lt;/code&gt; key bypasses RLS entirely.&lt;/p&gt;

&lt;p&gt;One field in the payload separates them, so decode rather than pattern match:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;supabaseJwtRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&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;parts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&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;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt; &lt;span class="o"&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;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;parts&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/-/g&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;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/_/g&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;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;role&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;unknown&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;typeof&lt;/span&gt; &lt;span class="nx"&gt;payload&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;string&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;role&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;&lt;code&gt;service_role&lt;/code&gt; is critical. &lt;code&gt;anon&lt;/code&gt; is not reported at all. Same for Firebase browser keys and Stripe publishable keys, which exist to ship in a page.&lt;/p&gt;

&lt;p&gt;A scanner that flags those has not found anything. It has taught its user that its output is noise, and the next thing they skip will be real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Modules imported from a URL
&lt;/h2&gt;

&lt;p&gt;Framer lets a component import straight from a URL. Useful, mostly fine, and it means whoever controls that host controls part of your site. If the file changes tomorrow, your site runs the new version.&lt;/p&gt;

&lt;p&gt;Two cases get flagged: anything over plain &lt;code&gt;http&lt;/code&gt;, and anything from a host that is not a recognised package CDN. esm.sh, jsDelivr, unpkg and Skypack stay quiet since that is normal practice. An unrecognised domain is worth a look, especially on a site someone else built.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content turned into code
&lt;/h2&gt;

&lt;p&gt;Three worth knowing about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;dangerouslySetInnerHTML&lt;/code&gt;, where any part of the HTML coming from a URL parameter or CMS field means someone can run scripts on your site&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;eval&lt;/code&gt; and &lt;code&gt;new Function&lt;/code&gt;, which run whatever string they are handed&lt;/li&gt;
&lt;li&gt;reading a key or token out of &lt;code&gt;location.search&lt;/code&gt; or &lt;code&gt;location.hash&lt;/code&gt;, which leaks it into browser history, referrer headers and analytics&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Redirects
&lt;/h2&gt;

&lt;p&gt;Not components, but the other thing nobody re-reads. When a redirect's destination page is deleted, Framer nulls the target:&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="kr"&gt;interface&lt;/span&gt; &lt;span class="nx"&gt;Redirect&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;
    &lt;span class="nx"&gt;to&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;   &lt;span class="c1"&gt;// null = destination page was deleted&lt;/span&gt;
    &lt;span class="nx"&gt;expandToAllLocales&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;boolean&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is a broken redirect with no indication in the UI. Chains, loops and duplicate sources all fall out of the same array.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug I shipped into my own first draft
&lt;/h2&gt;

&lt;p&gt;Before publishing I ran the patterns against strings that are &lt;strong&gt;not&lt;/strong&gt; secrets. Four of six benign inputs came back flagged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FALSE POSITIVE  git commit SHA in a comment    -&amp;gt; "Twilio API key exposed"
FALSE POSITIVE  cache-busting hash in a URL    -&amp;gt; "Twilio API key exposed"
FALSE POSITIVE  css class name                 -&amp;gt; "OpenAI API key exposed"
FALSE POSITIVE  hyphenated word chain          -&amp;gt; "OpenAI API key exposed"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Twilio key identifier is &lt;code&gt;SK&lt;/code&gt; plus 32 hex characters, which is also every other git hash. And I had allowed hyphens in the OpenAI pattern, so &lt;code&gt;sk-spinner-container-large-variant&lt;/code&gt; qualified.&lt;/p&gt;

&lt;p&gt;Two guards. Require a match to mix character classes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;looksHighEntropy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;boolean&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;classes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;a-z&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;re&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&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;classes&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And for formats shape-identical to ordinary text, require supporting context before reporting at all:&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="p"&gt;{&lt;/span&gt;
    &lt;span class="nl"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;twilio-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;pattern&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;bSK&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="mi"&gt;9&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;fA&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;F&lt;/span&gt;&lt;span class="p"&gt;]{&lt;/span&gt;&lt;span class="mi"&gt;32&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nx"&gt;requiresContext&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;twilio&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// no Twilio in the file, no finding&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero false positives afterwards, every real key still caught.&lt;/p&gt;

&lt;p&gt;Not a lesson about regexes. One confident wrong finding costs more trust than ten correct ones earn, and the only way to know which you have written is to test against things that are fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Limits worth stating out loud
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No custom code.&lt;/strong&gt; A clean scan does not mean a clean site. Check the Settings panel yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No published HTML.&lt;/strong&gt; No API for the rendered site, and you cannot fetch it cross-origin from the plugin iframe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No proof a tracker fired before consent.&lt;/strong&gt; That needs a browser watching the network on the live page. From inside the editor you can only infer, so the tool says "no consent signal found" rather than asserting a violation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The plugin
&lt;/h2&gt;

&lt;p&gt;Free, no account, no tiers: &lt;a href="https://www.framer.com/community/marketplace/plugins/thunkle-security-scanner/" rel="noopener noreferrer"&gt;Thunkle Security Scanner&lt;/a&gt; on the Framer Marketplace.&lt;/p&gt;

&lt;p&gt;Runs entirely in the browser, zero network requests, small static bundle so you can verify that in devtools rather than take my word for it.&lt;/p&gt;

&lt;p&gt;I write more of this at &lt;a href="https://thunkle.ai" rel="noopener noreferrer"&gt;thunkle.ai&lt;/a&gt;, where I audit and fix apps built with AI tools.&lt;/p&gt;

</description>
      <category>security</category>
      <category>webdev</category>
      <category>typescript</category>
      <category>cybersecurity</category>
    </item>
  </channel>
</rss>
