<?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: Max</title>
    <description>The latest articles on DEV Community by Max (@orthogonalinfo).</description>
    <link>https://dev.to/orthogonalinfo</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%2F3847175%2F78878eb1-022c-4880-ba72-cde851bc87d8.png</url>
      <title>DEV Community: Max</title>
      <link>https://dev.to/orthogonalinfo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/orthogonalinfo"/>
    <language>en</language>
    <item>
      <title>Check If a Password Was Breached Without Sending It (HIBP k-Anonymity)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Tue, 07 Jul 2026 17:01:51 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/check-if-a-password-was-breached-without-sending-it-hibp-k-anonymity-5j1</link>
      <guid>https://dev.to/orthogonalinfo/check-if-a-password-was-breached-without-sending-it-hibp-k-anonymity-5j1</guid>
      <description>&lt;p&gt;A junior dev on my team once wanted to add a "check if your password was breached" feature to our signup form. His first instinct: &lt;code&gt;POST&lt;/code&gt; the plaintext password to Have I Been Pwned and warn the user if it came back dirty.&lt;/p&gt;

&lt;p&gt;I stopped him before the PR got anywhere. Sending a user's raw password to a third party to &lt;em&gt;prove&lt;/em&gt; it isn't compromised is the kind of irony that ends up in a postmortem.&lt;/p&gt;

&lt;p&gt;The good news is that HIBP solved this exact problem years ago with a technique called &lt;strong&gt;k-anonymity&lt;/strong&gt;, and it's genuinely clever. You can check any password against 900+ million breached credentials &lt;strong&gt;without ever sending the password, its full hash, or anything that identifies it.&lt;/strong&gt; Let me walk through how it works, show the actual bytes on the wire, and explain why this is one of the few "phone home" security checks I trust in a browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with a naive breach check
&lt;/h2&gt;

&lt;p&gt;The obvious design is: hash the password, send the hash, get back yes/no. But a SHA-1 hash of a password isn't anonymous. SHA-1 is fast and unsalted here, and breach corpuses are massive. If you send the full hash &lt;code&gt;5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8&lt;/code&gt;, the server — or anyone sniffing the request — can reverse it in microseconds against a rainbow table. That hash is literally the word &lt;code&gt;password&lt;/code&gt;. You've leaked the credential.&lt;/p&gt;

&lt;p&gt;You need a way to ask "is this password in your list?" where the server learns nothing useful about &lt;em&gt;which&lt;/em&gt; password you asked about. That's what k-anonymity buys you.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the range API actually works
&lt;/h2&gt;

&lt;p&gt;The trick is to send only the &lt;strong&gt;first 5 characters&lt;/strong&gt; of the SHA-1 hash. Here's the full flow for the password &lt;code&gt;password&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SHA-1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
                    └─┬─┘└──────────────┬──────────────────┘
                   prefix (5)        suffix (35)

GET https://api.pwnedpasswords.com/range/5BAA6
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You send &lt;code&gt;5BAA6&lt;/code&gt;. The server responds with every breached-hash suffix that shares that prefix — the tail 35 hex characters plus a breach count, one per line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;003D68EB55068C33ACE09247EE4C639306B:29
00658BFD1E05761042698D19D32CD9F1A8F:15
...
1E4C9B93F3F0682250B6CF8331B7EE68FD8:52372427
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last line is the one you care about. &lt;strong&gt;Your browser — not the server — scans the response&lt;/strong&gt; for your suffix &lt;code&gt;1E4C9B93F3F0682250B6CF8331B7EE68FD8&lt;/code&gt;, finds it, and reads the count: &lt;code&gt;52,372,427&lt;/code&gt;. The word "password" has appeared in 52 million breached records.&lt;/p&gt;

&lt;p&gt;The server never saw which suffix you were looking for. It handed back roughly 800–1,000 candidates and let you do the final match locally. When I hit that prefix, I got 1,977 hash suffixes back. Any one of them could have been "your" password. That's the &lt;strong&gt;anonymity set&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Doing it yourself in ~15 lines
&lt;/h2&gt;

&lt;p&gt;No API key, no rate limit worth worrying about, and CORS is wide open so this runs fine from browser JavaScript. Here's the whole thing in Python so you can see there's no magic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;pwned_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha1&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;password&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;upper&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.pwnedpasswords.com/range/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;prefix&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Mozilla/5.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;urllib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;urlopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;splitlines&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pwned_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                  &lt;span class="c1"&gt;# 52372427
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pwned_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;123456&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                     &lt;span class="c1"&gt;# 210461208
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pwned_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;correcthorsebatterystaple&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# 4173
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pwned_count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;xK9#mQ2vLp8$wZ4nR7tB&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;       &lt;span class="c1"&gt;# 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those are real numbers I pulled today, not made up. A couple are worth sitting with. &lt;code&gt;123456&lt;/code&gt; shows up &lt;strong&gt;210 million&lt;/strong&gt; times — the single most breached string on the internet. And the famous XKCD passphrase &lt;code&gt;correcthorsebatterystaple&lt;/code&gt;? Pwned &lt;strong&gt;4,173&lt;/strong&gt; times. The moment a password becomes advice, it becomes a dictionary entry. Randomness is the only thing that keeps you at zero.&lt;/p&gt;

&lt;p&gt;The JavaScript version is nearly identical, using the built-in &lt;code&gt;crypto.subtle.digest("SHA-1", ...)&lt;/code&gt;:&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="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pwnedCount&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;buf&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;subtle&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;digest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;SHA-1&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;TextEncoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;padStart&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&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;toUpperCase&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;prefix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;hash&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&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;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`https://api.pwnedpasswords.com/range/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;prefix&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&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;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;text&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;line&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;body&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="se"&gt;\n&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="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&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;s&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;count&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&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="mi"&gt;0&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;This is exactly the kind of thing &lt;code&gt;SubtleCrypto&lt;/code&gt; is good at — unlike MD5, which the Web Crypto API flatly refuses to compute.&lt;/p&gt;

&lt;h2&gt;
  
  
  The padding option most people miss
&lt;/h2&gt;

&lt;p&gt;There's a subtle leak in the basic scheme. Response sizes vary — a prefix might return 400 suffixes or 1,200. A network observer counting bytes can sometimes narrow down which prefix you requested, and popular prefixes correlate with common passwords.&lt;/p&gt;

&lt;p&gt;HIBP added a fix: send the header &lt;code&gt;Add-Padding: true&lt;/code&gt; and the server pads every response with a random number of fake, zero-count entries.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="s2"&gt;"https://api.pwnedpasswords.com/range/5BAA6"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
     &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Add-Padding: true"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"User-Agent: Mozilla/5.0"&lt;/span&gt;

&lt;span class="c"&gt;# ...real entries...&lt;/span&gt;
DBB7A2BC0BCFAC5BF1E8B50FFC97A118303:0   ← decoy
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When I added the header, the response grew from 1,977 to 2,122 lines — 144 of them decoys with a count of &lt;code&gt;:0&lt;/code&gt;. Your matching code already ignores anything with count zero, so the padding is invisible to you but blows up traffic-analysis attacks. If you're building this into a product, &lt;strong&gt;turn padding on.&lt;/strong&gt; It costs a few KB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why browser-only matters here
&lt;/h2&gt;

&lt;p&gt;k-anonymity protects you from the HIBP server, but it doesn't protect you from &lt;em&gt;your own backend&lt;/em&gt; if you route the check through it. The cleanest design is to hash and query entirely client-side, so the plaintext never leaves the tab.&lt;/p&gt;

&lt;p&gt;That's the same principle behind every tool I build: the file, the password, the hash never touches a server I control. I wrote up &lt;a href="https://orthogonal.info/check-breached-password-without-sending-hibp-k-anonymity/" rel="noopener noreferrer"&gt;the full teardown with a live browser demo here&lt;/a&gt; if you want to poke at the bytes yourself.&lt;/p&gt;




&lt;p&gt;One thing this whole exercise reframed for me: a "check if breached" feature is only as trustworthy as its data flow. The clever part isn't the hashing — it's that the &lt;em&gt;question itself&lt;/em&gt; is anonymized.&lt;/p&gt;

&lt;p&gt;What's the sketchiest "we'll just send it to a third party to be safe" security feature you've seen ship? I've got a couple of horror stories.&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>python</category>
    </item>
    <item>
      <title>Pasting a JWT Into an Online Base64 Decoder Is a Credential Leak — Here's the Browser-Only Fix</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Tue, 30 Jun 2026 17:02:29 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/pasting-a-jwt-into-an-online-base64-decoder-is-a-credential-leak-heres-the-browser-only-fix-lmo</link>
      <guid>https://dev.to/orthogonalinfo/pasting-a-jwt-into-an-online-base64-decoder-is-a-credential-leak-heres-the-browser-only-fix-lmo</guid>
      <description>&lt;p&gt;Last month I watched a teammate debug an auth bug by pasting a production JWT into the first "base64 decode online" result on Google. The token was a live bearer credential — valid for another 50 minutes, signed for our payments service. He pasted it into a text box on a server he'd never heard of, hit decode, and read the payload. The bug got fixed. The token also got handed to a stranger's web server, where it sat in request logs neither of us will ever see.&lt;/p&gt;

&lt;p&gt;That's the quiet problem with online base64 tools, and it's worth understanding &lt;em&gt;why&lt;/em&gt; it happens — plus the two things even experienced devs get wrong when they try to skip the tool and just use the browser console.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why pasting a JWT into a random decoder is a credential leak
&lt;/h2&gt;

&lt;p&gt;A JWT is three base64url segments joined by dots: header, payload, signature. The first two decode to plain JSON. The third is the HMAC or RSA signature. Decoding it doesn't "crack" anything — but that misses the point: &lt;strong&gt;the whole string is the credential.&lt;/strong&gt; If your decoder runs server-side, you just POSTed a working bearer token to a third party.&lt;/p&gt;

&lt;p&gt;Most "free online" decoders &lt;em&gt;are&lt;/em&gt; server-side. You can tell because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;they still work with JavaScript disabled, or&lt;/li&gt;
&lt;li&gt;the network tab shows a request firing on every keystroke.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some are honest hobby projects. Some are ad-funded and log everything. You have no way to know which, and "it's probably fine" is not a security model when the input is a live session token, an API key in a config blob, or a base64-encoded &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;The fix isn't a better-behaved server. It's not using a server at all. &lt;code&gt;atob&lt;/code&gt;, &lt;code&gt;btoa&lt;/code&gt;, and &lt;code&gt;TextDecoder&lt;/code&gt; have shipped in every browser for years — the decode can happen entirely in your tab, with zero requests carrying your data. Open the network tab while a properly client-side tool decodes a 2 MB file and you'll see exactly that: nothing leaves.&lt;/p&gt;

&lt;h2&gt;
  
  
  The URL-safe gotcha that breaks the browser console
&lt;/h2&gt;

&lt;p&gt;Here's the part that trips up even experienced devs. You might think "I don't need a tool, I'll just run &lt;code&gt;atob()&lt;/code&gt; in the console." Try it on a real JWT segment and watch it throw.&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;// A JWT payload segment is base64URL, not standard base64&lt;/span&gt;
&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;eyJzdWIiOiIxMjM0NTY3ODkwIn0&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;// ok here&lt;/span&gt;

&lt;span class="c1"&gt;// But base64url uses - and _ instead of + and /&lt;/span&gt;
&lt;span class="c1"&gt;// and usually drops the trailing = padding:&lt;/span&gt;
&lt;span class="nf"&gt;atob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;-_-_Pj_4&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;// Uncaught DOMException: Failed to execute 'atob':&lt;/span&gt;
&lt;span class="c1"&gt;// The string to be decoded is not correctly encoded.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Base64url swaps two characters from the standard alphabet: &lt;code&gt;+&lt;/code&gt; becomes &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt; becomes &lt;code&gt;_&lt;/code&gt;, and trailing &lt;code&gt;=&lt;/code&gt; padding is usually dropped. The browser's &lt;code&gt;atob&lt;/code&gt; only understands the &lt;em&gt;standard&lt;/em&gt; alphabet with correct padding, so it rejects exactly the strings you most often need to decode — JWTs, OAuth &lt;code&gt;state&lt;/code&gt; params, anything that travels in a URL.&lt;/p&gt;

&lt;p&gt;The fix is a normalization step on every decode:&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;function&lt;/span&gt; &lt;span class="nf"&gt;decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;str&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="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;\s&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="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&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;4&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;      &lt;span class="c1"&gt;// re-add stripped padding&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&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;n&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;decodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;escape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;  &lt;span class="c1"&gt;// UTF-8 aware&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="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;                            &lt;span class="c1"&gt;// fall back to raw bytes&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Tested against the canonical jwt.io token: the header decodes to &lt;code&gt;{"alg":"HS256","typ":"JWT"}&lt;/code&gt; and the payload to &lt;code&gt;{"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022}&lt;/code&gt; — and the &lt;em&gt;same input&lt;/em&gt; throws &lt;code&gt;Invalid character&lt;/code&gt; through bare &lt;code&gt;atob&lt;/code&gt;. That &lt;code&gt;replace&lt;/code&gt;/repad dance is the whole reason a dedicated decode beats the raw console call.&lt;/p&gt;

&lt;h2&gt;
  
  
  The UTF-8 trap, and the emoji that proves it
&lt;/h2&gt;

&lt;p&gt;The second thing naive decoders get wrong is multi-byte text. &lt;code&gt;atob&lt;/code&gt; hands you a &lt;em&gt;binary string&lt;/em&gt; where each character is one byte. Decode UTF-8 content like &lt;code&gt;café&lt;/code&gt; and a naive reader shows you &lt;code&gt;cafÃ©&lt;/code&gt;, because it's reading the two UTF-8 bytes for &lt;code&gt;é&lt;/code&gt; as two separate Latin-1 characters.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;decodeURIComponent(escape(raw))&lt;/code&gt; trick handles it: &lt;code&gt;escape&lt;/code&gt; percent-encodes each byte, then &lt;code&gt;decodeURIComponent&lt;/code&gt; reads those percent groups as UTF-8. Encoding runs the mirror image:&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="nf"&gt;btoa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;unescape&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;encodeURIComponent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It's an old idiom, but it round-trips correctly, and the &lt;code&gt;try/catch&lt;/code&gt; means raw binary that isn't valid UTF-8 falls through untouched instead of corrupting silently. I ran a string of emoji through encode → decode and got byte-identical output the other side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where browser-only beats the command line too
&lt;/h2&gt;

&lt;p&gt;I live in a terminal, so I'll be honest about when &lt;code&gt;base64 -d&lt;/code&gt; is the right call: scripting, pipes, CI. But three things push me back to a browser tab more often than I expected.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Auto-detect direction.&lt;/strong&gt; Paste base64, it decodes; paste plain text, it encodes. No flipping a &lt;code&gt;-d&lt;/code&gt; flag and re-running.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-line mode.&lt;/strong&gt; A file of base64 strings, one per line, decodes row-by-row instead of being treated as one stream. macOS &lt;code&gt;base64&lt;/code&gt; won't do that without a &lt;code&gt;while read&lt;/code&gt; loop.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Image preview.&lt;/strong&gt; Paste a &lt;code&gt;data:image/png;base64,...&lt;/code&gt; URI and render the actual image — the fastest way to sanity-check an inline asset.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And if it's a PWA with a service worker, it works offline: load it once, kill wifi, still decodes — exactly the posture you want for a tool that touches secrets.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest limitation
&lt;/h2&gt;

&lt;p&gt;Base64 is &lt;strong&gt;encoding, not encryption.&lt;/strong&gt; Decoding a JWT shows you the claims; it does not verify the signature or let you forge one. If you need to validate signatures or test signing keys, that's a different job — reach for a proper JWT library, not a base64 tool.&lt;/p&gt;

&lt;p&gt;If you want a client-side one to poke at, I put the working version of all of the above (base64url normalization, UTF-8 round-trip, per-line, image preview, offline) into a free browser-only tool: &lt;a href="https://base64lab.orthogonal.info/" rel="noopener noreferrer"&gt;Base64Lab&lt;/a&gt;. Network tab stays empty by construction. Full write-up with the byte-level details is &lt;a href="https://orthogonal.info/base64lab-decode-jwt-base64url-browser-only/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's the worst credential you've watched someone paste into a random online tool? I'll start: a live Stripe restricted key, into a "JSON pretty print" site, on a shared screen.&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>Reverse-Engineering SEC EDGAR's Full-Text Search API (One Undocumented GET Request)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Tue, 23 Jun 2026 17:04:58 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/reverse-engineering-sec-edgars-full-text-search-api-one-undocumented-get-request-4ie6</link>
      <guid>https://dev.to/orthogonalinfo/reverse-engineering-sec-edgars-full-text-search-api-one-undocumented-get-request-4ie6</guid>
      <description>&lt;p&gt;The official SEC EDGAR full-text search box is great if you're a human clicking around. It's useless if you want to pull 200 filings that mention "going concern" into a script.&lt;/p&gt;

&lt;p&gt;So I opened the network tab, watched what the search page actually calls, and rebuilt the request myself. It turns out the entire thing runs on one &lt;strong&gt;undocumented GET request&lt;/strong&gt; that returns clean Elasticsearch JSON. No API key, no signup, no OAuth dance. The SEC quietly shipped one of the better free financial-data APIs and never put a docs page on it.&lt;/p&gt;

&lt;p&gt;Here's the exact request, the response fields nobody explains, and the gotchas that cost me an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint and its real parameters
&lt;/h2&gt;

&lt;p&gt;The page is a thin React front end. Every search fires a GET to &lt;code&gt;https://efts.sec.gov/LATEST/search-index&lt;/code&gt; and gets back raw Elasticsearch JSON.&lt;/p&gt;

&lt;p&gt;One trap before you copy anything: &lt;strong&gt;the path casing matters.&lt;/strong&gt; &lt;code&gt;/LATEST/&lt;/code&gt; is uppercase; a lowercase &lt;code&gt;/latest/&lt;/code&gt; 404s.&lt;/p&gt;

&lt;p&gt;The query parameters that actually do something:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;q&lt;/code&gt; — the search term. Wrap a phrase in URL-encoded double quotes (&lt;code&gt;%22climate+risk%22&lt;/code&gt;) for an exact match, or it tokenizes into an OR search.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;forms&lt;/code&gt; — comma-separated filing types: &lt;code&gt;10-K&lt;/code&gt;, &lt;code&gt;8-K&lt;/code&gt;, &lt;code&gt;SC 13D&lt;/code&gt;, etc. Leave it off to search everything.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;startdt&lt;/code&gt; and &lt;code&gt;enddt&lt;/code&gt; — date bounds in &lt;code&gt;YYYY-MM-DD&lt;/code&gt;. Both required if you want a window.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;from&lt;/code&gt; — pagination offset.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ciks&lt;/code&gt; — restrict to a specific company by its zero-padded CIK number.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A complete request:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="s2"&gt;"your-app your-email@example.com"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://efts.sec.gov/LATEST/search-index?q=%22machine+learning%22&amp;amp;forms=8-K&amp;amp;startdt=2026-01-01&amp;amp;enddt=2026-06-01"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The User-Agent header is not optional.&lt;/strong&gt; SEC's fair-access policy rejects requests with a generic or empty agent — you'll get a 403. Put your app name and a contact email in there. I learned this the hard way after my first ten curls returned nothing but an HTML block page.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two fields that unlock everything
&lt;/h2&gt;

&lt;p&gt;The response is the Elasticsearch envelope, untouched. A single hit looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0001193125-26-032000:ionq-ex99_2.htm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"_source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ciks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0001824920"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"display_names"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"IonQ, Inc.  (IONQ)  (CIK 0001824920)"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"root_forms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"8-K"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"form"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"8-K"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"file_date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-01-30"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"adsh"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0001193125-26-032000"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"file_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"EX-99.2"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two fields do all the work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;_id&lt;/code&gt;&lt;/strong&gt; is &lt;code&gt;{accession}:{filename}&lt;/code&gt;. Split on the colon and you can build a direct link to the document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;adsh&lt;/code&gt;&lt;/strong&gt; is the accession number — the join key you feed into the rest of EDGAR's data and XBRL endpoints to pull the full filing.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Turning a hit into a clickable filing URL means stripping the dashes from the accession number for the folder path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;filing_url&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;adsh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="sh"&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="n"&gt;cik&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hit&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ciks&lt;/span&gt;&lt;span class="sh"&gt;"&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="c1"&gt;# int() drops leading zeros
&lt;/span&gt;    &lt;span class="n"&gt;folder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;adsh&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://www.sec.gov/Archives/edgar/data/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cik&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;folder&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;fname&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few &lt;code&gt;_source&lt;/code&gt; fields are worth knowing because the docs never mention them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;items&lt;/code&gt;&lt;/strong&gt; — 8-K item codes. This is the fast filter for event-driven work: &lt;code&gt;2.02&lt;/code&gt; is earnings, &lt;code&gt;5.02&lt;/code&gt; is an exec change, &lt;code&gt;1.01&lt;/code&gt; is a material agreement.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;root_forms&lt;/code&gt;&lt;/strong&gt; — use this, not &lt;code&gt;form&lt;/code&gt;, when you want amendments grouped with originals (&lt;code&gt;8-K/A&lt;/code&gt; rolls up under &lt;code&gt;8-K&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;file_date&lt;/code&gt; vs &lt;code&gt;period_ending&lt;/code&gt;&lt;/strong&gt; — filing date vs the period the filing covers. For "what was disclosed today" you want &lt;code&gt;file_date&lt;/code&gt;; for fundamentals you want &lt;code&gt;period_ending&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;display_names&lt;/code&gt;&lt;/strong&gt; — a pre-formatted &lt;code&gt;Name (TICKER) (CIK …)&lt;/code&gt; string. Regex the ticker out instead of doing a second lookup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There's also a free bonus: every response carries an &lt;code&gt;aggregations&lt;/code&gt; block with &lt;code&gt;form_filter&lt;/code&gt;, &lt;code&gt;entity_filter&lt;/code&gt;, &lt;code&gt;sic_filter&lt;/code&gt;, and &lt;code&gt;biz_states_filter&lt;/code&gt; faceted counts — whether you asked for them or not. You can build a filings dashboard's sidebar without a single extra request.&lt;/p&gt;

&lt;h2&gt;
  
  
  A scraper that actually paginates
&lt;/h2&gt;

&lt;p&gt;Pagination is the one thing that trips people up. Each request returns at most 100 documents in &lt;code&gt;hits.hits&lt;/code&gt;; there's no &lt;code&gt;size&lt;/code&gt; parameter the backend honors past that. You walk the result set with &lt;code&gt;from&lt;/code&gt;, step by 100, and watch &lt;code&gt;hits.total.value&lt;/code&gt; for when to stop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;EFTS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://efts.sec.gov/LATEST/search-index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;User-Agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;orthogonal-research max@orthogonal.info&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;forms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;startdt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enddt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_results&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;max_results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;forms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;forms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;forms&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;startdt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;startdt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;startdt&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;enddt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;   &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;enddt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;enddt&lt;/span&gt;

        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EFTS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;15&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="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;hits&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hits&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;
        &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;0.15&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# stay under ~10 req/sec
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;

&lt;span class="n"&gt;filings&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;&lt;span class="s"&gt;going concern&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;forms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;10-K&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                     &lt;span class="n"&gt;startdt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-01-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;enddt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-06-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;filings&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;file_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;form&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;display_names&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;time.sleep(0.15)&lt;/code&gt; keeps you under SEC's documented limit of ~10 requests/sec. Go faster and you'll get temporary IP blocks lasting about ten minutes. There's &lt;strong&gt;no &lt;code&gt;X-RateLimit&lt;/code&gt; header&lt;/strong&gt; to watch — the only signal is a sudden 403, so it's better to throttle up front than to detect and back off.&lt;/p&gt;

&lt;h2&gt;
  
  
  The gotchas that cost me time
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Phrase vs token search.&lt;/strong&gt; A bare &lt;code&gt;q=climate risk&lt;/code&gt; matches documents containing "climate" OR "risk" anywhere — that returned 40x more noise than I expected. The quoted form &lt;code&gt;q=%22climate risk%22&lt;/code&gt; is the exact phrase, and it's what you almost always want.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The 10,000-result ceiling.&lt;/strong&gt; Elasticsearch caps deep pagination. Once &lt;code&gt;from&lt;/code&gt; passes 10,000 the endpoint errors out. If a query has more hits than that, narrow it with a tighter date range and stitch the windows together — there's no scroll cursor exposed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Full-text only covers 2001 onward.&lt;/strong&gt; The index starts in 2001. Older filings exist in EDGAR but won't show up here; for pre-2001 you're back to the structured submissions API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It indexes exhibits, not just the main doc.&lt;/strong&gt; A single 8-K can return several hits — one per attached exhibit. Dedupe on the accession number (&lt;code&gt;adsh&lt;/code&gt;) if you only want one row per filing.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Where this fits
&lt;/h2&gt;

&lt;p&gt;I use this as the front door for a couple of projects: a script that flags new 8-K filings mentioning specific risk language, and an insider-buying alerter that cross-references full-text hits against Form 4 data. The full-text endpoint &lt;em&gt;finds&lt;/em&gt; the filings; the structured EDGAR APIs &lt;em&gt;pull&lt;/em&gt; the details.&lt;/p&gt;

&lt;p&gt;I wrote up the full field-by-field decode of the &lt;code&gt;_source&lt;/code&gt; envelope (every key in a real &lt;code&gt;forms=8-K&lt;/code&gt; response) &lt;a href="https://orthogonal.info/sec-edgar-full-text-search-api-efts-python/" rel="noopener noreferrer"&gt;here&lt;/a&gt; if you want the complete reference.&lt;/p&gt;

&lt;p&gt;The whole thing is one undocumented GET request returning clean JSON — no key, no cost. What other "human-only" search boxes are quietly sitting on a clean JSON API? I keep finding them in network tabs.&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>tutorial</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Why Math.random() Is a Security Bug in Password Generators (and the Web Crypto Fix)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 11 Jun 2026 17:04:37 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/why-mathrandom-is-a-security-bug-in-password-generators-and-the-web-crypto-fix-3li4</link>
      <guid>https://dev.to/orthogonalinfo/why-mathrandom-is-a-security-bug-in-password-generators-and-the-web-crypto-fix-3li4</guid>
      <description>&lt;p&gt;Last week I was reviewing a small auth service and found this one-liner generating password-reset tokens:&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;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;from&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;16&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;CHARS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;CHARS&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs. It produces things like &lt;code&gt;xK9$mLp2@nQ7vR4w&lt;/code&gt;. It also happens to be a real security bug.&lt;/p&gt;

&lt;p&gt;That exact pattern is the one I deliberately avoided when I built a small browser-only password generator — and the reason is worth a few hundred words, because almost every "roll your own" password snippet on the web gets it wrong in the &lt;em&gt;same&lt;/em&gt; way. Here's what's broken about &lt;code&gt;Math.random()&lt;/code&gt; for secrets, the fix, and the two gotchas that bite people who try to fix it themselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;Math.random()&lt;/code&gt; is predictable by design
&lt;/h2&gt;

&lt;p&gt;In V8 — the engine behind Chrome and Node — &lt;code&gt;Math.random()&lt;/code&gt; has used an algorithm called &lt;strong&gt;xorshift128+&lt;/strong&gt; since version 4.9.40 (late 2015). It has 128 bits of internal state, a period of 2^128 − 1, and it passes the TestU01 statistical suite. Statistically, the numbers &lt;em&gt;look&lt;/em&gt; random.&lt;/p&gt;

&lt;p&gt;But "looks random" and "unpredictable" are different properties.&lt;/p&gt;

&lt;p&gt;xorshift128+ is a &lt;strong&gt;pseudo&lt;/strong&gt;-random generator: every output is a deterministic function of that 128-bit state, and the state is recoverable. Feed enough consecutive outputs into a system of linear equations and you can solve for the internal state — there are public tools on GitHub that recover it from as few as &lt;strong&gt;64–128 consecutive&lt;/strong&gt; &lt;code&gt;Math.random()&lt;/code&gt; calls. Once an attacker has the state, every future output is known. Every "random" password you generate after that point is predictable.&lt;/p&gt;

&lt;p&gt;For a UI animation or a Monte Carlo sim, who cares. For a password, an API key, or a session token, that's the whole ballgame.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;crypto.getRandomValues()&lt;/code&gt; is the actual fix
&lt;/h2&gt;

&lt;p&gt;Browsers ship a cryptographically secure RNG (CSPRNG) through the Web Crypto API. It pulls from the OS entropy pool (&lt;code&gt;/dev/urandom&lt;/code&gt; on Linux, &lt;code&gt;BCryptGenRandom&lt;/code&gt; on Windows) and is built so that observing past output tells you nothing about future output. There's no recoverable internal state to solve for.&lt;/p&gt;

&lt;p&gt;The core is four lines:&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;function&lt;/span&gt; &lt;span class="nf"&gt;secureRandom&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;max&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;arr&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint32Array&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="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;arr&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;arr&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="o"&gt;%&lt;/span&gt; &lt;span class="nx"&gt;max&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;Read a fresh 32-bit unsigned integer from the CSPRNG, reduce it into the range you need, done. Swap &lt;code&gt;Math.random()&lt;/code&gt; for this and the prediction attack above is gone.&lt;/p&gt;

&lt;p&gt;But notice that &lt;code&gt;% max&lt;/code&gt; — that's gotcha number one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 1: modulo bias is real (but size matters)
&lt;/h2&gt;

&lt;p&gt;When you take a random integer modulo your alphabet size, the ranges usually don't divide evenly, so some characters come up more often than others. I wanted to see how bad it actually is, so I generated &lt;strong&gt;6.2 million random bytes&lt;/strong&gt; and bucketed &lt;code&gt;byte % 62&lt;/code&gt; (a typical alphanumeric set):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;expected per character: 100,000&lt;/li&gt;
&lt;li&gt;lowest-frequency char: ~96,900 hits&lt;/li&gt;
&lt;li&gt;highest-frequency char: ~121,400 hits&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;ratio: 1.25&lt;/strong&gt; — a 25% skew&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It happens because &lt;code&gt;256 % 62 = 8&lt;/code&gt;, so byte values 0–7 each give one extra shot to the first eight characters.&lt;/p&gt;

&lt;p&gt;The textbook fix is &lt;strong&gt;rejection sampling&lt;/strong&gt;: throw away any byte in the biased tail and draw again. Rejecting values ≥ 248 dropped the skew to a 1.02 ratio in my test, at the cost of discarding about 3.1% of draws.&lt;/p&gt;

&lt;p&gt;But here's the part the "always use rejection sampling" advice skips: &lt;strong&gt;the bias depends entirely on how big your random integer is relative to the alphabet.&lt;/strong&gt; If you don't read a single byte but a full &lt;code&gt;Uint32&lt;/code&gt; (range 0 to ~4.29 billion), then for a 94-character symbol set, &lt;code&gt;Uint32 % 94&lt;/code&gt; makes the favored characters more likely by roughly &lt;strong&gt;1 part in 45 million&lt;/strong&gt; — a bias of 0.0000022%.&lt;/p&gt;

&lt;p&gt;For a password, that's noise far below anything that matters. So you can skip rejection sampling on purpose and keep the code simple, &lt;em&gt;because a 32-bit draw already makes the bias irrelevant&lt;/em&gt;. If you're minting cryptographic keys, add the rejection step; for human passwords, a wide draw is enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotcha 2: the 64KB quota wall
&lt;/h2&gt;

&lt;p&gt;The second surprise showed up while running that bias test. My first attempt asked &lt;code&gt;getRandomValues()&lt;/code&gt; to fill one big buffer:&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Uint8Array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;620000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="c1"&gt;// QuotaExceededError: The requested length exceeds 65,536 bytes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;getRandomValues()&lt;/code&gt; refuses any request over &lt;strong&gt;65,536 bytes (64 KB)&lt;/strong&gt; in a single call. It's in the spec and every browser enforces it. If you're generating one 16-character password you'll never hit it, but the moment you batch-generate or fill a large buffer, you have to chunk:&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;function&lt;/span&gt; &lt;span class="nf"&gt;fillSecure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;)&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;let&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;buf&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="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRandomValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;buf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;subarray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;65536&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Undocumented in most tutorials, and a hard failure rather than a silent one — which is at least honest of it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why browser-only matters here
&lt;/h2&gt;

&lt;p&gt;A password generator that does the work &lt;strong&gt;server-side&lt;/strong&gt; is a service that has seen your password in plaintext. The only design that makes sense for a secret is to build it on the user's machine, from their OS entropy, so it never touches a network. Open dev tools, watch the Network tab while you click generate, and you should see exactly zero requests.&lt;/p&gt;

&lt;p&gt;If you want to poke at a working version, here's the &lt;a href="https://orthogonal.info/free-password-generator-online/" rel="noopener noreferrer"&gt;browser-only password generator&lt;/a&gt; I built around these exact decisions — everything runs client-side.&lt;/p&gt;

&lt;h2&gt;
  
  
  One layer is never enough
&lt;/h2&gt;

&lt;p&gt;A strong, truly-random password fixes the "guessable" problem. It does &lt;strong&gt;nothing&lt;/strong&gt; about phishing, reused credentials, or a leaked database. Generate unique passwords, store them in a real manager, and gate the important accounts with hardware 2FA. Three cheap layers beat one strong one.&lt;/p&gt;

&lt;p&gt;The lesson I keep relearning: in security, the code that "works" and the code that's &lt;em&gt;correct&lt;/em&gt; are often the same length and completely different.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Math.random()&lt;/code&gt; works. &lt;code&gt;crypto.getRandomValues()&lt;/code&gt; is correct.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;How do you handle modulo bias in your own token/ID generators — always reject, or do you size the draw so it doesn't matter? Curious what others do in practice.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>How to Compress Images From the Command Line (and in CI) — No Upload, No Account</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Tue, 09 Jun 2026 15:24:08 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/how-to-compress-images-from-the-command-line-and-in-ci-no-upload-no-account-481m</link>
      <guid>https://dev.to/orthogonalinfo/how-to-compress-images-from-the-command-line-and-in-ci-no-upload-no-account-481m</guid>
      <description>&lt;p&gt;Most "compress your images" advice ends with &lt;em&gt;"...now drag your files into this website."&lt;/em&gt; That's fine for a one-off. It's useless when you have a &lt;code&gt;/public/images&lt;/code&gt; folder with 300 PNGs, or a build step that should never ship a 4 MB hero image again.&lt;/p&gt;

&lt;p&gt;I wanted image compression that lives where the rest of my tooling lives: the terminal and CI. No upload, no account, no clicking. Here's the workflow I landed on, plus a tiny CLI I built to make it one command.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem with web-based compressors in a dev workflow
&lt;/h2&gt;

&lt;p&gt;TinyPNG, Squoosh, and friends are great tools. But in a real project they have three issues:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;They don't script.&lt;/strong&gt; You can't put "open a browser and drag files" in a &lt;code&gt;package.json&lt;/code&gt; or a GitHub Action.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They upload.&lt;/strong&gt; For a lot of teams, shipping customer/product images to a third-party server is a non-starter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;They're one-at-a-time-ish.&lt;/strong&gt; Batch + recursive folders + keeping structure is exactly the boring part you want automated.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;What you actually want: &lt;code&gt;compress ./images&lt;/code&gt; → done, locally, every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 1: raw &lt;code&gt;sharp&lt;/code&gt; in a script
&lt;/h2&gt;

&lt;p&gt;If you just want the engine, &lt;a href="https://sharp.pixelplumbing.com/" rel="noopener noreferrer"&gt;&lt;code&gt;sharp&lt;/code&gt;&lt;/a&gt; (libvips bindings) is the workhorse. A minimal batch script:&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;// compress.js&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;sharp&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;sharp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;glob&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;glob&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:path&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;node:fs/promises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;glob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;images/**/*.{jpg,jpeg,png}&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;fs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;mkdir&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dist&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;recursive&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="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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dist&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;basename&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;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extname&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="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;.webp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;sharp&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="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;resize&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;width&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;withoutEnlargement&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;span class="nf"&gt;webp&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;out&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="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="nx"&gt;out&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;This works. But you'll quickly want flags (quality, format, max-width), parallelism across cores, "don't enlarge," metadata stripping, dry-run, and preserved folder structure — and now you're maintaining a tool instead of shipping your app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 2: a tiny CLI that already does all that
&lt;/h2&gt;

&lt;p&gt;So I packaged exactly that into a small, MIT-licensed CLI called &lt;strong&gt;QuickShrink&lt;/strong&gt;. It's a thin, well-tested wrapper over &lt;code&gt;sharp&lt;/code&gt;, focused on the batch-folder workflow. Run it once with &lt;code&gt;npx&lt;/code&gt;, no global install:&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;# compress every image in ./images → ./compressed&lt;/span&gt;
npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./images
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Convert a whole folder to WebP and cap the width for web (the single most impactful thing you can do for page weight):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./photos &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; ./web &lt;span class="nt"&gt;--format&lt;/span&gt; webp &lt;span class="nt"&gt;--max-width&lt;/span&gt; 1600 &lt;span class="nt"&gt;--quality&lt;/span&gt; 80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recurse into subfolders and keep the structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./assets &lt;span class="nt"&gt;-o&lt;/span&gt; ./out &lt;span class="nt"&gt;--recursive&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Preview before you touch anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./photos &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Typical output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  ✓ out/hero.webp        1.38 MB → 42.7 KB  (-97%)
  ✓ out/sub/banner.webp  3.10 MB → 31.1 KB  (-99%)

Done: 2 ok, 0 failed.
Total: 4.47 MB → 73.8 KB  (saved 4.40 MB, 98.4%)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Flags it supports:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;-o, --out &amp;lt;dir&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Output directory (default &lt;code&gt;./compressed&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--format &amp;lt;fmt&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;jpeg&lt;/code&gt; \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--quality &amp;lt;1-100&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Encoder quality (default 80)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;--max-width&lt;/code&gt; / &lt;code&gt;--max-height&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Resize down, never up&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--recursive&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Walk subfolders&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--dry-run&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Show the plan, write nothing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Everything runs &lt;strong&gt;locally&lt;/strong&gt; — your images never leave the machine. It uses all your CPU cores and strips metadata by default.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option 3: wire it into CI
&lt;/h2&gt;

&lt;p&gt;Because it's a single command, dropping it into a GitHub Action is trivial. Here's a step that compresses everything under &lt;code&gt;public/images&lt;/code&gt; and fails loudly if compression errors:&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="c1"&gt;# .github/workflows/images.yml&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Compress images&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;paths&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;public/images/**"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;shrink&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&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;actions/checkout@v4&lt;/span&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;actions/setup-node@v4&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;node-version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;20&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Compress images&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;npx -y https://quickshrink.orthogonal.info/cli/quickshrink.tgz \&lt;/span&gt;
            &lt;span class="s"&gt;./public/images -o ./public/images --format webp --max-width 1600&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Commit if changed&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;git config user.name "image-bot"&lt;/span&gt;
          &lt;span class="s"&gt;git config user.email "bot@users.noreply.github.com"&lt;/span&gt;
          &lt;span class="s"&gt;git add -A&lt;/span&gt;
          &lt;span class="s"&gt;git diff --cached --quiet || git commit -m "chore: compress images"&lt;/span&gt;
          &lt;span class="s"&gt;git push&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now nobody on the team can accidentally ship a 4 MB screenshot again. The bot quietly WebP's and resizes on every PR that touches images.&lt;/p&gt;

&lt;h2&gt;
  
  
  A package.json shortcut
&lt;/h2&gt;

&lt;p&gt;For local use, alias it so teammates don't need to remember the URL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"scripts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"images"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx -y https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./src/assets -o ./public/img --format webp --max-width 1600"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;npm run images&lt;/code&gt; and you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about serverless / "I can't install native deps"?
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;sharp&lt;/code&gt; ships prebuilt binaries, so the CLI works in most CI runners and locally. The one place it gets awkward is constrained serverless functions or runtimes where native libs are a pain. For that case I'm building a small &lt;strong&gt;hosted compression API&lt;/strong&gt; (key-based, metered) so you can &lt;code&gt;POST&lt;/code&gt; an image and get bytes back without bundling libvips. It's in private beta — if that's your use case, there's a note + email on the &lt;a href="https://quickshrink.orthogonal.info/cli/" rel="noopener noreferrer"&gt;CLI page&lt;/a&gt; and I'd genuinely like the feedback on what limits/pricing make sense.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prefer a GUI for one-offs?
&lt;/h2&gt;

&lt;p&gt;For the occasional "just shrink this one screenshot" moment, there's a &lt;a href="https://quickshrink.orthogonal.info/" rel="noopener noreferrer"&gt;browser version&lt;/a&gt; that does the same thing client-side (the compression runs in your browser via Canvas — also no upload). But for anything repeatable, the CLI is the move.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx https://quickshrink.orthogonal.info/cli/quickshrink.tgz ./images &lt;span class="nt"&gt;--format&lt;/span&gt; webp &lt;span class="nt"&gt;--max-width&lt;/span&gt; 1600
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Local, scriptable, batch, free, MIT. That's the whole pitch. If you put it in CI, I'd love to hear how it goes — and what flag you wish it had next.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Online SQL Formatter Might Be Logging Your Database Password</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 04 Jun 2026 17:03:46 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/your-online-sql-formatter-might-be-logging-your-database-password-3n9p</link>
      <guid>https://dev.to/orthogonalinfo/your-online-sql-formatter-might-be-logging-your-database-password-3n9p</guid>
      <description>&lt;p&gt;Last month I watched a contractor paste a full Kubernetes secret manifest — base64 blobs and all — into the first "free YAML validator" that came up on Google. He just wanted to check the indentation. What he actually did was POST a production database password to a server he'd never heard of, run by people he'll never meet, with a privacy policy he didn't read.&lt;/p&gt;

&lt;p&gt;That's the part of online dev tools nobody talks about.&lt;/p&gt;

&lt;p&gt;A SQL formatter, a YAML validator, a JSON beautifier — they &lt;em&gt;feel&lt;/em&gt; disposable, like a calculator. But a huge number of them send whatever you paste to a backend for processing. If that paste contains a connection string, an API key, or a customer record, you just leaked it. No breach required. You handed it over.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "format my SQL" is a data exfiltration path
&lt;/h2&gt;

&lt;p&gt;Here's the mechanic. Server-side tools work like this: your text goes into a &lt;code&gt;&amp;lt;textarea&amp;gt;&lt;/code&gt;, JavaScript fires an HTTP request to &lt;code&gt;/api/format&lt;/code&gt;, the server runs the actual formatting, and the result comes back. Simple to build, which is exactly why so many sites do it that way.&lt;/p&gt;

&lt;p&gt;The problem is what travels in that request body.&lt;/p&gt;

&lt;p&gt;I tested a handful of popular online formatters with my browser's Network tab open. Several of them sent the &lt;strong&gt;entire input payload&lt;/strong&gt; to their own domain. One sent it to a third-party API. The query I pasted was harmless test data, but the request was real — my text left my machine.&lt;/p&gt;

&lt;p&gt;Now picture the realistic version. You're debugging a failing migration at 11pm. You copy the offending query straight out of your ORM logs to "just clean it up." That query has a hardcoded credential a teammate left in six months ago. You paste, you format, you move on.&lt;/p&gt;

&lt;p&gt;The credential is now in someone's request logs, maybe their analytics, maybe an LLM training pipeline if the tool resells data. You will never know.&lt;/p&gt;

&lt;p&gt;This isn't paranoia. It's the same threat model that makes pasting code into random pastebins a fireable offense at most security-conscious shops. We just don't apply it to "format" tools because they feel too small to matter.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to actually verify a tool is client-side
&lt;/h2&gt;

&lt;p&gt;Don't take any tool's word for it — including the one I'm about to mention. Verifying is a two-minute job and every developer should know how.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Watch the Network tab.&lt;/strong&gt; Open DevTools (F12), go to the Network panel, clear it, then paste your text and hit format. If you see a new XHR/fetch request fire with your input in the payload, the tool is server-side. If nothing happens on the network, the work is local.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;// What a server-side formatter looks like in the Network tab:
POST /api/format-sql
Request Payload:
{ "query": "SELECT * FROM users WHERE token='sk_live_...'" }

// What a client-side tool looks like:
// (nothing — no request fires when you click format)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. Kill your connection.&lt;/strong&gt; The bluntest test there is. Load the page, then turn off Wi-Fi or drop into airplane mode. If the tool still formats your text, it's running entirely in the browser. If it spins or errors, it needed a server. I do this with any tool before I trust it with anything sensitive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Check for a service worker.&lt;/strong&gt; Truly offline-capable tools register a service worker so they work with no connection at all. In DevTools, look under &lt;strong&gt;Application → Service Workers&lt;/strong&gt;. Its presence is a strong signal the developer designed for offline-first, which usually means client-side processing too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this fits in a real workflow
&lt;/h2&gt;

&lt;p&gt;A few concrete cases where I reach for browser-only tools &lt;em&gt;specifically&lt;/em&gt; because of the data:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Reviewing a teammate's config PR.&lt;/strong&gt; Diffing two Helm values files that contain registry credentials — done locally, nothing logged anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cleaning up a query from prod logs.&lt;/strong&gt; Format it to read it, without shipping whatever sensitive &lt;code&gt;WHERE&lt;/code&gt; clause it carries to a stranger's server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Validating a CI secrets file.&lt;/strong&gt; Checking that a GitHub Actions YAML parses before you commit, without exposing the encrypted values to a validation API.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On a locked-down network.&lt;/strong&gt; Some client environments block external dev-tool domains entirely. Offline-capable tools just keep working.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The broader point: treat every "paste your text here" box as a potential outbound network call until you've proven otherwise. Most of the time it's fine. The one time it isn't, it's a leaked credential you can't un-leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  The structural fix
&lt;/h2&gt;

&lt;p&gt;The fix is structural, not procedural. Don't rely on &lt;em&gt;remembering&lt;/em&gt; to scrub secrets first — use tools that physically can't send your data anywhere, because all the work happens in your tab.&lt;/p&gt;

&lt;p&gt;That's the reason I built &lt;a href="https://orthogonal.info/free-sql-formatter-online/" rel="noopener noreferrer"&gt;our formatters&lt;/a&gt; as single-file, client-side apps — a &lt;a href="https://orthogonal.info/free-sql-formatter-online/" rel="noopener noreferrer"&gt;SQL Formatter&lt;/a&gt;, a &lt;a href="https://orthogonal.info/free-yaml-validator-formatter-online/" rel="noopener noreferrer"&gt;YAML Validator&lt;/a&gt;, and a &lt;a href="https://orthogonal.info/free-diff-checker-online/" rel="noopener noreferrer"&gt;Diff Checker&lt;/a&gt; where the parsing runs in JavaScript on your device. There is no &lt;code&gt;/api/format&lt;/code&gt; endpoint. The text in your textarea never crosses the network because there's nowhere for it to go.&lt;/p&gt;

&lt;p&gt;But browser-only tools only remove &lt;em&gt;one&lt;/em&gt; exfiltration path. Defense in depth still applies: rotate the credentials that have already been pasted into who-knows-what, and put a &lt;a href="https://orthogonal.info/i-caught-14-leaked-secrets-in-my-git-history-heres-the-pre-commit-setup-that-stops-it/" rel="noopener noreferrer"&gt;pre-commit secret scanner&lt;/a&gt; in front of your repos so the hardcoded ones never ship in the first place.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;How do you handle this on your team — do you have a policy on pasting into online dev tools, or is it the wild west? Curious what threat models other people apply here.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Made an Image Compressor That Never Sees Your Images (100% Client-Side)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Fri, 29 May 2026 15:02:23 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/i-made-an-image-compressor-that-never-sees-your-images-100-client-side-309e</link>
      <guid>https://dev.to/orthogonalinfo/i-made-an-image-compressor-that-never-sees-your-images-100-client-side-309e</guid>
      <description>&lt;p&gt;Ever notice how most "free" image compressors upload your files to their servers?&lt;/p&gt;

&lt;p&gt;I got fed up with this — especially when compressing screenshots that might contain sensitive data — so I built &lt;strong&gt;QuickShrink&lt;/strong&gt;: an image compressor that runs entirely in your browser.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why client-side matters
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Privacy: Your images never leave your device&lt;/li&gt;
&lt;li&gt;Speed: No upload/download round-trip. Compression is instant&lt;/li&gt;
&lt;li&gt;Works offline: It's a PWA. Install it and use it without internet&lt;/li&gt;
&lt;li&gt;No account needed: Just drag, drop, done&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Compress JPEG/PNG/WebP with adjustable quality (1-100)&lt;/li&gt;
&lt;li&gt;Batch compress multiple images at once&lt;/li&gt;
&lt;li&gt;Convert between formats (PNG to WebP for 60-80% size reduction)&lt;/li&gt;
&lt;li&gt;Resize images with aspect ratio lock&lt;/li&gt;
&lt;li&gt;See before/after comparison with file sizes&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The tech
&lt;/h2&gt;

&lt;p&gt;Built with vanilla JavaScript + Canvas API + OffscreenWorker for non-blocking compression. The entire app is about 50KB. No React, no framework, no build step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://quickshrink.orthogonal.info" rel="noopener noreferrer"&gt;quickshrink.orthogonal.info&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Free tier: 5 compressions/day&lt;br&gt;
Pro: Unlimited + batch + format conversion ($4.99 one-time)&lt;/p&gt;

&lt;p&gt;I'd love feedback on compression quality vs file size. Is the default quality slider (80%) a good default for web images?&lt;/p&gt;

&lt;p&gt;Built this as a weekend project after TinyPNG's free tier dropped to 20 images/month. If you're compressing screenshots or blog images regularly, give it a shot.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>privacy</category>
    </item>
    <item>
      <title>Why I Stopped Uploading Images to Compression Services (And Built a Browser-Only Alternative)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 28 May 2026 17:01:55 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/why-i-stopped-uploading-images-to-compression-services-and-built-a-browser-only-alternative-1n1o</link>
      <guid>https://dev.to/orthogonalinfo/why-i-stopped-uploading-images-to-compression-services-and-built-a-browser-only-alternative-1n1o</guid>
      <description>&lt;h2&gt;
  
  
  The Problem
&lt;/h2&gt;

&lt;p&gt;Every web developer has been there: you need to compress images for a client project, so you drag them to TinyPNG or some random compression site.&lt;/p&gt;

&lt;p&gt;But then you pause. These are mockups with unreleased branding. Or screenshots with sensitive data. Or client photos under NDA.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Do you really want to upload them to a third-party server?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I started thinking about this more seriously after GDPR enforcement got real. If you're processing client images through external services, that's technically data processing you need to account for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Browser Can Do This Now
&lt;/h2&gt;

&lt;p&gt;Modern browsers ship with Canvas API, OffscreenCanvas, and WebAssembly support that makes client-side image processing genuinely fast. We're not talking about the janky Canvas-based compression from 2015 — WASM codecs for WebP and AVIF run at near-native speed now.&lt;/p&gt;

&lt;p&gt;The key insight: &lt;strong&gt;your browser is already an image processing engine&lt;/strong&gt;. It decodes every image you view. Why upload somewhere else just to re-encode it smaller?&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Built
&lt;/h2&gt;

&lt;p&gt;I made &lt;a href="https://quickshrink.orthogonal.info" rel="noopener noreferrer"&gt;QuickShrink&lt;/a&gt; — a free image compressor that runs 100% in your browser:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Drag and drop (or paste from clipboard)&lt;/li&gt;
&lt;li&gt;Images never leave your machine&lt;/li&gt;
&lt;li&gt;No accounts, no limits, no upsells&lt;/li&gt;
&lt;li&gt;Handles PNG, JPEG, WebP&lt;/li&gt;
&lt;li&gt;Batch processing supported&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The entire thing is static HTML/JS. You could literally save the page and run it offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Approach
&lt;/h2&gt;

&lt;p&gt;The compression pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read file as ArrayBuffer&lt;/li&gt;
&lt;li&gt;Decode to ImageBitmap (hardware-accelerated)&lt;/li&gt;
&lt;li&gt;Draw to OffscreenCanvas at target quality&lt;/li&gt;
&lt;li&gt;Export as compressed blob&lt;/li&gt;
&lt;li&gt;Trigger download&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For most photos, you get 60-80% size reduction at quality 0.8 with zero perceptible difference. For PNGs with transparency, re-encoding as WebP typically saves 40-60%.&lt;/p&gt;

&lt;h2&gt;
  
  
  When You Should Still Use Server-Side
&lt;/h2&gt;

&lt;p&gt;To be fair, client-side isn't always the answer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Build pipelines&lt;/strong&gt;: Use Sharp/libvips in your CI for automated optimization&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CDN transforms&lt;/strong&gt;: Cloudflare/Imgix handle format negotiation per-device&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bulk processing&lt;/strong&gt;: 10,000 images? Use a proper batch tool&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But for the everyday "I need to shrink these 5 screenshots before adding them to the README" — a browser tool is faster and more private than any upload-based service.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://quickshrink.orthogonal.info" rel="noopener noreferrer"&gt;quickshrink.orthogonal.info&lt;/a&gt; — open source, free forever, no tracking.&lt;/p&gt;

&lt;p&gt;Would love feedback from folks who've built similar tools. What codecs are you using? Anyone gotten JPEG XL working reliably in-browser yet?&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you found this useful, I write about web performance and developer tools at &lt;a href="https://orthogonal.info" rel="noopener noreferrer"&gt;orthogonal.info&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>webperf</category>
      <category>javascript</category>
      <category>tools</category>
    </item>
    <item>
      <title>How to Optimize Images for Website Speed in 2026 (Without Losing Quality)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 28 May 2026 15:03:17 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/how-to-optimize-images-for-website-speed-in-2026-without-losing-quality-56b1</link>
      <guid>https://dev.to/orthogonalinfo/how-to-optimize-images-for-website-speed-in-2026-without-losing-quality-56b1</guid>
      <description>&lt;p&gt;Images account for ~50% of total page weight on most websites. If your site loads slowly, images are almost certainly the bottleneck.&lt;/p&gt;

&lt;p&gt;After years of optimizing web performance, here's my complete workflow for image optimization in 2026.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Image Optimization Matters
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Core Web Vitals&lt;/strong&gt;: Google uses LCP (Largest Contentful Paint) as a ranking signal. Unoptimized hero images = slow LCP = lower rankings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounce rate&lt;/strong&gt;: 53% of mobile users leave if a page takes &amp;gt;3 seconds to load.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bandwidth costs&lt;/strong&gt;: Smaller images = less CDN/hosting cost.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The 4-Step Image Optimization Workflow
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Choose the Right Format
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;th&gt;Best For&lt;/th&gt;
&lt;th&gt;Browser Support&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;WebP&lt;/td&gt;
&lt;td&gt;Photos, illustrations&lt;/td&gt;
&lt;td&gt;97%+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AVIF&lt;/td&gt;
&lt;td&gt;Photos (best compression)&lt;/td&gt;
&lt;td&gt;~92%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PNG&lt;/td&gt;
&lt;td&gt;Transparency, icons&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SVG&lt;/td&gt;
&lt;td&gt;Logos, simple graphics&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Rule of thumb&lt;/strong&gt;: Use WebP for everything unless you need transparency (PNG) or have vector graphics (SVG).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Resize Before Compressing
&lt;/h3&gt;

&lt;p&gt;Never serve a 4000px image in a 800px container. Resize first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; 
  &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"image-400.webp 400w, image-800.webp 800w, image-1200.webp 1200w"&lt;/span&gt;
  &lt;span class="na"&gt;sizes=&lt;/span&gt;&lt;span class="s"&gt;"(max-width: 600px) 400px, (max-width: 1000px) 800px, 1200px"&lt;/span&gt;
  &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"image-800.webp"&lt;/span&gt;
  &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"descriptive alt text"&lt;/span&gt;
  &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt;
  &lt;span class="na"&gt;decoding=&lt;/span&gt;&lt;span class="s"&gt;"async"&lt;/span&gt;
&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Compress Aggressively
&lt;/h3&gt;

&lt;p&gt;Most images look identical at 75-80% quality. The sweet spot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Hero images&lt;/strong&gt;: 80-85% quality&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thumbnails&lt;/strong&gt;: 70-75% quality
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Background images&lt;/strong&gt;: 60-70% quality&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I use &lt;a href="https://quickshrink.orthogonal.info" rel="noopener noreferrer"&gt;QuickShrink&lt;/a&gt; for this because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It runs entirely in the browser (no upload = no privacy risk)&lt;/li&gt;
&lt;li&gt;Supports batch compression&lt;/li&gt;
&lt;li&gt;Lets you compare before/after side by side&lt;/li&gt;
&lt;li&gt;Works offline once loaded&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Lazy Load Everything Below the Fold
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;!-- Above the fold: load immediately --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"hero.webp"&lt;/span&gt; &lt;span class="na"&gt;fetchpriority=&lt;/span&gt;&lt;span class="s"&gt;"high"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;

&lt;span class="c"&gt;&amp;lt;!-- Below the fold: lazy load --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;img&lt;/span&gt; &lt;span class="na"&gt;src=&lt;/span&gt;&lt;span class="s"&gt;"gallery-1.webp"&lt;/span&gt; &lt;span class="na"&gt;loading=&lt;/span&gt;&lt;span class="s"&gt;"lazy"&lt;/span&gt; &lt;span class="na"&gt;decoding=&lt;/span&gt;&lt;span class="s"&gt;"async"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quick Wins Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Convert all JPEGs to WebP (30-50% smaller)&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;loading="lazy"&lt;/code&gt; to images below the fold&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;width&lt;/code&gt; and &lt;code&gt;height&lt;/code&gt; attributes (prevents layout shift)&lt;/li&gt;
&lt;li&gt;[ ] Use &lt;code&gt;srcset&lt;/code&gt; for responsive images&lt;/li&gt;
&lt;li&gt;[ ] Compress to 75-80% quality&lt;/li&gt;
&lt;li&gt;[ ] Add &lt;code&gt;fetchpriority="high"&lt;/code&gt; to LCP image&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Tools I Recommend
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://quickshrink.orthogonal.info" rel="noopener noreferrer"&gt;QuickShrink&lt;/a&gt;&lt;/strong&gt; — Free, browser-based, no upload needed. My go-to for quick compression.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sharp (Node.js)&lt;/strong&gt; — For build pipelines and automation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Squoosh CLI&lt;/strong&gt; — Google's tool for batch processing.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Real Results
&lt;/h2&gt;

&lt;p&gt;On a recent client site, following this workflow:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Page weight: 4.2MB → 890KB (79% reduction)&lt;/li&gt;
&lt;li&gt;LCP: 4.1s → 1.3s&lt;/li&gt;
&lt;li&gt;PageSpeed score: 62 → 94&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Use WebP format&lt;/li&gt;
&lt;li&gt;Resize to display dimensions&lt;/li&gt;
&lt;li&gt;Compress to 75-80% quality (try &lt;a href="https://quickshrink.orthogonal.info" rel="noopener noreferrer"&gt;QuickShrink&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Lazy load below-the-fold images&lt;/li&gt;
&lt;li&gt;Add width/height to prevent layout shift&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Doing just steps 1-3 typically cuts image size by 70%+.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's your image optimization workflow? Drop it in the comments — always looking for new tricks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>webperf</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>TinyPNG vs QuickShrink: Why I Switched to Client-Side Image Compression</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Wed, 27 May 2026 15:03:51 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/tinypng-vs-quickshrink-why-i-switched-to-client-side-image-compression-56b9</link>
      <guid>https://dev.to/orthogonalinfo/tinypng-vs-quickshrink-why-i-switched-to-client-side-image-compression-56b9</guid>
      <description>&lt;p&gt;Every web developer knows TinyPNG. It's been the go-to image compressor for years. But after years of using it, I started questioning: &lt;strong&gt;why am I uploading my images to someone else's server?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The Problem with Server-Side Compression
&lt;/h2&gt;

&lt;p&gt;Tools like TinyPNG, Compressor.io, and even Squoosh (partially) require you to upload images to their servers. For most casual use, that's fine. But consider:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NDA projects&lt;/strong&gt; — client mockups, unreleased product photos&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Medical/legal images&lt;/strong&gt; — patient data, case evidence&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Personal photos&lt;/strong&gt; — family photos you want smaller but private&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise work&lt;/strong&gt; — screenshots of internal dashboards, Slack messages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Every upload is a data transfer you can't take back.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Client-Side Compression?
&lt;/h2&gt;

&lt;p&gt;Instead of sending your image to a server for processing, client-side compression uses your browser's built-in Canvas API and WebP encoding to compress images &lt;strong&gt;locally&lt;/strong&gt;. The file never leaves your device.&lt;/p&gt;

&lt;p&gt;Open DevTools → Network tab → compress an image → &lt;strong&gt;zero outgoing requests&lt;/strong&gt;. That's the proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison: TinyPNG vs QuickShrink
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;TinyPNG&lt;/th&gt;
&lt;th&gt;QuickShrink&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Privacy&lt;/td&gt;
&lt;td&gt;Server-side (images uploaded)&lt;/td&gt;
&lt;td&gt;100% client-side&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Batch&lt;/td&gt;
&lt;td&gt;Yes (20 images, 5MB each)&lt;/td&gt;
&lt;td&gt;Yes (unlimited size)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WebP output&lt;/td&gt;
&lt;td&gt;No (PNG/JPEG only)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Resize&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Quality presets&lt;/td&gt;
&lt;td&gt;No (auto only)&lt;/td&gt;
&lt;td&gt;Web, Social, Email, Print&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Offline&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Yes (PWA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;API&lt;/td&gt;
&lt;td&gt;Yes ($)&lt;/td&gt;
&lt;td&gt;Coming soon&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Price&lt;/td&gt;
&lt;td&gt;Free (20/day) or 9/yr&lt;/td&gt;
&lt;td&gt;Free (5/day) or .99/mo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Compression Quality
&lt;/h2&gt;

&lt;p&gt;Let's be honest — TinyPNG's lossy PNG compression is excellent. Their algorithm (quantization + DEFLATE) produces incredibly small PNGs with minimal quality loss.&lt;/p&gt;

&lt;p&gt;QuickShrink uses Canvas API resampling + WebP encoding, which takes a different approach:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JPEG → WebP&lt;/strong&gt;: typically 40-60% smaller at equivalent quality&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PNG → WebP&lt;/strong&gt;: typically 50-80% smaller (lossy WebP vs lossless PNG)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JPEG → JPEG&lt;/strong&gt;: 20-40% smaller with quality adjustment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The tradeoff: TinyPNG gives better PNG-to-PNG results. QuickShrink wins when you convert to WebP (which you should for web use in 2024+).&lt;/p&gt;

&lt;h2&gt;
  
  
  When to Use Which
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Use TinyPNG when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need PNG output specifically&lt;/li&gt;
&lt;li&gt;Images aren't sensitive&lt;/li&gt;
&lt;li&gt;You want the absolute smallest PNG&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Use QuickShrink when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Privacy matters (NDAs, sensitive content)&lt;/li&gt;
&lt;li&gt;You want WebP output (better web performance)&lt;/li&gt;
&lt;li&gt;You need resize + compress in one step&lt;/li&gt;
&lt;li&gt;You want offline capability&lt;/li&gt;
&lt;li&gt;You don't want to create an account&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Try It
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://quickshrink.orthogonal.info" rel="noopener noreferrer"&gt;QuickShrink&lt;/a&gt; — free, no signup, works in any modern browser.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;What's your image compression workflow? Still uploading to servers, or have you gone client-side?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>performance</category>
      <category>privacy</category>
      <category>tools</category>
    </item>
    <item>
      <title>I Caught 14 Leaked Secrets in My Git History — Here is the Pre-Commit Setup That Stops It</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Tue, 26 May 2026 17:03:43 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/i-caught-14-leaked-secrets-in-my-git-history-here-is-the-pre-commit-setup-that-stops-it-df4</link>
      <guid>https://dev.to/orthogonalinfo/i-caught-14-leaked-secrets-in-my-git-history-here-is-the-pre-commit-setup-that-stops-it-df4</guid>
      <description>&lt;p&gt;Last month I ran &lt;code&gt;trufflehog&lt;/code&gt; against one of my private repos — a homelab automation project I’d never planned to open-source. It found 14 live secrets. AWS keys, a Telegram bot token, two database passwords, and a Stripe test key that still had access to customer data. All committed between 2022 and 2024, scattered across dozens of commits.&lt;/p&gt;

&lt;p&gt;The fix took me about 20 minutes. I now run two tools as pre-commit hooks that catch secrets before they ever reach &lt;code&gt;.git/objects&lt;/code&gt;. Here’s exactly how I set it up, what each tool catches that the other misses, and the one configuration mistake that will give you false confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Two Tools: git-secrets vs trufflehog
&lt;/h2&gt;

&lt;p&gt;I use both &lt;a href="https://github.com/awslabs/git-secrets" rel="noopener noreferrer"&gt;git-secrets&lt;/a&gt; and &lt;a href="https://github.com/trufflesecurity/trufflehog" rel="noopener noreferrer"&gt;trufflehog&lt;/a&gt; because they work differently and catch different things.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;git-secrets&lt;/strong&gt; is pattern-based. It ships with AWS-specific patterns out of the box (matches &lt;code&gt;AKIA[0-9A-Z]{16}&lt;/code&gt; and similar) and lets you add custom regexes. It’s fast — sub-100ms on most commits — and runs as a native git hook. The downside: it only knows what you tell it to look for.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;trufflehog&lt;/strong&gt; uses entropy detection &lt;em&gt;and&lt;/em&gt; pattern matching. It calculates Shannon entropy on strings and flags anything that looks random enough to be a key. Version 3 also verifies secrets against live APIs — it’ll actually try your AWS key against STS to confirm it’s active. This is slower (2-5 seconds per commit) but catches novel secret formats that pattern matching misses.&lt;/p&gt;

&lt;p&gt;In my 14-secret audit, git-secrets would have caught 9 of them. trufflehog caught all 14. But git-secrets has zero false positives in my workflow, while trufflehog flags about 1 false positive per week on base64-encoded config blobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting Up git-secrets as a Pre-Commit Hook
&lt;/h2&gt;

&lt;p&gt;Install it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;brew install git-secrets   # macOS
# or
git clone https://github.com/awslabs/git-secrets.git
cd git-secrets &amp;amp;&amp;amp; make install
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Register it in your repo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cd your-repo
git secrets --install
git secrets --register-aws
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;--register-aws&lt;/code&gt; flag adds patterns for AWS access keys, secret keys, and account IDs. Now add your own patterns for whatever services you use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Telegram bot tokens (numeric:alphanumeric format)
git secrets --add '[0-9]{8,10}:[A-Za-z0-9_-]{35}'

# Stripe keys
git secrets --add 'sk_(live|test)_[A-Za-z0-9]{24,}'

# Generic high-entropy passwords in connection strings
git secrets --add 'password\s*=\s*[^\s]{12,}'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Test it works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;echo "AKIAIOSFODNN7EXAMPLE" &amp;gt; test.txt
git add test.txt
git commit -m "test"
# Output: [ERROR] Matched one or more prohibited patterns
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One gotcha: &lt;code&gt;git secrets --install&lt;/code&gt; only sets up hooks in &lt;em&gt;that&lt;/em&gt; repo. For global coverage across all repos:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git secrets --install ~/.git-templates/git-secrets
git config --global init.templateDir ~/.git-templates/git-secrets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Adding trufflehog as a Pre-Commit Hook
&lt;/h2&gt;

&lt;p&gt;I use the &lt;a href="https://pre-commit.com/" rel="noopener noreferrer"&gt;pre-commit&lt;/a&gt; framework for trufflehog since it handles updates and version pinning:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# .pre-commit-config.yaml
repos:
  - repo: https://github.com/trufflesecurity/trufflehog
    rev: v3.78.1
    hooks:
      - id: trufflehog
        entry: trufflehog git file://. --since-commit HEAD --only-verified --fail
        stages: [commit, push]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;--only-verified&lt;/code&gt; flag is important. Without it, trufflehog reports every high-entropy string — UUIDs, hashes, random test data. With it, you only get alerts for secrets that are confirmed active against their respective APIs. This drops false positives from ~30/week to about 1.&lt;/p&gt;

&lt;p&gt;Install and activate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;pip install pre-commit
pre-commit install
pre-commit install --hook-type pre-push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Configuration Mistake That Gives False Confidence
&lt;/h2&gt;

&lt;p&gt;Here’s what tripped me up for months: &lt;strong&gt;git-secrets only scans staged changes by default, not the full file.&lt;/strong&gt; If you have a secret on line 5 and you modify line 50, git-secrets won’t flag it because line 5 isn’t in the diff.&lt;/p&gt;

&lt;p&gt;This matters because secrets often enter a file in one commit and stay there forever. The pre-commit hook only fires on new changes, so existing secrets remain invisible.&lt;/p&gt;

&lt;p&gt;Fix: run a full-repo scan on a schedule. I have this in a weekly cron:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Scan entire repo history
trufflehog git file:///path/to/repo --only-verified --json &amp;gt; /tmp/secrets-audit.json

# Scan all current files (not just diffs)
git secrets --scan
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I pipe the output to ntfy for notifications. If something shows up, I rotate the credential immediately and use &lt;code&gt;git filter-repo&lt;/code&gt; to purge it from history:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;git filter-repo --invert-paths --path secrets.env
# Then force-push and tell collaborators to re-clone
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What About GitHub’s Built-in Secret Scanning?
&lt;/h2&gt;

&lt;p&gt;GitHub’s secret scanning (free for public repos, paid for private) is solid but it’s a safety net, not prevention. By the time GitHub alerts you, the secret has already been pushed to a remote. If your repo was public for even 5 seconds, bots have already scraped it — I’ve seen AWS keys exploited within &lt;a href="https://www.comparitech.com/blog/information-security/github-honeypot-experiment/" rel="noopener noreferrer"&gt;4 minutes of being pushed&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Pre-commit hooks stop the secret locally. That’s the difference between “we caught it early” and “we need to rotate everything and audit CloudTrail logs.”&lt;/p&gt;

&lt;h2&gt;
  
  
  My Full .pre-commit-config.yaml
&lt;/h2&gt;

&lt;p&gt;Here’s what I run on every project now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;repos:
  - repo: https://github.com/trufflesecurity/trufflehog
    rev: v3.78.1
    hooks:
      - id: trufflehog
        entry: trufflehog git file://. --since-commit HEAD --only-verified --fail
        stages: [commit, push]

  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.4
    hooks:
      - id: gitleaks
        stages: [commit]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I actually dropped git-secrets from the pre-commit config because gitleaks covers similar patterns with better regex coverage and active maintenance. I still keep git-secrets installed globally as a backup layer — defense in depth.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Total overhead per commit:&lt;/strong&gt; ab&lt;/p&gt;

</description>
      <category>security</category>
      <category>git</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Built a Free Client-Side Image Compressor (No Upload, Works Offline)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Tue, 26 May 2026 15:02:21 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/i-built-a-free-client-side-image-compressor-no-upload-works-offline-46f6</link>
      <guid>https://dev.to/orthogonalinfo/i-built-a-free-client-side-image-compressor-no-upload-works-offline-46f6</guid>
      <description>&lt;p&gt;I got tired of uploading sensitive images to servers just to compress them. TinyPNG is great but requires server upload. Squoosh only does one image at a time.&lt;/p&gt;

&lt;p&gt;So I built &lt;strong&gt;QuickShrink&lt;/strong&gt; — a browser-based image compressor where everything happens client-side. Your images never leave your device.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client-side compression&lt;/strong&gt; — uses Canvas API + WebP encoding in-browser&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Batch processing&lt;/strong&gt; — drag multiple files, compress all at once&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;WebP output&lt;/strong&gt; — 30-50% smaller than JPEG at same quality&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resize + compress&lt;/strong&gt; — set max dimensions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality presets&lt;/strong&gt; — Web, Social Media, Email, Print&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PWA&lt;/strong&gt; — install it, works offline&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why client-side matters
&lt;/h2&gt;

&lt;p&gt;If you are compressing screenshots with credentials, internal docs, or client work — you probably do not want them hitting someone elses server. With QuickShrink, the files stay in your browser tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://quickshrink.orthogonal.info" rel="noopener noreferrer"&gt;quickshrink.orthogonal.info&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Free tier: 5 images/day. Pro (4.99/mo): unlimited + priority features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech stack
&lt;/h2&gt;

&lt;p&gt;Vanilla JS, Canvas API, Web Workers for non-blocking compression, Service Worker for offline support. No framework, loads in under 1 second.&lt;/p&gt;




&lt;p&gt;Would love feedback from fellow devs. What features would make this more useful for your workflow?&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>tools</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
