<?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: Bedirhan Elibol</title>
    <description>The latest articles on DEV Community by Bedirhan Elibol (@bedirhandev).</description>
    <link>https://dev.to/bedirhandev</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%2F4185338%2F7ae60fba-27d0-4be8-8638-20ae9c54b512.jpg</url>
      <title>DEV Community: Bedirhan Elibol</title>
      <link>https://dev.to/bedirhandev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bedirhandev"/>
    <language>en</language>
    <item>
      <title>One using (true) policy makes your whole table public</title>
      <dc:creator>Bedirhan Elibol</dc:creator>
      <pubDate>Tue, 03 Nov 2026 09:23:15 +0000</pubDate>
      <link>https://dev.to/bedirhandev/one-using-true-policy-makes-your-whole-table-public-1dg7</link>
      <guid>https://dev.to/bedirhandev/one-using-true-policy-makes-your-whole-table-public-1dg7</guid>
      <description>&lt;p&gt;You hit a permission error. The quick fix is a policy with &lt;code&gt;using (true)&lt;/code&gt;, and the error goes away. So does the protection on that table.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why one loose policy wins
&lt;/h2&gt;

&lt;p&gt;Postgres policies are permissive by default. When a table has several permissive policies for the same command, a row is visible if any one of them returns true. They are combined with OR.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"Org members read invoices"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&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="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"allow read"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first policy is correct. The second makes it irrelevant: every row passes &lt;code&gt;true&lt;/code&gt;, so anyone holding the anon key can read every invoice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Find them
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;schemaname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policyname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;roles&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt;
&lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pg_policies&lt;/span&gt;
&lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;qual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt; &lt;span class="k"&gt;or&lt;/span&gt; &lt;span class="n"&gt;with_check&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each row is a policy that lets everything through for its command and roles. Some are on purpose, like a public &lt;code&gt;countries&lt;/code&gt; table. The rest are bugs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make it a test
&lt;/h2&gt;

&lt;p&gt;Run that query in CI after your migrations and fail when it returns a table that is not on an allow list. One test covers the whole class, including the policy nobody has written yet: the next one an agent adds to make an error go away.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you need a hard limit
&lt;/h2&gt;

&lt;p&gt;Restrictive policies are combined with AND. A row has to pass at least one permissive policy and every restrictive one, so a restrictive policy caps what any permissive policy can open:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"org boundary"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;invoices&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;restrictive&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;select&lt;/span&gt;
  &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;org_id&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="n"&gt;members&lt;/span&gt; &lt;span class="k"&gt;where&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With that in place, a later &lt;code&gt;using (true)&lt;/code&gt; cannot widen reads past the org boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two more to check
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;with check (true)&lt;/code&gt; on insert or update: anyone allowed to write can write any row.&lt;/li&gt;
&lt;li&gt;A policy with no &lt;code&gt;to&lt;/code&gt; clause applies to every role, &lt;code&gt;anon&lt;/code&gt; included.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Zelsis flags &lt;code&gt;using (true)&lt;/code&gt; policies on pull requests. There is also a 14-second version of this post: &lt;a href="https://youtube.com/shorts/hHrIz9LJhD0" rel="noopener noreferrer"&gt;https://youtube.com/shorts/hHrIz9LJhD0&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Have you found one of these in a real project? I'm curious what the "temporary" reason was.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>supabase</category>
      <category>security</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Has an accessibility bug ever cost you a user?</title>
      <dc:creator>Bedirhan Elibol</dc:creator>
      <pubDate>Fri, 30 Oct 2026 13:17:06 +0000</pubDate>
      <link>https://dev.to/bedirhandev/has-an-accessibility-bug-ever-cost-you-a-user-hoo</link>
      <guid>https://dev.to/bedirhandev/has-an-accessibility-bug-ever-cost-you-a-user-hoo</guid>
      <description>&lt;p&gt;Most accessibility bugs pass every test and look fine in a demo. The first report often comes from a customer who couldn't finish checkout, and plenty never write in at all.&lt;/p&gt;

&lt;p&gt;The ones I keep seeing in pull requests: a div with onClick that Tab skips, an icon button a screen reader announces as just "button", and a label not tied to its input, so the field has no name.&lt;/p&gt;

&lt;p&gt;A quick test before a release: unplug the mouse and try to finish your main flow with only the keyboard. It takes five minutes.&lt;/p&gt;

&lt;p&gt;Has one of these ever cost you a user or a deal? And how do you catch them now: a linter, axe in CI, manual testing, or customers telling you?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>a11y</category>
      <category>react</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Four privacy bugs that get through code review</title>
      <dc:creator>Bedirhan Elibol</dc:creator>
      <pubDate>Thu, 29 Oct 2026 12:06:23 +0000</pubDate>
      <link>https://dev.to/bedirhandev/four-privacy-bugs-that-get-through-code-review-f09</link>
      <guid>https://dev.to/bedirhandev/four-privacy-bugs-that-get-through-code-review-f09</guid>
      <description>&lt;p&gt;Privacy bugs rarely break a test. The page renders, the form submits, the dashboard fills up with sessions. They show up later, in a complaint or an audit, and by then the code has been in production for months.&lt;/p&gt;

&lt;p&gt;Here are four that show up often in pull requests. Each one is small to fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. A cookie banner with no way to refuse
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;CookieBanner&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt; &lt;span class="na"&gt;className&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"cookie-banner"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;We use cookies to improve your experience.&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;p&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;acceptAll&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Accept all&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt; &lt;span class="na"&gt;href&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;"/cookies"&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Settings&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;a&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;div&lt;/span&gt;&lt;span class="p"&gt;&amp;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;GDPR and the ePrivacy rules expect refusing to be as easy as accepting. Regulators in France have fined sites for a banner where Accept is one click and Reject needs a trip through a settings page.&lt;/p&gt;

&lt;p&gt;The fix is a second button with the same weight:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;acceptAll&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Accept all&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt; &lt;span class="na"&gt;onClick&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nx"&gt;rejectAll&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;Reject all&lt;span class="p"&gt;&amp;lt;/&lt;/span&gt;&lt;span class="nt"&gt;button&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To find this in your own code, open the banner component and search for "reject", "decline" or "necessary only". If there's an Accept button and none of those words, users have no direct way to say no.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The consent cookie lives too long
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;Cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie_consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;730&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt; &lt;span class="c1"&gt;// 2 years&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CNIL recommends keeping a consent choice for 13 months at most, then asking again. Watch the units, because each library uses a different one: js-cookie's &lt;code&gt;expires&lt;/code&gt; is in days, Express &lt;code&gt;res.cookie&lt;/code&gt; takes &lt;code&gt;maxAge&lt;/code&gt; in milliseconds, and the Next.js cookies API takes &lt;code&gt;maxAge&lt;/code&gt; in seconds. Copy &lt;code&gt;maxAge: 31536000&lt;/code&gt; from a Next.js file into Express and one year becomes about eight hours. The reverse mistake turns a year into centuries.&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;Cookies&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cookie_consent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;all&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;expires&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;365&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Session replay that records form input
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;posthog&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;init&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="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;session_recording&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;maskAllInputs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&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;PostHog and Sentry Replay mask inputs by default. Someone turns masking off to debug a checkout form, the PR gets merged, and from then on the replays store addresses, phone numbers and whatever else people type. That data now sits with a third party, and your privacy policy probably doesn't list it for that purpose.&lt;/p&gt;

&lt;p&gt;Leave masking on and unmask only the fields you really need, using the tool's per-element option.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Personal data in the URL
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/onboarding?email=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Query strings end up in server logs, analytics, browser history and the Referer header sent to any third-party script on the next page. An email in the URL gets copied to all of those places.&lt;/p&gt;

&lt;p&gt;Pass it in state or in the request body, or look it up on the server from the session:&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;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/onboarding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One more you have to test by hand
&lt;/h2&gt;

&lt;p&gt;Loading analytics or ad scripts before the user has answered the banner. This one is hard to see in a diff because it depends on load order. Open the site in a private window, open the network tab, and check what loads before you click anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Catching these on the pull request
&lt;/h2&gt;

&lt;p&gt;I build Zelsis, a check that runs on every pull request. The first four patterns above are flagged there as medium findings: they show up as warnings on the PR and don't fail the check. Writing plain emails or phone numbers to logs, or sending them unmasked to an error tracker, is reported as high.&lt;/p&gt;

&lt;p&gt;Public repos scan free, and you can paste one on zelsis.com to see a result without an account.&lt;/p&gt;

&lt;p&gt;What privacy bug have you found in your own code after it shipped?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>privacy</category>
      <category>javascript</category>
      <category>gdpr</category>
    </item>
    <item>
      <title>How do you stop double charges when the user clicks Pay twice?</title>
      <dc:creator>Bedirhan Elibol</dc:creator>
      <pubDate>Thu, 29 Oct 2026 09:33:37 +0000</pubDate>
      <link>https://dev.to/bedirhandev/how-do-you-stop-double-charges-when-the-user-clicks-pay-twice-1o4n</link>
      <guid>https://dev.to/bedirhandev/how-do-you-stop-double-charges-when-the-user-clicks-pay-twice-1o4n</guid>
      <description>&lt;p&gt;I keep finding the same pattern in checkout code. The Pay button calls &lt;code&gt;fetch('/api/checkout', { method: 'POST' })&lt;/code&gt;, the button stays enabled, and nothing in the handler checks whether a request is already running. On a slow connection people click twice all the time, and the second click sends a second charge.&lt;/p&gt;

&lt;p&gt;What I do now: a pending flag with an early return, the button disabled while it's set, and an idempotency key on the Stripe call built from the order id. Webhooks get a unique constraint on the event id.&lt;/p&gt;

&lt;p&gt;What do you do? Do you rely on the button state, the server, or both? Has anyone been bitten by a retry after a timeout where the first charge had actually gone through?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>stripe</category>
      <category>discuss</category>
    </item>
    <item>
      <title>How do you catch N+1 queries before production?</title>
      <dc:creator>Bedirhan Elibol</dc:creator>
      <pubDate>Tue, 27 Oct 2026 08:52:02 +0000</pubDate>
      <link>https://dev.to/bedirhandev/how-do-you-catch-n1-queries-before-production-5e74</link>
      <guid>https://dev.to/bedirhandev/how-do-you-catch-n1-queries-before-production-5e74</guid>
      <description>&lt;p&gt;Our test database has ten rows, so every page feels instant. An N+1 only shows up once a table has real data, and by then the page is timing out.&lt;/p&gt;

&lt;p&gt;Two things have worked for me: query logging in development, where the same query repeating with only the id changing gives it away, and a test that seeds 50 rows and asserts the query count matches the count with 5.&lt;/p&gt;

&lt;p&gt;What do you use? Bullet, a DataLoader, APM traces, code review? I'm curious which of them catches the case where the loop is in a component and the query is hidden in a helper.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>database</category>
      <category>performance</category>
      <category>postgres</category>
    </item>
    <item>
      <title>What's the worst deploy-time lock you've hit?</title>
      <dc:creator>Bedirhan Elibol</dc:creator>
      <pubDate>Mon, 26 Oct 2026 16:33:38 +0000</pubDate>
      <link>https://dev.to/bedirhandev/whats-the-worst-deploy-time-lock-youve-hit-4def</link>
      <guid>https://dev.to/bedirhandev/whats-the-worst-deploy-time-lock-youve-hit-4def</guid>
      <description>&lt;p&gt;I've seen a two-second migration freeze a checkout page because a plain CREATE INDEX held a lock on a table with a few million rows. Staging never showed it, because staging had a few hundred rows.&lt;/p&gt;

&lt;p&gt;What was yours? Curious which statements bit people most: CREATE INDEX, SET NOT NULL, adding a column with a volatile default, something with foreign keys?&lt;/p&gt;

&lt;p&gt;I wrote up the three I see most often with the safe versions, if useful: &lt;a href="https://zelsis.com/guides/postgres-migration-locks" rel="noopener noreferrer"&gt;https://zelsis.com/guides/postgres-migration-locks&lt;/a&gt;&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>devops</category>
      <category>webdev</category>
    </item>
    <item>
      <title>What a release check found in well-kept open-source apps</title>
      <dc:creator>Bedirhan Elibol</dc:creator>
      <pubDate>Wed, 14 Oct 2026 18:53:11 +0000</pubDate>
      <link>https://dev.to/bedirhandev/what-a-release-check-found-in-well-kept-open-source-apps-pm6</link>
      <guid>https://dev.to/bedirhandev/what-a-release-check-found-in-well-kept-open-source-apps-pm6</guid>
      <description>&lt;p&gt;We keep a public benchmark of open-source projects that people copy from: SaaS starters, framework example apps, a few production products and some Android apps. They are well maintained and widely forked, and most of them have nothing seriously wrong. We use them to measure false alarms, so a person reads every high-severity finding on them before we count it as real.&lt;/p&gt;

&lt;p&gt;Some of those findings held up. None of them is exotic. They are the kind of thing that gets copied into a new project on day one and is still there when it ships, so here they are with the fix for each. We left out project names because several are not fixed upstream yet, and the snippets show the shape of each issue rather than the exact upstream code.&lt;/p&gt;

&lt;h2&gt;
  
  
  A fallback secret that signs real tokens
&lt;/h2&gt;

&lt;p&gt;An Express example app signs its JWTs with this:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&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;JWT_SECRET&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;superSecret&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Locally this is convenient. In production, if the variable goes missing because a deploy dropped it or someone renamed it, the app keeps running and signs tokens with a key anyone can read in the repo. Nothing errors, so nobody notices.&lt;/p&gt;

&lt;p&gt;Fail at startup instead:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;secret&lt;/span&gt; &lt;span class="o"&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;JWT_SECRET&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JWT_SECRET is not set&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A storage policy that only checks the bucket
&lt;/h2&gt;

&lt;p&gt;A test migration in a database client library has a storage policy shaped like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"avatars"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&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;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&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;for all&lt;/code&gt; covers insert, update and delete, and the condition never looks at who owns the file, so any signed-in user can overwrite or delete anyone's avatar. The same project has a &lt;code&gt;using (true)&lt;/code&gt; read policy that makes every row of a table public. In an example that may be on purpose, but example migrations are exactly what people paste into real projects.&lt;/p&gt;

&lt;p&gt;Scope write policies to the owner:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="nv"&gt;"owners update own files"&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="k"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;objects&lt;/span&gt;
  &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="k"&gt;update&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bucket_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'avatars'&lt;/span&gt; &lt;span class="k"&gt;and&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;select&lt;/span&gt; &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uid&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;owner_id&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A SECURITY DEFINER function without a search path
&lt;/h2&gt;

&lt;p&gt;A popular subscription starter creates a trigger function that runs with its owner's privileges and no fixed &lt;code&gt;search_path&lt;/code&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="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handle_new_user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="err"&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 function then resolves table names through whatever schemas the calling session puts first. If a user can create objects in one of those schemas, they can shadow a table the function writes to and run code as the owner. Pin the path and schema-qualify the tables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="k"&gt;public&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;handle_new_user&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;returns&lt;/span&gt; &lt;span class="k"&gt;trigger&lt;/span&gt;
&lt;span class="k"&gt;language&lt;/span&gt; &lt;span class="n"&gt;plpgsql&lt;/span&gt; &lt;span class="k"&gt;security&lt;/span&gt; &lt;span class="k"&gt;definer&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="n"&gt;search_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="err"&gt;$$&lt;/span&gt; &lt;span class="p"&gt;...&lt;/span&gt; &lt;span class="err"&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 same starter pins Next.js 14.2.3. That version is affected by CVE-2025-29927, where a request header lets a caller skip middleware, including any auth check that lives there. The fix is an upgrade, plus a dependency check in CI so the next advisory shows up in a pull request.&lt;/p&gt;

&lt;h2&gt;
  
  
  Containers that run as root and a database on every interface
&lt;/h2&gt;

&lt;p&gt;Several Dockerfiles in the set have no &lt;code&gt;USER&lt;/code&gt; line, so the app runs as root inside the container. A compose file publishes Postgres like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;5432:5432"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That binds to all interfaces. On a laptop it hardly matters. On a VPS without a firewall in front, the database is on the internet. Use &lt;code&gt;"127.0.0.1:5432:5432"&lt;/code&gt; unless something outside the host needs it, and give the image a non-root user:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;useradd &lt;span class="nt"&gt;--create-home&lt;/span&gt; app
&lt;span class="k"&gt;USER&lt;/span&gt;&lt;span class="s"&gt; app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  CI actions pinned to a tag
&lt;/h2&gt;

&lt;p&gt;Workflows reference third-party actions as &lt;code&gt;some-org/some-action@v3&lt;/code&gt;. Whoever controls that repo can move the tag to different code, and the job often holds a token with write access. Pin to the full commit SHA and keep the tag in a comment, so the code that runs is the code you reviewed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;some-org/some-action@&amp;lt;full-commit-sha&amp;gt;&lt;/span&gt; &lt;span class="c1"&gt;# v3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Android: cleartext traffic, exported components and a feed parser
&lt;/h2&gt;

&lt;p&gt;An open-source Android podcast app sets &lt;code&gt;android:usesCleartextTraffic="true"&lt;/code&gt; so feeds and media can load over plain HTTP. For a podcast player that is a real trade-off, since some feeds are still HTTP only, but the flag applies to every request the app makes. A network security config can allow cleartext for the hosts that need it and keep everything else on HTTPS.&lt;/p&gt;

&lt;p&gt;The same app exports a broadcast receiver and a widget that accept its own custom actions, so any other installed app can send them. If only the system needs to reach a component, a permission on it, or a separate unexported component for the app's internal actions, closes that.&lt;/p&gt;

&lt;p&gt;It also parses remote feeds with a default &lt;code&gt;SAXParserFactory&lt;/code&gt; and no DTD or external-entity settings, on XML that comes straight from the internet. Whether that is exploitable depends on the parser underneath, which is a good reason to set those features explicitly and test them on the Android versions you support.&lt;/p&gt;

&lt;p&gt;A database upgrade step builds &lt;code&gt;UPDATE ... WHERE file_url='...'&lt;/code&gt; by concatenating a file path into the string. A path with a quote in it breaks the query. That is a bug before it is a security issue, and a bound parameter fixes both.&lt;/p&gt;

&lt;h2&gt;
  
  
  A migration that locks a busy table
&lt;/h2&gt;

&lt;p&gt;One migration creates an index on an existing table without &lt;code&gt;CONCURRENTLY&lt;/code&gt;. On Postgres, a plain &lt;code&gt;CREATE INDEX&lt;/code&gt; blocks writes to the table until the build finishes. On a small table nobody notices. On a large one, the deploy that runs it stops writes for as long as the index takes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;create&lt;/span&gt; &lt;span class="k"&gt;index&lt;/span&gt; &lt;span class="n"&gt;concurrently&lt;/span&gt; &lt;span class="n"&gt;if&lt;/span&gt; &lt;span class="k"&gt;not&lt;/span&gt; &lt;span class="k"&gt;exists&lt;/span&gt; &lt;span class="n"&gt;idx_orders_user&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CONCURRENTLY&lt;/code&gt; can't run inside a transaction, so some migration tools need it in a migration of its own.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checking your own project
&lt;/h2&gt;

&lt;p&gt;None of this needs a pentest. Each issue is visible in code or config, and each fix is a few lines. The hard part is noticing before release, when nobody is reading the Dockerfile or the old migrations anymore.&lt;/p&gt;

&lt;p&gt;We build Zelsis to run that check on every pull request, across code, dependencies, migrations, containers and CI config, in most languages. Public repos get a free scan at &lt;a href="https://zelsis.com" rel="noopener noreferrer"&gt;zelsis.com&lt;/a&gt;. If you maintain one of the projects above and want the details, write to &lt;a href="mailto:contact@zelsis.com"&gt;contact@zelsis.com&lt;/a&gt; and we'll send what we found.&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>postgres</category>
      <category>android</category>
    </item>
  </channel>
</rss>
