<?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: Veristria</title>
    <description>The latest articles on DEV Community by Veristria (@veristria).</description>
    <link>https://dev.to/veristria</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%2F4088170%2Fe4ca5920-1f77-4f11-8ba5-b1b8b9364928.png</url>
      <title>DEV Community: Veristria</title>
      <link>https://dev.to/veristria</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/veristria"/>
    <language>en</language>
    <item>
      <title>What a lost dispute actually costs a Connect platform</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:40:43 +0000</pubDate>
      <link>https://dev.to/veristria/what-a-lost-dispute-actually-costs-a-connect-platform-5gh2</link>
      <guid>https://dev.to/veristria/what-a-lost-dispute-actually-costs-a-connect-platform-5gh2</guid>
      <description>&lt;p&gt;Refunds get the attention. Disputes are worse, and the reason is structural rather than a matter of degree.&lt;/p&gt;

&lt;p&gt;On a refund you at least have a parameter. &lt;code&gt;reverse_transfer: true&lt;/code&gt; exists, you can pass it, and if you forget there is a boolean to point at afterwards. &lt;strong&gt;On a dispute there is no such parameter, because there is no such option.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What Stripe does when you lose
&lt;/h2&gt;

&lt;p&gt;Stripe debits the platform for the disputed amount plus the dispute fee. That is it. That is the whole automatic behaviour.&lt;/p&gt;

&lt;p&gt;The connected account that received the original transfer is not touched. Not reduced, not notified, not flagged. From that seller's perspective nothing happened at all — the money arrived weeks ago and is still theirs.&lt;/p&gt;

&lt;p&gt;So the platform's position after losing a dispute on a destination charge is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- disputed amount   (debited from platform balance)
- dispute fee       (debited from platform balance, typically ~15 USD)
&lt;/span&gt;&lt;span class="gi"&gt;+ nothing            (the transfer is untouched)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The seller keeps the funds. You paid the customer back and paid a fee for the privilege.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is not symmetrical with refunds
&lt;/h2&gt;

&lt;p&gt;A refund is initiated by you, so Stripe can offer a parameter at the moment of initiation. A dispute is initiated by the cardholder's bank, days or weeks after the charge, through a process you are not in. There is no call of yours to attach a flag to.&lt;/p&gt;

&lt;p&gt;The reversal, if you want one, has to be a separate action you take afterwards:&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;// on charge.dispute.closed with status 'lost'&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;transfers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createReversal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;transferId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;disputedAmount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;       &lt;span class="c1"&gt;// not necessarily the full transfer&lt;/span&gt;
  &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`dispute &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;disputeId&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;Nothing calls that for you. If your webhook handler for &lt;code&gt;charge.dispute.closed&lt;/code&gt; only writes a row and updates a status, the clawback never happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three things that make this accumulate quietly
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;The timing hides it.&lt;/strong&gt; A dispute closes 30 to 90 days after the charge. By then the transfer is in a different payout, a different month, and usually a different reconciliation report. The debit and the un-reversed transfer are never adjacent in any view you look at.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The connected balance may not cover it.&lt;/strong&gt; Even when you do reverse, the seller may have already paid out. The reversal leaves the account negative, which surfaces later as a failed payout — a third event, in a fourth place, with no obvious link back to a dispute.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Winning still costs.&lt;/strong&gt; The dispute fee is generally not returned on a win in most regions. A platform with a good win rate still bleeds fees, and those fees are charged to the platform rather than to the seller whose transaction caused them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The reconciliation query
&lt;/h2&gt;

&lt;p&gt;The question is the same shape as the refund one, and it is worth running over full history rather than a recent window, because the whole failure mode is that it is old by the time it matters:&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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;                &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dispute_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;            &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;debited&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;fee&lt;/span&gt;               &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;dispute_fee&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;unrecovered&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;disputes&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;transfer_reversals&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'lost'&lt;/span&gt;
   &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&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="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;unrecovered&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The policy question underneath the technical one
&lt;/h2&gt;

&lt;p&gt;Whether to claw back is genuinely a business decision, and reasonable platforms answer it differently. A marketplace with sellers who have no control over fraud may absorb disputes deliberately. A platform whose sellers &lt;em&gt;do&lt;/em&gt; control fraud usually should not.&lt;/p&gt;

&lt;p&gt;What is not a decision is doing it by accident. If you have never reversed a transfer on a lost dispute, you have chosen to absorb every one of them — you just did not know that was the choice you were making. Run the query and find out which platform you are.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>payments</category>
      <category>fintech</category>
      <category>api</category>
    </item>
    <item>
      <title>The Stripe Connect refund that only costs the platform</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:39:36 +0000</pubDate>
      <link>https://dev.to/veristria/the-stripe-connect-refund-that-only-costs-the-platform-1l68</link>
      <guid>https://dev.to/veristria/the-stripe-connect-refund-that-only-costs-the-platform-1l68</guid>
      <description>&lt;p&gt;There is a class of bug in Stripe Connect that never throws, never logs, and never fails a webhook. The refund succeeds. The customer is made whole. The money comes out of your balance and stays with the seller.&lt;/p&gt;

&lt;p&gt;It is worth understanding precisely, because the fix is one boolean and the detection is one join.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a destination charge actually is
&lt;/h2&gt;

&lt;p&gt;On a destination charge, one API call does two things: it charges the customer, and it creates a &lt;strong&gt;transfer&lt;/strong&gt; moving funds to the connected account. Two objects, one call.&lt;/p&gt;

&lt;p&gt;A refund, by default, only undoes the first one.&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;// the customer gets their money back. the transfer is untouched.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ch_123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// the transfer is reversed too&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ch_123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reverse_transfer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;reverse_transfer&lt;/code&gt; defaults to &lt;code&gt;false&lt;/code&gt;. That default is defensible — sometimes the platform genuinely wants to absorb a refund rather than claw it back from a seller who already shipped. What makes it a footgun is that choosing wrongly produces no signal at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second flag, which is not the same flag
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;refund_application_fee&lt;/code&gt; is independent. Reversing the transfer does not refund your platform fee, and refunding the fee does not reverse the transfer.&lt;/p&gt;

&lt;p&gt;That means a refund can be wrong in either direction on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;transfer reversed, fee kept -&amp;gt; you kept a fee on revenue that no longer exists&lt;/li&gt;
&lt;li&gt;fee refunded, transfer not reversed -&amp;gt; you paid the customer &lt;em&gt;and&lt;/em&gt; gave up the fee&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three details that bite during reconciliation
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Partial refunds do not partially reverse unless you ask.&lt;/strong&gt; A partial refund with &lt;code&gt;reverse_transfer: true&lt;/code&gt; reverses the corresponding fraction. A partial refund &lt;em&gt;without&lt;/em&gt; it reverses nothing at all — not a smaller amount, nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A negative connected balance does not block the refund.&lt;/strong&gt; The reversal is attempted and the account can be left negative. You find out later, as a failed payout, in a different part of the system, with no obvious link back.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Disputes behave differently again.&lt;/strong&gt; On a lost dispute, Stripe debits the platform in full. The connected account that received the transfer is untouched unless you reverse it yourself. There is no &lt;code&gt;reverse_transfer&lt;/code&gt; on a dispute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The query that finds them
&lt;/h2&gt;

&lt;p&gt;The reconciliation question is not "did the refund succeed". It is: &lt;strong&gt;for every &lt;code&gt;charge.refunded&lt;/code&gt;, is there a matching &lt;code&gt;transfer.reversal&lt;/code&gt; of the expected amount?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Working from the events you already receive:&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;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;charge_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;                      &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;refunded&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;reversed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&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="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;unrecovered&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;refunds&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
  &lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;transfer_reversals&lt;/span&gt; &lt;span class="n"&gt;rev&lt;/span&gt;
         &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transfer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;transfer_id&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rev&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&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="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;unrecovered&lt;/span&gt; &lt;span class="k"&gt;DESC&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 is money that left and did not come back. Run it across your full history the first time — this accumulates silently, so the first run is usually the interesting one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it survives code review
&lt;/h2&gt;

&lt;p&gt;Because the code looks correct. &lt;code&gt;refunds.create({ charge })&lt;/code&gt; is the documented call, it does what its name says, and the omission is a parameter that is not there. Reviewers see present code, not absent parameters.&lt;/p&gt;

&lt;p&gt;The durable fix is not to remember the flag. It is to make the two event streams reconcile automatically, so the answer to "is our ledger whole" is a number somebody looks at rather than an assumption everybody makes.&lt;/p&gt;

</description>
      <category>stripe</category>
      <category>payments</category>
      <category>api</category>
      <category>webdev</category>
    </item>
    <item>
      <title>gitignore is not a security control</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:34:31 +0000</pubDate>
      <link>https://dev.to/veristria/gitignore-is-not-a-security-control-2gok</link>
      <guid>https://dev.to/veristria/gitignore-is-not-a-security-control-2gok</guid>
      <description>&lt;p&gt;A &lt;code&gt;.env&lt;/code&gt; file in &lt;code&gt;.gitignore&lt;/code&gt; tells you one thing: the file is not in your git history. It tells you nothing about whether the value inside it is currently readable by anyone who loads your site.&lt;/p&gt;

&lt;p&gt;Those are different questions, and only the second one describes what an attacker actually does. Nobody clones your repo. They open the deployed bundle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the value actually goes
&lt;/h2&gt;

&lt;p&gt;A build reads &lt;code&gt;.env&lt;/code&gt; and then makes a decision about each variable. The decision is usually made by a prefix.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Inlined into client output&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Next.js&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NEXT_PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vite&lt;/td&gt;
&lt;td&gt;&lt;code&gt;VITE_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Create React App&lt;/td&gt;
&lt;td&gt;&lt;code&gt;REACT_APP_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nuxt&lt;/td&gt;
&lt;td&gt;&lt;code&gt;NUXT_PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Astro&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SvelteKit&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PUBLIC_*&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Those prefixes are not warnings. They are &lt;strong&gt;instructions to publish&lt;/strong&gt;. A variable so named is compiled into JavaScript that ships to every visitor, by design, and it works exactly as intended.&lt;/p&gt;

&lt;p&gt;The mistake is almost never that someone misunderstood the prefix. It is that a key was moved into one during a debugging session at 2am, the bug got fixed, and it never moved back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three places the value survives after you rotate
&lt;/h2&gt;

&lt;p&gt;This is the part people miss. Rotating the key is necessary. It is not sufficient, because the old value is still sitting in artefacts nobody thinks of as artefacts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Source maps.&lt;/strong&gt; A minified bundle may not show a readable key. The &lt;code&gt;.map&lt;/code&gt; file beside it usually does — that is its entire purpose. Shipping source maps to production is common and mostly harmless, right up until the moment it is not.&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;# what an attacker runs, and takes about four seconds&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://yoursite.com/_next/static/chunks/main-&lt;span class="k"&gt;*&lt;/span&gt;.js &lt;span class="se"&gt;\n&lt;/span&gt;  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'(sk|pk|rk)_(live|test)_[A-Za-z0-9]{16,}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Previous deploys.&lt;/strong&gt; Every platform keeps old deployments addressable. A Vercel preview URL from three weeks ago serves the bundle from three weeks ago, containing the key from three weeks ago. Rotation does not reach backwards.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Build logs.&lt;/strong&gt; CI output, deploy logs and provider dashboards retain output for weeks. One &lt;code&gt;console.log&lt;/code&gt; in a failing build is enough, and failing builds are exactly when people add logging.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check that means something
&lt;/h2&gt;

&lt;p&gt;Stop asking whether the file is ignored. Ask whether the value is recoverable from what you shipped.&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;# fetch what the browser actually receives, and look in it&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; https://yoursite.com | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'/_next/static/chunks/[^"]+.js'&lt;/span&gt; &lt;span class="se"&gt;\n&lt;/span&gt;  | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-20&lt;/span&gt; &lt;span class="se"&gt;\n&lt;/span&gt;  | xargs &lt;span class="nt"&gt;-I&lt;/span&gt;&lt;span class="o"&gt;{}&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://yoursite.com{}"&lt;/span&gt; &lt;span class="se"&gt;\n&lt;/span&gt;  | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-oE&lt;/span&gt; &lt;span class="s1"&gt;'[A-Za-z0-9_-]{32,}'&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is crude and it is still more informative than reading &lt;code&gt;.gitignore&lt;/code&gt;, because it operates on the artefact rather than on the intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to do about the ones that are already out
&lt;/h2&gt;

&lt;p&gt;In order, because the order matters:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Rotate first, investigate second.&lt;/strong&gt; The window matters more than the root cause, and the root cause is still there in an hour.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Check the provider's logs for use.&lt;/strong&gt; Stripe, Supabase, OpenAI and most others will tell you whether the key was used and from where. "It leaked" and "it was used" call for very different responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete or expire old deploy previews.&lt;/strong&gt; Otherwise step 1 protected the future and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then&lt;/strong&gt; fix the prefix, move the call server-side, and add the check to CI.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The general principle is worth stating plainly, because it generalises past secrets: &lt;strong&gt;a control that operates on your source is not a control on what you ship.&lt;/strong&gt; The build stands between them, and the build is where the interesting mistakes live.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Your RLS policies were correct when you wrote them</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:34:01 +0000</pubDate>
      <link>https://dev.to/veristria/your-rls-policies-were-correct-when-you-wrote-them-310h</link>
      <guid>https://dev.to/veristria/your-rls-policies-were-correct-when-you-wrote-them-310h</guid>
      <description>&lt;p&gt;Row-level security has a property that makes it unusually easy to get wrong over time: &lt;strong&gt;a policy that is correct is indistinguishable from a policy that is wrong, until someone reads it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is no error. No failed query. A too-permissive policy returns &lt;em&gt;more&lt;/em&gt; rows, which looks exactly like a working application. That is the whole problem in one sentence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The four ways a policy stops being correct
&lt;/h2&gt;

&lt;p&gt;None of these involve anyone editing the policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. A new column arrives.&lt;/strong&gt; You wrote &lt;code&gt;USING (auth.uid() = user_id)&lt;/code&gt; and it was right. Then a migration added &lt;code&gt;organization_id&lt;/code&gt;, and rows are now reachable by a user who belongs to a different organisation but happens to match on &lt;code&gt;user_id&lt;/code&gt;. The policy did not change. Its meaning did.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;USING&lt;/code&gt; was written and &lt;code&gt;WITH CHECK&lt;/code&gt; was not.&lt;/strong&gt; These are separate halves and people routinely write one. &lt;code&gt;USING&lt;/code&gt; filters what a statement can &lt;em&gt;see&lt;/em&gt;; &lt;code&gt;WITH CHECK&lt;/code&gt; constrains what it can &lt;em&gt;write&lt;/em&gt;. A policy with only &lt;code&gt;USING&lt;/code&gt; will happily let a user &lt;code&gt;UPDATE&lt;/code&gt; a row into a state where they can no longer see it — writing data they cannot read back, into someone else's tenant.&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;-- readable only by the owner, writable into any shape&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&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;span class="c1"&gt;-- both halves&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;POLICY&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;documents&lt;/span&gt;
  &lt;span class="k"&gt;FOR&lt;/span&gt; &lt;span class="k"&gt;ALL&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;span class="k"&gt;WITH&lt;/span&gt; &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;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;&lt;strong&gt;3. A table was created without RLS at all.&lt;/strong&gt; &lt;code&gt;ALTER TABLE ... ENABLE ROW LEVEL SECURITY&lt;/code&gt; is a separate statement from &lt;code&gt;CREATE TABLE&lt;/code&gt;. A table created by a migration, a dashboard click, or a framework generator does not have it on by default. Enabling RLS with &lt;strong&gt;no policies&lt;/strong&gt; denies everything, which is safe and visible. Forgetting to enable it exposes everything, which is unsafe and invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. The service role bypasses all of it.&lt;/strong&gt; That is what it is for. The failure is not the key existing, it is the key being used from somewhere that handles end-user input — an edge function, a server action, a webhook handler that grew a query.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading a policy is not testing it
&lt;/h2&gt;

&lt;p&gt;This is the part worth internalising. You can read a policy, agree it looks right, and be wrong — because what it does depends on the current schema, the current role, and the current &lt;code&gt;auth.uid()&lt;/code&gt;, none of which are in the policy text.&lt;/p&gt;

&lt;p&gt;The test that means something is: &lt;strong&gt;connect as the role, set the claims, and count rows you should not be able to see.&lt;/strong&gt;&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;-- as an authenticated user who owns nothing in this tenant&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;claims&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'{"sub":"11111111-1111-1111-1111-111111111111"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="k"&gt;ROLE&lt;/span&gt; &lt;span class="n"&gt;authenticated&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;SELECT&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;documents&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- expect 0, not "looks fine"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that returns a non-zero number, the policy is wrong regardless of how it reads.&lt;/p&gt;

&lt;h2&gt;
  
  
  The check worth automating
&lt;/h2&gt;

&lt;p&gt;Three queries, run on every migration, catch most of it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- tables with RLS off&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;relname&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;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&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="c1"&gt;-- tables with RLS on and no policies (denies all - usually unintended)&lt;/span&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;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;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;pg_policy&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;polrelid&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;oid&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;relrowsecurity&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
 &lt;span class="k"&gt;GROUP&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;relname&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- policies with USING and no WITH CHECK on a write command&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;polrelid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;regclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;polname&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_policy&lt;/span&gt;
 &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;polwithcheck&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;polcmd&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'a'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'*'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of these need a tool. Run them in CI and fail the build.&lt;/p&gt;

&lt;p&gt;The reason drift is worth monitoring rather than auditing once is in the first sentence: nothing tells you. The schema moves, the policy stays, and the gap between them opens quietly. The only way you find out is by looking, on a schedule, at something that does not change unless you check it.&lt;/p&gt;

</description>
      <category>supabase</category>
      <category>postgres</category>
      <category>security</category>
      <category>database</category>
    </item>
    <item>
      <title>Your .env file is not the problem</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:24:36 +0000</pubDate>
      <link>https://dev.to/veristria/your-env-file-is-not-the-problem-45fp</link>
      <guid>https://dev.to/veristria/your-env-file-is-not-the-problem-45fp</guid>
      <description>&lt;h1&gt;
  
  
  Your .env file is not the problem
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://keydrift.dev/blog/why-your-env-file-is-not-the-problem" rel="noopener noreferrer"&gt;keydrift.dev&lt;/a&gt;. Syndicated here with a canonical link back to the original.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There is a reassuring ritual in every new project. You put your keys in &lt;code&gt;.env&lt;/code&gt;, you check that &lt;code&gt;.env&lt;/code&gt; is in &lt;code&gt;.gitignore&lt;/code&gt;, and you move on. The repository stays clean. Every secret scanner in CI passes.&lt;/p&gt;

&lt;p&gt;And then the key ships to every visitor who loads your site.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the prefix actually does
&lt;/h2&gt;

&lt;p&gt;Frontend build tools do not have a runtime environment in the browser. There is no &lt;code&gt;process.env&lt;/code&gt; on a user's machine. So when you write this:&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;client&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="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_KEY&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 bundler does not pass the variable through. It performs a &lt;strong&gt;textual substitution at build time&lt;/strong&gt;. The literal string value is written into the output JavaScript, and that file is served from your CDN to anyone who asks for it.&lt;/p&gt;

&lt;p&gt;This is documented, intended behaviour. &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; in Next.js and &lt;code&gt;VITE_&lt;/code&gt; in Vite both mean "inline this value into the client bundle". The prefix is the opt-in.&lt;/p&gt;

&lt;p&gt;The problem is not the mechanism. The problem is which variable ends up behind the prefix.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the wrong key gets there
&lt;/h2&gt;

&lt;p&gt;Consider a common sequence. You are building a feature that needs to read a table. The anon key returns nothing, because row-level security is doing its job. The feature does not work.&lt;/p&gt;

&lt;p&gt;The fastest way to make it work is to use the service-role key instead. It bypasses RLS entirely, and the feature immediately works. To use it in client code, it needs a &lt;code&gt;NEXT_PUBLIC_&lt;/code&gt; prefix — otherwise it is &lt;code&gt;undefined&lt;/code&gt; in the browser.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; gets created, the feature ships, and the tests pass.&lt;/p&gt;

&lt;p&gt;AI coding assistants make this sequence faster, not different. Asked to make a failing query work, the shortest correct-looking path is the elevated credential. The generated diff contains a variable name, not a secret. Nothing in review looks wrong, because in the source, nothing &lt;em&gt;is&lt;/em&gt; wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why your scanners miss it
&lt;/h2&gt;

&lt;p&gt;Almost every secret-scanning tool works on one of two things: your git history, or your working tree. Both are the wrong place to look.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The repository is clean.&lt;/strong&gt; The &lt;code&gt;.env&lt;/code&gt; was never committed. The scanner is correct when it reports nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The source is clean.&lt;/strong&gt; The code contains &lt;code&gt;process.env.NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY&lt;/code&gt; — an identifier, not a credential. There is no high-entropy string to match on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The build output is not scanned.&lt;/strong&gt; It is generated on a CI runner, uploaded to a CDN, and never inspected by anything.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The secret only exists in one place: the artefact. And nothing is looking at the artefact.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check instead
&lt;/h2&gt;

&lt;p&gt;You can do this yourself, right now, without any tooling. Open your deployed site, view source, and open one of the JavaScript chunks. Search it for &lt;code&gt;eyJ&lt;/code&gt; — the prefix of every JWT, which is what a Supabase key is. Search for &lt;code&gt;sk_live&lt;/code&gt;, &lt;code&gt;sk-&lt;/code&gt;, &lt;code&gt;AKIA&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you find something, note that the fix is not to remove the string from the bundle. &lt;strong&gt;The key is already compromised.&lt;/strong&gt; Anyone who loaded that page has it, and CDN caches and archives may still serve it after you deploy again. The fix is:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Rotate the key at the provider, immediately.&lt;/li&gt;
&lt;li&gt;Move the call that needed it to a server route, an edge function or an RPC.&lt;/li&gt;
&lt;li&gt;Give the client the publishable key and let RLS do the work it was there to do.&lt;/li&gt;
&lt;li&gt;Redeploy, then check the new bundle to confirm the value is gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Not every key in a bundle is a leak
&lt;/h2&gt;

&lt;p&gt;Worth saying clearly, because the opposite mistake is also expensive: some credentials belong in client-side JavaScript.&lt;/p&gt;

&lt;p&gt;A Supabase anon key, a Stripe publishable key, a Mapbox public token, a Sentry DSN — these are designed to be public. They have recognisable formats. Reporting them as leaks trains people to ignore the tool that reported them, which is worse than not scanning at all.&lt;/p&gt;

&lt;p&gt;The useful question is never "is there a key in the bundle". It is "is there a key in the bundle that grants more than a stranger should have".&lt;/p&gt;

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

&lt;p&gt;Git-ignoring &lt;code&gt;.env&lt;/code&gt; protects your repository. It does nothing for your bundle, because the bundle is built from the values, not from the file. If you have never looked at what your deployed JavaScript actually contains, that is the gap — and it takes about two minutes to close.&lt;/p&gt;

</description>
      <category>secrets</category>
      <category>security</category>
      <category>devops</category>
    </item>
    <item>
      <title>You issued the refund. They kept the money.</title>
      <dc:creator>Veristria</dc:creator>
      <pubDate>Fri, 21 Aug 2026 13:24:35 +0000</pubDate>
      <link>https://dev.to/veristria/you-issued-the-refund-they-kept-the-money-4e7f</link>
      <guid>https://dev.to/veristria/you-issued-the-refund-they-kept-the-money-4e7f</guid>
      <description>&lt;h1&gt;
  
  
  You issued the refund. They kept the money.
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;Originally published on &lt;a href="https://feeguard.dev/blog/the-refund-that-only-costs-the-platform" rel="noopener noreferrer"&gt;feeguard.dev&lt;/a&gt;. Syndicated here with a canonical link back to the original.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A customer asks for their money back. You refund the charge. Stripe returns the funds, the customer is satisfied, the support ticket closes.&lt;/p&gt;

&lt;p&gt;The connected account keeps the transfer.&lt;/p&gt;

&lt;p&gt;Nothing in that sequence is a bug. Stripe did exactly what it was asked to do. But the platform is now down the full transfer amount, and there is no error, no alert and no line item that says so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic
&lt;/h2&gt;

&lt;p&gt;A single Connect charge involves three movements, not one.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The customer is charged. Money arrives in the platform's balance.&lt;/li&gt;
&lt;li&gt;An application fee is taken. That is the platform's revenue.&lt;/li&gt;
&lt;li&gt;A transfer moves the rest to the connected account.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Refunding the charge reverses &lt;strong&gt;movement one only&lt;/strong&gt;. The customer's money goes back. Movements two and three are separate objects with separate lifecycles, and neither is touched unless you say so.&lt;/p&gt;

&lt;p&gt;Take a €100 charge with a €10 application fee.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Charge: +€100 to the platform.&lt;/li&gt;
&lt;li&gt;Application fee: €10 stays with the platform.&lt;/li&gt;
&lt;li&gt;Transfer: €90 to the connected account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Refund the €100 in full and, by default:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The customer gets €100 back, taken from the platform balance.&lt;/li&gt;
&lt;li&gt;The €10 application fee is refunded too, if you asked for it — otherwise it stays.&lt;/li&gt;
&lt;li&gt;The €90 transfer is &lt;strong&gt;not&lt;/strong&gt; reversed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Net position: the platform is €90 down on a transaction that was, a moment ago, €10 up.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flags that decide it
&lt;/h2&gt;

&lt;p&gt;Two parameters control this, and their defaults are the ones that cost money.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;refund_application_fee&lt;/code&gt; decides whether the platform gives back its own fee. Defaulting to &lt;code&gt;false&lt;/code&gt; is usually correct for a platform-fault refund and usually wrong for a seller-fault one.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;reverse_transfer&lt;/code&gt; decides whether the connected account gives back what it received. &lt;strong&gt;This is the expensive one.&lt;/strong&gt; It defaults to &lt;code&gt;false&lt;/code&gt;, which means every refund issued without it leaves the seller paid in full.&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;await&lt;/span&gt; &lt;span class="nx"&gt;stripe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;refunds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ch_...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;refund_application_fee&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;reverse_transfer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your refund path was written by someone reading the basic refund documentation, or generated from a prompt that said "refund the charge", these flags are almost certainly absent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Partial refunds are worse
&lt;/h2&gt;

&lt;p&gt;A full refund at least has an obvious correct answer. Partial refunds require you to decide how the loss is split, and the platform bears whatever you do not explicitly allocate.&lt;/p&gt;

&lt;p&gt;Refund €40 of that €100 charge. What should happen?&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reverse €36 of the transfer and refund €4 of the application fee, keeping the 10% split intact? That is proportional, and usually what both parties expect.&lt;/li&gt;
&lt;li&gt;Reverse the full €40 from the transfer and keep the €10 fee? The seller absorbs the entire refund.&lt;/li&gt;
&lt;li&gt;Reverse nothing? The platform absorbs €40 on a transaction that earned it €10.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Stripe will do any of these. It has no view on which is right, because that is a commercial decision, not an API one. What it will not do is warn you that the third option is what your code currently implements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it never surfaces
&lt;/h2&gt;

&lt;p&gt;This class of loss has four properties that make it nearly invisible.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It succeeds.&lt;/strong&gt; Every API call returns 200. There is nothing to catch, nothing to retry, nothing to log at error level.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It is per-transaction and small.&lt;/strong&gt; €90 does not trigger anyone's finance alert. Ten thousand of them do, but by then they are spread across a year of statements.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The objects are separate.&lt;/strong&gt; The charge, the transfer, the application fee and the refund are four different records. Noticing the gap means joining them and doing arithmetic that no default dashboard performs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It expires.&lt;/strong&gt; Transfer reversals have practical limits — the connected account has to have the balance, and the further back you go the more likely it does not. Discovering a leak from March in October frequently means discovering that it is no longer recoverable.&lt;/p&gt;

&lt;p&gt;That last point is the one that matters most. This is not a problem you can defer to a quarterly reconciliation, because a quarterly reconciliation finds losses that have already aged out of recovery.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to check today
&lt;/h2&gt;

&lt;p&gt;You can do a version of this by hand. Pull your refunds from the last 90 days, and for each one ask:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Did a transfer exist for the original charge?&lt;/li&gt;
&lt;li&gt;Was a transfer reversal created against it?&lt;/li&gt;
&lt;li&gt;If the refund was partial, does the reversal amount match the split your terms of service promise?&lt;/li&gt;
&lt;li&gt;Was the application fee refunded when it should have been — or kept when it should not have been?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Any row where the answer to two is "no" is money the platform paid on the seller's behalf without deciding to. Some of it is recoverable. The oldest of it probably is not, and that is the part worth fixing the code for.&lt;/p&gt;

&lt;p&gt;Disputes have the same structure, with a shorter fuse: a lost dispute takes the charge amount &lt;em&gt;and&lt;/em&gt; a fee, and the transfer is untouched unless you reverse it.&lt;/p&gt;

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

&lt;p&gt;&lt;code&gt;reverse_transfer&lt;/code&gt; defaults to &lt;code&gt;false&lt;/code&gt;. If your refund path does not set it, and does not have a deliberate reason not to, every refund you have issued has been paid for by the platform. Check the last ninety days first — those are the ones you can still get back.&lt;/p&gt;

</description>
      <category>stripeconnect</category>
      <category>reconciliation</category>
      <category>payments</category>
    </item>
  </channel>
</rss>
