<?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>yfinance 1.6.0: Practical Python Stock Data, Live Streaming, and Equity Screening</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 27 Aug 2026 17:01:25 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/yfinance-160-practical-python-stock-data-live-streaming-and-equity-screening-18ee</link>
      <guid>https://dev.to/orthogonalinfo/yfinance-160-practical-python-stock-data-live-streaming-and-equity-screening-18ee</guid>
      <description>&lt;p&gt;I pulled yfinance stock data for the first time in 2021, back when the library was still fighting Yahoo's API changes every few months. In 2026, yfinance 1.6.0 landed on PyPI, and the gap between what it could do then and what it can do now is significant enough to revisit. This is not a documentation mirror — these are notes from working with it on a personal portfolio tracker and a couple of backtesting scripts.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Changed Worth Knowing About
&lt;/h2&gt;

&lt;p&gt;The most practically useful additions in recent releases are the live data components and the screening API. The &lt;code&gt;WebSocket&lt;/code&gt; and &lt;code&gt;AsyncWebSocket&lt;/code&gt; classes now expose real-time quote streaming. The &lt;code&gt;Screener&lt;/code&gt; and &lt;code&gt;EquityQuery&lt;/code&gt; objects let you build structured queries to filter equities without leaving Python. The &lt;code&gt;Market&lt;/code&gt; class gives you status and session info for an exchange. Together these move yfinance closer to a self-contained data layer for personal projects.&lt;/p&gt;

&lt;p&gt;One important caveat the PyPI page spells out: yfinance is an open-source tool using Yahoo's publicly available APIs, not affiliated with or endorsed by Yahoo, and their terms say the API is intended for &lt;strong&gt;personal use only&lt;/strong&gt;. If you are building anything commercial, resolve that before writing a line of code.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;yfinance
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default install now includes &lt;code&gt;curl_cffi&lt;/code&gt; as a request fallback. If that is a problem in some corporate proxies, older OS images, or constrained containers, the docs cover an alternative install path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fetching Historical Data Without Shooting Yourself in the Foot
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;Ticker&lt;/code&gt; object is the entry point for single-instrument data:&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;yfinance&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;

&lt;span class="n"&gt;msft&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Ticker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MSFT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Last 3 months of daily data
&lt;/span&gt;&lt;span class="n"&gt;hist&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3mo&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;hist&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tail&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="c1"&gt;# Specific date range
&lt;/span&gt;&lt;span class="n"&gt;hist2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;msft&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&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;end&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-08-01&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;hist2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;shape&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;history()&lt;/code&gt; returns a pandas DataFrame with Open, High, Low, Close, Volume, Dividends, and Stock Splits columns. The index is a &lt;strong&gt;timezone-aware&lt;/strong&gt; DatetimeIndex — that trips up new users who compare it against naive datetime objects. Normalize your date comparisons or the filtering silently returns wrong results.&lt;/p&gt;

&lt;p&gt;One thing I missed longer than I should have: &lt;code&gt;history()&lt;/code&gt; auto-adjusts for splits and dividends by default. If you want raw unadjusted prices — say, when cross-referencing a broker's records — pass &lt;code&gt;auto_adjust=False&lt;/code&gt;. The default is almost always right for portfolio math, but it matters when you are debugging discrepancies against another source.&lt;/p&gt;

&lt;p&gt;For multiple tickers at once, use &lt;code&gt;yf.download()&lt;/code&gt;:&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="n"&gt;tickers&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;AAPL&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;GOOGL&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;BRK-B&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;VTI&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;download&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tickers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1y&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;group_by&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ticker&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The result has a MultiIndex column structure. If you only need closing prices, &lt;code&gt;data["Close"]&lt;/code&gt; gives a clean DataFrame with one column per ticker — the shape most backtesting libraries expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Live Streaming and Where It Actually Helps
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;WebSocket&lt;/code&gt; class is genuinely new territory. For personal projects that want live quote updates without paying for a proper market data feed, it is a workable starting point:&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;yfinance&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;

&lt;span class="n"&gt;ws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;WebSocket&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;AAPL&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;MSFT&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start&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;quote&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&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;quote&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;some_condition&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;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Practical notes: the stream reflects Yahoo's quote-feed latency and hours. During pre/post-market the gaps between ticks can be longer than in regular session — do not bake hard latency assumptions into logic that processes this stream. Also test reconnection behavior before depending on it; a dropped connection mid-session should resume gracefully, but verify that on your network first.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;AsyncWebSocket&lt;/code&gt; variant is better if you are integrating into an async app — alongside FastAPI, an async queue, or asyncio orchestration it avoids threading headaches.&lt;/p&gt;

&lt;h2&gt;
  
  
  Screening Equities with EquityQuery
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;EquityQuery&lt;/code&gt; + &lt;code&gt;Screener&lt;/code&gt; combo is the most underused feature I see people miss. Instead of downloading a universe and filtering in pandas, push the filter criteria upstream:&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;from&lt;/span&gt; &lt;span class="n"&gt;yfinance&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;EquityQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Screener&lt;/span&gt;

&lt;span class="c1"&gt;# Stocks with market cap over $10B in technology sector
&lt;/span&gt;&lt;span class="n"&gt;q&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;EquityQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;and&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="nc"&gt;EquityQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;marketcap&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10_000_000_000&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="nc"&gt;EquityQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;sector&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;Technology&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;screener&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Screener&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="n"&gt;screener&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set_predefined_body&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="nf"&gt;fetch&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;results&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quotes&lt;/span&gt;&lt;span class="sh"&gt;"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API mirrors Yahoo's internal screener query structure. Some fields behave differently than their names suggest — test on a small query first and validate the output shape before building business logic on top.&lt;/p&gt;

&lt;p&gt;For backtesting infrastructure I run a weekly screener pull into a local SQLite database, then do all historical analysis offline. That keeps you out of rate-limit territory and gives reproducible inputs for strategy tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  What It Cannot Do
&lt;/h2&gt;

&lt;p&gt;yfinance does not give you tick data, Level 2 order-book depth, or historical intraday beyond what Yahoo's API exposes. For most personal finance projects and simple backtests, that is fine. Anything requiring precise intraday execution modeling needs a real market data vendor.&lt;/p&gt;

&lt;p&gt;Rate limiting is worth understanding too. Yahoo publishes no official limits, and observed behavior varies with region, time of day, and residential vs cloud IP. If you fetch in bulk — five years of daily history for a thousand symbols — add delays and handle the occasional 429 or empty response gracefully:&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;yfinance&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;fetch_with_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delay&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retries&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="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yf&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Ticker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;history&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;period&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1y&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;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;empty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Attempt &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; failed: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&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="n"&gt;delay&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A Practical Starting Project
&lt;/h2&gt;

&lt;p&gt;If you want a concrete thing to build rather than experimenting in a notebook, try a &lt;strong&gt;weekly portfolio snapshot script&lt;/strong&gt;. Once a week it downloads the last 52 weeks of price history for every position you hold, computes each position's percentage return against the index of your choice, writes to CSV, and optionally pushes a summary to a bot or local dashboard.&lt;/p&gt;

&lt;p&gt;That forces you to handle the MultiIndex DataFrame, deal with corporate actions (splits and dividends) correctly, manage missing trading days across different exchanges, and think about where you store the output. Those four problems cover most of what you hit in more complex work.&lt;/p&gt;

&lt;p&gt;yfinance 1.6.0 is a capable free data layer for personal finance work. It has real limits — not a production feed, personal-use terms — but within those limits it has grown into something genuinely useful. The screening API and live streaming components in particular are worth building time into if you have only ever used the historical-data path.&lt;/p&gt;

&lt;p&gt;What are you pulling market data with these days — yfinance, a paid vendor, or something you rolled yourself?&lt;/p&gt;

</description>
      <category>python</category>
      <category>datascience</category>
      <category>finance</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I stopped uploading client images to compressors — here's the browser-only workflow I use now</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Mon, 24 Aug 2026 15:02:05 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/i-stopped-uploading-client-images-to-compressors-heres-the-browser-only-workflow-i-use-now-34il</link>
      <guid>https://dev.to/orthogonalinfo/i-stopped-uploading-client-images-to-compressors-heres-the-browser-only-workflow-i-use-now-34il</guid>
      <description>&lt;p&gt;A while back I was prepping images for a client site. Some of the photos were under NDA — product shots that weren't public yet. And I caught myself doing what I'd done a hundred times: dragging them into an online compressor to shrink them before upload.&lt;/p&gt;

&lt;p&gt;Then it hit me. I was uploading confidential client images to a random third-party server, just to save a few hundred KB. I had no idea where those files went, how long they were kept, or who could see them.&lt;/p&gt;

&lt;p&gt;That's a bad habit, and if you build sites for clients, you probably have it too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the usual tools are a quiet risk
&lt;/h2&gt;

&lt;p&gt;Most "compress images online" tools upload your file to their server, process it there, and hand it back. That's fine for a meme. It is &lt;em&gt;not&lt;/em&gt; fine for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client photos under NDA&lt;/li&gt;
&lt;li&gt;Internal product screenshots&lt;/li&gt;
&lt;li&gt;Anything with people's faces you don't have distribution rights to&lt;/li&gt;
&lt;li&gt;Medical, legal, or financial documents saved as images&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The compression is convenient. The upload is the problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: compress in the browser, upload nothing
&lt;/h2&gt;

&lt;p&gt;Modern browsers can resize and re-encode images entirely on your machine with the Canvas API. The file never leaves your laptop. You can prove it: open DevTools → Network tab, compress an image, and watch — zero requests go out.&lt;/p&gt;

&lt;p&gt;Here's the minimal version of what's happening under the hood:&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;compress&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;quality&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;0.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;maxWidth&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1920&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;bitmap&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;createImageBitmap&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt; &lt;span class="o"&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;min&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;maxWidth&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&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;canvas&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;canvas&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bitmap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;drawImage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bitmap&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;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;width&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;height&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="nx"&gt;canvas&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toBlob&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/webp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;quality&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;// WebP: usually 60-80% smaller&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;That's the whole trick. &lt;code&gt;createImageBitmap&lt;/code&gt; decodes, canvas resizes, &lt;code&gt;toBlob&lt;/code&gt; re-encodes to WebP/JPEG. No backend. No upload. It works offline.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you don't want to wire it up yourself
&lt;/h2&gt;

&lt;p&gt;I turned this into a small tool called &lt;strong&gt;QuickShrink&lt;/strong&gt; — it's the browser-only version of TinyPNG, with presets for web/social/email/print, PNG→WebP conversion, and resize. Everything runs client-side; it's a PWA so you can install it and use it on a plane. Free, no account.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool: &lt;a href="https://quickshrink.orthogonal.info/?ref=devto-nda" rel="noopener noreferrer"&gt;https://quickshrink.orthogonal.info/?ref=devto-nda&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But honestly, whether you use my tool, &lt;a href="https://squoosh.app" rel="noopener noreferrer"&gt;Squoosh&lt;/a&gt;, or the 12 lines above — the point is the same: &lt;strong&gt;for anything confidential, compress locally.&lt;/strong&gt; The upload is the part you should be paranoid about, not the compression.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule I follow now
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Public/throwaway image → whatever tool is fastest is fine&lt;/li&gt;
&lt;li&gt;Client, NDA, faces, or docs → browser-only, upload nothing&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Takes the same amount of time. Removes an entire category of "wait, where did that file go?" risk.&lt;/p&gt;

&lt;p&gt;What's your workflow for prepping client images? Still uploading, or local-only?&lt;/p&gt;

</description>
      <category>security</category>
      <category>privacy</category>
      <category>webdev</category>
      <category>javascript</category>
    </item>
    <item>
      <title>A Matching Checksum Doesn't Prove What You Think: Integrity vs Authenticity</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 20 Aug 2026 17:03:34 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/a-matching-checksum-doesnt-prove-what-you-think-integrity-vs-authenticity-502b</link>
      <guid>https://dev.to/orthogonalinfo/a-matching-checksum-doesnt-prove-what-you-think-integrity-vs-authenticity-502b</guid>
      <description>&lt;p&gt;Last week I downloaded a CLI release and saw the familiar pair of links: a binary and its &lt;code&gt;SHA256SUMS&lt;/code&gt; file. I hashed the binary, the digest matched, and I felt safe for about five seconds. Then I noticed the binary &lt;strong&gt;and&lt;/strong&gt; its checksum came from the same server.&lt;/p&gt;

&lt;p&gt;If that server were compromised, an attacker could have swapped both. The hash would still match perfectly. It just would not answer the question I actually cared about: &lt;em&gt;did this file come from the real publisher?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That gap — &lt;strong&gt;integrity vs authenticity&lt;/strong&gt; — trips up a lot of otherwise careful developers. Here's how I reason about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a matching hash actually proves
&lt;/h2&gt;

&lt;p&gt;A cryptographic hash turns any number of bytes into a fixed-length fingerprint. SHA-256 always returns 256 bits, shown as 64 hex characters. Flip one bit of input and the output changes unpredictably.&lt;/p&gt;

&lt;p&gt;So when your computed SHA-256 equals the value a publisher lists, you have strong evidence that your local file is &lt;strong&gt;byte-for-byte identical&lt;/strong&gt; to the file the publisher hashed. That catches:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;an incomplete or resumed download&lt;/li&gt;
&lt;li&gt;disk / transfer corruption&lt;/li&gt;
&lt;li&gt;a broken or stale mirror&lt;/li&gt;
&lt;li&gt;an accidental artifact replacement&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's real and useful. It is also the &lt;em&gt;entire&lt;/em&gt; claim. A match says two byte sequences agree. It says nothing about &lt;strong&gt;who&lt;/strong&gt; produced those bytes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Integrity is not authenticity
&lt;/h2&gt;

&lt;p&gt;Picture a release page serving &lt;code&gt;tool.tar.gz&lt;/code&gt; and &lt;code&gt;SHA256SUMS&lt;/code&gt; from the same origin. An attacker who controls that origin uploads a backdoored archive, recomputes its SHA-256, and overwrites the checksum file. Your comparison passes — because both malicious artifacts agree with each other.&lt;/p&gt;

&lt;p&gt;HTTPS doesn't save you here. TLS protects the &lt;em&gt;connection&lt;/em&gt; between your browser and the server. It says nothing about whether the server, build pipeline, maintainer account, or artifact was trustworthy &lt;strong&gt;before&lt;/strong&gt; that connection began.&lt;/p&gt;

&lt;p&gt;A checksum earns its keep when the reference value arrives over an &lt;strong&gt;independent, authenticated path&lt;/strong&gt;: a signed checksum manifest, a package manager with signed metadata, a maintainer's verified channel, or a separately-controlled domain. The &lt;em&gt;independence&lt;/em&gt; matters far more than the visual length of the hash.&lt;/p&gt;

&lt;h2&gt;
  
  
  MD5 and SHA-1 need careful language
&lt;/h2&gt;

&lt;p&gt;MD5 and SHA-1 are broken for &lt;strong&gt;collision resistance&lt;/strong&gt;. In 2017, the SHAttered work (Google + CWI Amsterdam) produced two visibly different PDFs with the same SHA-1 digest. MD5 fell years earlier.&lt;/p&gt;

&lt;p&gt;But be precise about what that means, because people overstate it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;collision&lt;/strong&gt; = an attacker crafts &lt;em&gt;two&lt;/em&gt; inputs that share a hash (they control both).&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;second-preimage&lt;/strong&gt; attack = an attacker takes &lt;em&gt;your existing&lt;/em&gt; file and manufactures a different malicious file with the same digest. That's a harder, separate problem.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Collisions alone are still enough to make MD5/SHA-1 unacceptable whenever an adversary can shape &lt;em&gt;both&lt;/em&gt; artifacts (chosen-prefix collisions). Keep MD5 around only for identifying old files or matching a legacy checksum — never as proof against an attacker. SHA-256 is the sensible default; no practical SHA-256 collision is known.&lt;/p&gt;

&lt;h2&gt;
  
  
  The download check I actually run
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Download the artifact from the publisher's HTTPS release page.&lt;/li&gt;
&lt;li&gt;Find the publisher's SHA-256 value — prefer a &lt;strong&gt;signed&lt;/strong&gt; checksum manifest or an independent official channel, not the same box that served the binary.&lt;/li&gt;
&lt;li&gt;Compute the digest locally.&lt;/li&gt;
&lt;li&gt;Compare the &lt;strong&gt;full&lt;/strong&gt; 64-char digest, not the first/last few characters.&lt;/li&gt;
&lt;li&gt;If the project offers a signature, verify it as a &lt;em&gt;separate&lt;/em&gt; step. A matching checksum does not replace signature verification.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For repeatable builds, pin the expected value and fail closed:&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;# macOS&lt;/span&gt;
shasum &lt;span class="nt"&gt;-a&lt;/span&gt; 256 tool.tar.gz

&lt;span class="c"&gt;# Linux&lt;/span&gt;
&lt;span class="nb"&gt;sha256sum &lt;/span&gt;tool.tar.gz

&lt;span class="c"&gt;# PowerShell&lt;/span&gt;
Get-FileHash .&lt;span class="se"&gt;\t&lt;/span&gt;ool.zip &lt;span class="nt"&gt;-Algorithm&lt;/span&gt; SHA256
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Never paste a &lt;em&gt;shortened&lt;/em&gt; digest into CI. A full SHA-256 is only 64 characters; truncating it deliberately throws away collision resistance. Store the expected value in reviewed source control, or consume a signed manifest.&lt;/p&gt;

&lt;h2&gt;
  
  
  When a signature is the real answer
&lt;/h2&gt;

&lt;p&gt;A digital signature binds a digest to a private key, and verification proves the signer controlled that key. That's the authenticity property a bare checksum can't give you (GPG, platform code signing, or Sigstore in modern projects).&lt;/p&gt;

&lt;p&gt;The catch just moves: &lt;strong&gt;how do you trust the public key or identity?&lt;/strong&gt; A signature from an unknown key is worthless. Look for a fingerprint published on the project's established site, a verified maintainer identity, a package ecosystem's trust root, or Sigstore's identity + transparency-log checks.&lt;/p&gt;

&lt;p&gt;(And HMAC is a different thing again — it authenticates data between parties sharing a secret, which is why it's great for webhook signatures but useless for public downloads: you can't safely hand every visitor the secret.)&lt;/p&gt;

&lt;h2&gt;
  
  
  A five-second threat model
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;th&gt;Does a plain SHA-256 comparison help?&lt;/th&gt;
&lt;th&gt;Better control&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Accidental corruption&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Checksum comparison&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Broken / stale mirror&lt;/td&gt;
&lt;td&gt;Yes, with an independent reference&lt;/td&gt;
&lt;td&gt;Official checksum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network tampering&lt;/td&gt;
&lt;td&gt;Somewhat&lt;/td&gt;
&lt;td&gt;HTTPS + independent checksum&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compromised download server&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;No&lt;/strong&gt;, if it hosts both files&lt;/td&gt;
&lt;td&gt;Signed release manifest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Malicious publisher / stolen signing key&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Reproducible builds, transparency logs, key revocation&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I trust in practice
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Low-risk utility:&lt;/strong&gt; HTTPS + a SHA-256 from the official release page is fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Installer, firmware, wallet, security tool, production dependency:&lt;/strong&gt; I want a signature or signed package metadata too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;High-impact infrastructure:&lt;/strong&gt; reproducible builds or a transparency log on top.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One more privacy note: if the artifact is proprietary, contains customer data, or comes from an internal build, don't upload it to a random online checksum site just to answer a local math question — that creates a &lt;em&gt;new&lt;/em&gt; disclosure risk. I keep integrity checks browser-only for exactly that reason: I built &lt;a href="https://hashforge.orthogonal.info" rel="noopener noreferrer"&gt;HashForge&lt;/a&gt; as a client-side hasher that runs &lt;code&gt;crypto.subtle.digest()&lt;/code&gt; in your own browser (SHA-1/256/384/512, plus a local JS MD5 for legacy matching) so the file never leaves your machine. Use it when you need a fast manual check; use &lt;code&gt;sha256sum&lt;/code&gt; + a signed manifest when a build must fail closed.&lt;/p&gt;

&lt;p&gt;I wrote up the longer version — including the reproducible-builds and Sigstore angles — &lt;a href="https://orthogonal.info/sha256-checksums-integrity-authenticity-hashforge/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Keep the claim precise: &lt;strong&gt;a matching hash proves two byte sequences agree. It does not tell you who created those bytes.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What's the worst "the checksum matched, so it's safe" assumption you've seen ship — and did anything catch it before prod?&lt;/p&gt;

</description>
      <category>security</category>
      <category>devops</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>Reading a JWT Offline: How to Spot alg:none and Algorithm Confusion Before They Bite</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:03:13 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/reading-a-jwt-offline-how-to-spot-algnone-and-algorithm-confusion-before-they-bite-pl7</link>
      <guid>https://dev.to/orthogonalinfo/reading-a-jwt-offline-how-to-spot-algnone-and-algorithm-confusion-before-they-bite-pl7</guid>
      <description>&lt;p&gt;A pentester friend sent me a JWT last month with a one-line note: "spot the bug in 10 seconds." I pasted the three segments into a decoder, flipped on URL-safe decoding, and read the header. The &lt;code&gt;alg&lt;/code&gt; field said &lt;code&gt;none&lt;/code&gt;. That token had no signature at all, and the backend was accepting it. Ten seconds, exactly.&lt;/p&gt;

&lt;p&gt;Most JWT bugs aren't cryptographic. They're the kind you catch by just reading the token — if you can decode it without shipping it to a random website first. Here's how I read tokens offline and the three things I look for every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  A JWT is three Base64url blobs, not encryption
&lt;/h2&gt;

&lt;p&gt;People treat JWTs like ciphertext. They're not. A JWT is three chunks joined by dots:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIn0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
[----- header -----].[--------- payload ---------].[------------- signature -------------]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The header and payload are plain Base64url-encoded JSON. Anyone holding the token can read them. The signature is the only part that's cryptographic, and it only proves the header and payload haven't been tampered with — it does not hide anything.&lt;/p&gt;

&lt;p&gt;The catch: JWTs use &lt;em&gt;Base64url&lt;/em&gt;, not standard Base64. RFC 7515 swaps &lt;code&gt;+&lt;/code&gt; for &lt;code&gt;-&lt;/code&gt;, &lt;code&gt;/&lt;/code&gt; for &lt;code&gt;_&lt;/code&gt;, and strips the trailing &lt;code&gt;=&lt;/code&gt; padding so tokens survive inside URLs. Paste a raw JWT segment into a standard Base64 decoder and it often chokes on the missing padding or the &lt;code&gt;-_&lt;/code&gt; characters. That's why I keep a URL-safe toggle on — it undoes the substitution and re-adds padding before decoding, so each segment comes out as clean JSON.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #1: alg is "none"
&lt;/h2&gt;

&lt;p&gt;The original JWT spec allowed an &lt;code&gt;alg&lt;/code&gt; value of &lt;code&gt;none&lt;/code&gt;, meaning "this token is unsigned, trust it anyway." It was meant for cases where transport security already handled integrity. In practice it became one of the most reliable auth bypasses on the web.&lt;/p&gt;

&lt;p&gt;The attack: take a valid token, change the payload to &lt;code&gt;"role":"admin"&lt;/code&gt;, set the header to &lt;code&gt;{"alg":"none"}&lt;/code&gt;, and drop the signature entirely. Libraries that honored &lt;code&gt;none&lt;/code&gt; would accept it. CVE-2015-9235 (jsonwebtoken), CVE-2016-5431, and a long tail of copycats all trace back to this.&lt;/p&gt;

&lt;p&gt;So the first thing I decode is the header. Grab the part before the first dot and decode it:&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;header&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;segment&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;eyJhbGciOiJub&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="err"&gt;lIn&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;decoded&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"alg"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"none"&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;If you ever see &lt;code&gt;none&lt;/code&gt; in production, that's a critical finding. Your validation library should reject it outright — modern versions of most libraries do, but only if you pin the expected algorithm on the verify call.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #2: HS256 where you expected RS256
&lt;/h2&gt;

&lt;p&gt;This one is subtler and still bites people in 2026. RS256 signs with a private key and verifies with a public key. HS256 signs and verifies with the same shared secret. The algorithm-confusion attack swaps RS256 for HS256, then signs the forged token using the server's &lt;em&gt;public&lt;/em&gt; key as the HMAC secret — and the public key is, by definition, public.&lt;/p&gt;

&lt;p&gt;If a verify function is written like this, it's vulnerable:&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;// BAD: trusts whatever alg the token claims&lt;/span&gt;
&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;keyOrSecret&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the library picks the algorithm from the attacker-controlled header. The fix is to pin it:&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;// GOOD: server dictates the algorithm&lt;/span&gt;
&lt;span class="nx"&gt;jwt&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;publicKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;algorithms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;RS256&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reading the header offline tells you instantly which algorithm a token claims. If your service issues RS256 tokens but you're staring at an &lt;code&gt;HS256&lt;/code&gt; header, someone is probing you.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug #3: secrets and PII sitting in the payload
&lt;/h2&gt;

&lt;p&gt;The payload is not a secret. I've decoded production tokens and found full email addresses, internal user IDs, feature flags, and — twice — what looked like a hashed password stuffed into a custom claim. Anyone who intercepts the token, or pulls it out of a browser's localStorage, reads all of it.&lt;/p&gt;

&lt;p&gt;Decode the middle segment and actually look at what you're shipping to the client:&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="nl"&gt;"sub"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"12345"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"role"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"user"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"email"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"jane@corp.com"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"iat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1752000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"exp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1752003600&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;Check the &lt;code&gt;exp&lt;/code&gt; claim too. It's a Unix timestamp. If it's missing, the token never expires, which turns a single leaked token into permanent access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I decode offline, every time
&lt;/h2&gt;

&lt;p&gt;The obvious way to read a JWT is to paste it into one of the popular online decoders. I stopped doing that, and I think you should too.&lt;/p&gt;

&lt;p&gt;A JWT is a live credential. For as long as it hasn't expired, it &lt;em&gt;is&lt;/em&gt; the logged-in session. Pasting a production token into a third-party website means handing your auth to whatever that site's server does with the request — logging, analytics, a compromised CDN, a curious employee. The token in the RFC 7519 examples is harmless. The one from your staging environment at 2am is not.&lt;/p&gt;

&lt;p&gt;The property I want from anything touching a credential is that the decode happens entirely in the browser — no server round-trip, so the JSON never leaves your machine. You can confirm any client-side decoder does this: open the page, kill your network connection, and check that it still decodes. If it does, the token you paste to inspect stays local. (The one I built for this, &lt;a href="https://base64lab.orthogonal.info/" rel="noopener noreferrer"&gt;Base64Lab&lt;/a&gt;, works offline for exactly that reason — but the test matters more than the tool.)&lt;/p&gt;

&lt;h2&gt;
  
  
  My 30-second token triage
&lt;/h2&gt;

&lt;p&gt;When a token lands in front of me, the routine is always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Split on the dots into three parts.&lt;/li&gt;
&lt;li&gt;Decode the header (URL-safe on). Check &lt;code&gt;alg&lt;/code&gt; — reject &lt;code&gt;none&lt;/code&gt;, question anything that doesn't match what the service issues.&lt;/li&gt;
&lt;li&gt;Decode the payload. Scan for PII or secrets that shouldn't be there. Confirm &lt;code&gt;exp&lt;/code&gt; exists and is sane.&lt;/li&gt;
&lt;li&gt;Leave the signature alone — you can't verify it without the key, and you don't need to for triage.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this requires a CLI, a library, or an internet connection to a decoder that logs your input. It's reading JSON. The only trick is a decoder that understands Base64url and keeps the data on your machine.&lt;/p&gt;




&lt;p&gt;What's the worst thing you've found sitting in a JWT payload in production? I'll start: a hashed password in a custom claim, shipped to every browser. Curious what else is out there.&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>Parsing Trading Data With Regex: Test a Whole Suite of Cases at Once, Not One String</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 30 Jul 2026 17:04:01 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/parsing-trading-data-with-regex-test-a-whole-suite-of-cases-at-once-not-one-string-45p8</link>
      <guid>https://dev.to/orthogonalinfo/parsing-trading-data-with-regex-test-a-whole-suite-of-cases-at-once-not-one-string-45p8</guid>
      <description>&lt;p&gt;Last quarter I was normalizing a feed of options trade confirmations from a brokerage API. The format looked clean at first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="ld"&gt;2026-07-14&lt;/span&gt; &lt;span class="ld"&gt;09:31:02&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;AAPL&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="k"&gt;C&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;190.00&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;2&lt;/span&gt; &lt;span class="err"&gt;|&lt;/span&gt; &lt;span class="mf"&gt;3.45&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Until it wasn't. Some rows used dots instead of pipes. Some had extra whitespace. A handful dropped the seconds off the timestamp entirely. My first regex ate the happy path and &lt;em&gt;silently swallowed&lt;/em&gt; everything else — the worst failure mode, because nothing errored. I spent 45 minutes in Python's &lt;code&gt;re&lt;/code&gt; module iterating the pattern: print intermediate results, tweak, reprint, repeat.&lt;/p&gt;

&lt;p&gt;That's the loop I want to talk about, because most of the regex pain I see isn't "I don't know the syntax." It's "I can't see all my cases at once, so every fix quietly breaks a different row."&lt;/p&gt;

&lt;h2&gt;
  
  
  Why real-world data is especially hard to regex
&lt;/h2&gt;

&lt;p&gt;Structured text from legacy systems each carries slightly different conventions. A few I've hit in the same project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Timestamps that mix &lt;code&gt;2026-07-14 09:31:02&lt;/code&gt;, &lt;code&gt;14-JUL-2026 09:31&lt;/code&gt;, and &lt;code&gt;20260714T093102Z&lt;/code&gt; — sometimes in one file&lt;/li&gt;
&lt;li&gt;Prices as &lt;code&gt;190.00&lt;/code&gt;, &lt;code&gt;190,00&lt;/code&gt; (European locale), or &lt;code&gt;$190.00&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Ticker symbols that bleed into option codes: &lt;code&gt;AAPL260718C00190000&lt;/code&gt; (OCC format)&lt;/li&gt;
&lt;li&gt;Account/reference fields that are sometimes all-numeric, sometimes alphanumeric with dashes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Writing a regex for any &lt;em&gt;one&lt;/em&gt; of these in isolation is easy. Writing one that survives all the variants your real data actually contains is where it gets messy — and where iterating against a single pasted string falls apart.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trick: a visible pass/fail test suite, not one string
&lt;/h2&gt;

&lt;p&gt;Most regex testers let you paste a pattern + a string and highlight the match. Useful, but it only shows you one case at a time. What actually shortened my loop was defining a &lt;em&gt;set&lt;/em&gt; of inputs, each tagged "should match" or "should not match," and watching them all evaluate simultaneously as I edit.&lt;/p&gt;

&lt;p&gt;Take the timestamp column. My real requirements were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;09:31:02&lt;/code&gt; → must match&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;09:31&lt;/code&gt; → must &lt;strong&gt;also&lt;/strong&gt; match (seconds optional upstream)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;9:31:02&lt;/code&gt; → must &lt;strong&gt;fail&lt;/strong&gt; (no leading zero — the downstream DB column is strict &lt;code&gt;HH:MM:SS&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;25:00:00&lt;/code&gt; → must fail (not a real hour)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern I landed on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^(\d{4})-(\d{2})-(\d{2})\s([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d))?$
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two parts people get wrong here:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;([01]\d|2[0-3])&lt;/code&gt; for hours&lt;/strong&gt;, not &lt;code&gt;\d{2}&lt;/code&gt;. The naive version happily accepts &lt;code&gt;25&lt;/code&gt; and &lt;code&gt;99&lt;/code&gt;. Constrain the ranges or your "validation" validates nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;(?::([0-5]\d))?&lt;/code&gt; makes the seconds group optional&lt;/strong&gt; — the &lt;code&gt;(?: ... )?&lt;/code&gt; wraps &lt;em&gt;both&lt;/em&gt; the colon and the digits so &lt;code&gt;09:31&lt;/code&gt; passes but &lt;code&gt;09:31:&lt;/code&gt; (dangling colon) doesn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point isn't the pattern — it's that when I changed the hour clause and it fixed &lt;code&gt;25:00:00&lt;/code&gt;, I could see &lt;em&gt;at a glance&lt;/em&gt; that it hadn't broken the &lt;code&gt;09:31&lt;/code&gt; (no-seconds) case. That's the feedback loop that turns a 45-minute grind into a 5-minute one.&lt;/p&gt;

&lt;h2&gt;
  
  
  OCC option symbols: a great regex stress test
&lt;/h2&gt;

&lt;p&gt;OCC option symbology is a compact torture test. The format is &lt;code&gt;[ROOT][YYMMDD][C/P][STRIKE×8]&lt;/code&gt;, where the strike is 8 digits with 3 implied decimal places. Apple's $190 call expiring 2026-07-18 is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AAPL260718C00190000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decomposed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^([A-Z]{1,6})(\d{2})(\d{2})(\d{2})([CP])(\d{5})(\d{3})$
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Group 1: root, 1–6 uppercase letters&lt;/li&gt;
&lt;li&gt;Groups 2–4: YY, MM, DD&lt;/li&gt;
&lt;li&gt;Group 5: C or P&lt;/li&gt;
&lt;li&gt;Groups 6–7: the 8-digit strike split into whole dollars (5) and thousandths (3)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You rebuild the strike as &lt;code&gt;int(g6) + int(g7) / 1000&lt;/code&gt; → &lt;code&gt;190 + 000/1000 = 190.0&lt;/code&gt;. A &lt;code&gt;$7.50&lt;/code&gt; strike encodes as &lt;code&gt;00007500&lt;/code&gt; → &lt;code&gt;7 + 500/1000 = 7.5&lt;/code&gt;. Getting that split right on the first try is exactly the kind of thing worth verifying visually before it goes into parser code — use a replace template like &lt;code&gt;$1 | $2$3$4 | $5 | $6.$7&lt;/code&gt; and watch a handful of real symbols decompose cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two habits that make the parser code readable
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Named groups.&lt;/strong&gt; Instead of:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(\d{4})-(\d{2})-(\d{2})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;write:&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="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;year&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;\&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;month&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;\&lt;span class="n"&gt;d&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="o"&gt;-&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;?&lt;/span&gt;&lt;span class="n"&gt;P&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;day&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;\&lt;span class="n"&gt;d&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now it's &lt;code&gt;m.group('year')&lt;/code&gt; in Python, not &lt;code&gt;m.group(1)&lt;/code&gt;. When a pattern has 8 capture groups (common with OCC symbols or log lines), named groups make the downstream code self-documenting — no comment needed to explain what group 6 was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Know what regex can't do.&lt;/strong&gt; I needed to flag rows where volume &amp;gt; 1000 &lt;em&gt;and&lt;/em&gt; price &amp;lt; $5.00 (a low-priced, high-volume scanner filter). Regex can't compare numbers — but it can match digit &lt;em&gt;ranges&lt;/em&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;^\S+\s+[0-4]\.\d{2}\s+([1-9]\d{3})
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;[0-4]\.\d{2}&lt;/code&gt; is "0.00–4.99" and &lt;code&gt;[1-9]\d{3}&lt;/code&gt; is "a 4-digit number that doesn't start with 0" (1000–9999). That's a deliberately narrow pattern for a specific range, not a general numeric comparison — and it's fine to reach for as long as you know that's what you're doing. The moment your ranges get irregular, drop back to code.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing that matters for regulated data: don't upload it
&lt;/h2&gt;

&lt;p&gt;This is the part I care about most. Financial data is regulated, and I'm not pasting trade confirmations with account numbers into a cloud service whose logging policy I've never read. Whatever tester you use, prefer one that runs &lt;strong&gt;client-side&lt;/strong&gt; — the JavaScript executes in your browser, the data never leaves the machine, and it keeps working offline after first load. I've iterated patterns against a brokerage CSV export on a flight with no connection. For anything touching account numbers or client data, "runs locally" isn't a nice-to-have.&lt;/p&gt;

&lt;p&gt;The one I keep bookmarked for this is &lt;a href="https://regexlab.orthogonal.info" rel="noopener noreferrer"&gt;RegexLab&lt;/a&gt; — the multi-case test-suite mode is what actually shortened my loop, and it's browser-only so nothing gets uploaded. I wrote up the full trading-data walkthrough (locale-price handling, the OCC replace template, the timestamp suite) &lt;a href="https://orthogonal.info/regex-trading-data-parsing-regexlab/" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;But the tool is secondary to the habit: &lt;strong&gt;stop testing one string at a time.&lt;/strong&gt; Build the failing cases into a visible suite so every fix has to survive all of them at once.&lt;/p&gt;

&lt;p&gt;What's the nastiest real-world format you've had to regex — and what finally made it click?&lt;/p&gt;

</description>
      <category>regex</category>
      <category>python</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Verifying Webhook Signatures by Hand: HMAC-SHA256 and Why Yours Keeps Failing</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 23 Jul 2026 17:00:52 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/verifying-webhook-signatures-by-hand-hmac-sha256-and-why-yours-keeps-failing-2d55</link>
      <guid>https://dev.to/orthogonalinfo/verifying-webhook-signatures-by-hand-hmac-sha256-and-why-yours-keeps-failing-2d55</guid>
      <description>&lt;p&gt;A webhook fired at 2am, my handler 500'd, and the vendor's dashboard just said "delivery failed." No body, no signature, no clue. When I finally caught the payload, the first thing I needed to know was: is this actually from them, or is someone POSTing garbage at my endpoint?&lt;/p&gt;

&lt;p&gt;That question is answered by one line of crypto — an HMAC-SHA256 signature — and you can check it by hand without pasting a production secret into some random website. This is about the boring, load-bearing part of webhooks nobody documents well: how the signature header is computed, why your comparison keeps failing, and how to verify one manually when a delivery breaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the signature header actually is
&lt;/h2&gt;

&lt;p&gt;Every serious webhook provider signs the request body. GitHub sends &lt;code&gt;X-Hub-Signature-256&lt;/code&gt;. Stripe sends &lt;code&gt;Stripe-Signature&lt;/code&gt;. Shopify sends &lt;code&gt;X-Shopify-Hmac-Sha256&lt;/code&gt;. Different header names, same idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;signature = HMAC-SHA256(secret, raw_request_body)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The provider and you both know a shared secret. They hash the exact bytes of the body with that secret and ship the result in a header. You recompute the same hash on your side. Match = authentic and untampered. No match = reject with a 401 and move on.&lt;/p&gt;

&lt;p&gt;Why it matters: your webhook URL is public the moment you register it. Anyone who finds it can POST a fake "payment succeeded" event. Without signature verification, your app will happily believe them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying a GitHub signature by hand
&lt;/h2&gt;

&lt;p&gt;GitHub's own docs use a concrete example, which makes it a perfect sanity check. Secret is &lt;code&gt;It's a Secret to Everybody&lt;/code&gt;, body is &lt;code&gt;Hello, World!&lt;/code&gt;. The expected signature is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run HMAC-SHA256 with that key and message and you get exactly that hex string. That's the whole verification. GitHub prefixes it with &lt;code&gt;sha256=&lt;/code&gt; in the header, so the real value on the wire is &lt;code&gt;sha256=757107ea...&lt;/code&gt; — strip the prefix before comparing.&lt;/p&gt;

&lt;p&gt;Do this client-side (the Web Crypto API's &lt;code&gt;crypto.subtle.sign&lt;/code&gt; keeps the secret in the tab — check the Network panel, there are no outbound requests). Pasting a webhook secret into a server-side "online HMAC generator" is the kind of thing that ends up in someone's access logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three reasons your comparison fails
&lt;/h2&gt;

&lt;p&gt;Manual verification exposes the bugs that silently break webhook handlers. In order of how often they've bitten me:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. You hashed the parsed body, not the raw body.&lt;/strong&gt; This is the big one. Frameworks love to parse JSON for you. But &lt;code&gt;JSON.stringify(JSON.parse(body))&lt;/code&gt; is not the original bytes — key order changes, whitespace vanishes, unicode gets re-escaped. The signature is over the &lt;em&gt;exact bytes&lt;/em&gt; the provider sent. In Express you need the raw 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;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/webhook&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;express&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&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="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;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;sig&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&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="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X-Hub-Signature-256&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;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;sha256=&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;hmacSha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="c1"&gt;// req.body is a Buffer here, not a parsed object&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your handler works in tests but fails in production, this is almost always why — a body parser upstream mangled the bytes before you hashed them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Wrong key encoding.&lt;/strong&gt; Most providers treat the secret as a UTF-8 string. Some — a few payment and banking APIs — give you a hex or base64 secret that must be decoded to raw bytes first. Hashing the literal hex characters instead of the decoded bytes gives a completely different result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. Non-constant-time comparison.&lt;/strong&gt; Once the bytes are right, don't compare signatures with &lt;code&gt;===&lt;/code&gt;. A naive string compare returns early on the first mismatched character, which leaks timing information an attacker can measure. Use a constant-time compare:&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;crypto&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;crypto&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;safeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;b&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;ba&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&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="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nx"&gt;bb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;Buffer&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="nx"&gt;b&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;ba&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="nx"&gt;bb&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="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&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;crypto&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timingSafeEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ba&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;bb&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;h2&gt;
  
  
  Stripe adds a timestamp — and so should you
&lt;/h2&gt;

&lt;p&gt;Stripe's &lt;code&gt;Stripe-Signature&lt;/code&gt; header isn't just the HMAC. It looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;t=1699999999,v1=5257a869e7ecebeda32affa62cdca3fa...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The signed payload is &lt;code&gt;t + "." + body&lt;/code&gt;, &lt;strong&gt;not&lt;/strong&gt; the body alone. You concatenate the timestamp, a literal dot, and the raw body, then HMAC-SHA256 that whole string with your signing secret.&lt;/p&gt;

&lt;p&gt;The timestamp exists to stop replay attacks. Someone who captures a valid signed request can't resend it a day later, because you also check that &lt;code&gt;t&lt;/code&gt; is within a few minutes of now. If you're building your own webhook &lt;em&gt;sender&lt;/em&gt;, copy this pattern — sign the timestamp alongside the body and reject stale ones.&lt;/p&gt;

&lt;h2&gt;
  
  
  When to reach for manual verification
&lt;/h2&gt;

&lt;p&gt;You don't do this on every request — your code handles the happy path. Manual HMAC checking earns its keep in exactly three moments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First integration.&lt;/strong&gt; Before you trust your verification code, confirm it against a known payload. Recompute the signature and diff it against what your handler produced. If they disagree, your handler is wrong, not the provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A specific delivery failed.&lt;/strong&gt; Grab the raw body and the signature header from the provider's delivery log, recompute by hand, and you'll immediately see whether it's a body-encoding bug or a genuinely bad signature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rotating secrets.&lt;/strong&gt; After changing a signing secret, verify one real event manually before you trust the pipeline again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Four rules
&lt;/h2&gt;

&lt;p&gt;Compute the HMAC on the &lt;strong&gt;raw bytes&lt;/strong&gt;, compare in &lt;strong&gt;constant time&lt;/strong&gt;, decode the key to the &lt;strong&gt;right encoding&lt;/strong&gt;, and check the &lt;strong&gt;timestamp&lt;/strong&gt;. Four rules, and your webhook endpoint stops trusting strangers.&lt;/p&gt;

&lt;p&gt;I wrote the full walkthrough — including the byte-level view of what's actually being hashed and a browser-based way to reproduce each provider's signature without a network request — &lt;a href="https://orthogonal.info/verifying-webhook-signatures-by-hand-hmac-sha256-in-the-browser-with-hashforge/" rel="noopener noreferrer"&gt;over on my blog&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;What's the weirdest webhook signature scheme you've had to reverse-engineer? A few payment APIs out there do genuinely cursed things with the payload ordering.&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>node</category>
    </item>
    <item>
      <title>Strip GPS and EXIF From a Whole Folder of Photos Before You Publish (Local, No Upload)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Thu, 23 Jul 2026 15:01:34 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/strip-gps-and-exif-from-a-whole-folder-of-photos-before-you-publish-local-no-upload-246n</link>
      <guid>https://dev.to/orthogonalinfo/strip-gps-and-exif-from-a-whole-folder-of-photos-before-you-publish-local-no-upload-246n</guid>
      <description>&lt;p&gt;Every JPEG your phone takes can carry the exact GPS coordinates of where you stood. Post a few "harmless" photos online and you may be broadcasting your home address, your kid's school, your gym. Most people find this out the hard way.&lt;/p&gt;

&lt;p&gt;You don't need a website that "cleans metadata" — you can strip every image in a folder locally in one command. Nothing leaves your machine.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-liner (exiftool)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# macOS: brew install exiftool  |  Debian/Ubuntu: sudo apt install libimage-exiftool-perl&lt;/span&gt;
exiftool &lt;span class="nt"&gt;-all&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;-overwrite_original&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; ./photos
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;-all=&lt;/code&gt; removes &lt;strong&gt;all&lt;/strong&gt; metadata (GPS, timestamps, camera serial, software).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-overwrite_original&lt;/code&gt; skips the &lt;code&gt;.jpg_original&lt;/code&gt; backups.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-r&lt;/code&gt; recurses into subfolders.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify nothing sensitive remains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-gps&lt;/span&gt;:all &lt;span class="nt"&gt;-make&lt;/span&gt; &lt;span class="nt"&gt;-model&lt;/span&gt; ./photos
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty output = clean.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep orientation, drop everything else
&lt;/h2&gt;

&lt;p&gt;Stripping &lt;code&gt;-all=&lt;/code&gt; can rotate photos that relied on the EXIF orientation flag. Preserve just that one tag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;exiftool &lt;span class="nt"&gt;-all&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;--Orientation&lt;/span&gt; &lt;span class="nt"&gt;-overwrite_original&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; ./photos
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A tiny reusable script
&lt;/h2&gt;

&lt;p&gt;Save as &lt;code&gt;clean-photos.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail
&lt;span class="nv"&gt;target&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;1&lt;/span&gt;&lt;span class="k"&gt;:-&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Stripping metadata from images in: &lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
exiftool &lt;span class="nt"&gt;-all&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nt"&gt;--Orientation&lt;/span&gt; &lt;span class="nt"&gt;-overwrite_original&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-ext&lt;/span&gt; jpg &lt;span class="nt"&gt;-ext&lt;/span&gt; jpeg &lt;span class="nt"&gt;-ext&lt;/span&gt; png &lt;span class="nt"&gt;-ext&lt;/span&gt; webp &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Done. Verifying GPS is gone:"&lt;/span&gt;
exiftool &lt;span class="nt"&gt;-gps&lt;/span&gt;:all &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$target&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; gps &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"No GPS tags found. ✅"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x clean-photos.sh
./clean-photos.sh ~/Pictures/to-publish
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why local matters
&lt;/h2&gt;

&lt;p&gt;Uploading photos to an online "EXIF remover" means handing your original, geotagged images — the exact thing you're trying to protect — to a third-party server. Do it locally and the sensitive data never leaves your disk.&lt;/p&gt;

&lt;p&gt;If you only have one or two images and don't want to touch a terminal, &lt;a href="https://quickshrink.orthogonal.info/?ref=devto-exif" rel="noopener noreferrer"&gt;QuickShrink&lt;/a&gt; strips metadata (and compresses) entirely in your browser — the file never uploads. But for a whole folder, the exiftool script above is faster and scriptable.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;Photos carry GPS. Assume every image does until proven otherwise.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;exiftool -all= --Orientation -overwrite_original -r ./photos&lt;/code&gt; cleans a folder locally.&lt;/li&gt;
&lt;li&gt;Verify with &lt;code&gt;exiftool -gps:all&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Never upload the originals you're trying to protect.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>security</category>
      <category>privacy</category>
      <category>bash</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Batch-Convert a Folder of Images to WebP with a Makefile (cwebp, No SaaS)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Wed, 22 Jul 2026 15:00:35 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/batch-convert-a-folder-of-images-to-webp-with-a-makefile-cwebp-no-saas-4mhd</link>
      <guid>https://dev.to/orthogonalinfo/batch-convert-a-folder-of-images-to-webp-with-a-makefile-cwebp-no-saas-4mhd</guid>
      <description>&lt;h1&gt;
  
  
  Batch-Convert a Folder of Images to WebP with a Makefile (cwebp, No SaaS)
&lt;/h1&gt;

&lt;p&gt;Shipping WebP instead of JPEG/PNG is one of the cheapest web-perf wins there is — usually 25–35% smaller at the same visual quality. But nobody wants to hand-run &lt;code&gt;cwebp&lt;/code&gt; on 200 files, and uploading a client's product photos to a random web tool is a non-starter.&lt;/p&gt;

&lt;p&gt;Here's a tiny, local, dependency-light &lt;code&gt;Makefile&lt;/code&gt; that converts every image in &lt;code&gt;assets/&lt;/code&gt; to WebP, skips files that are already up to date, and never touches the network.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Install cwebp
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Debian/Ubuntu&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;webp
&lt;span class="c"&gt;# macOS&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;webp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  2. The Makefile
&lt;/h2&gt;

&lt;p&gt;Drop this in your project root:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nv"&gt;SRC_DIR&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; assets
&lt;span class="nv"&gt;OUT_DIR&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; assets/webp
&lt;span class="nv"&gt;QUALITY&lt;/span&gt; &lt;span class="o"&gt;?=&lt;/span&gt; 80

&lt;span class="nv"&gt;SOURCES&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;wildcard &lt;span class="p"&gt;$(&lt;/span&gt;SRC_DIR&lt;span class="p"&gt;)&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.jpg &lt;span class="p"&gt;$(&lt;/span&gt;SRC_DIR&lt;span class="p"&gt;)&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.jpeg &lt;span class="p"&gt;$(&lt;/span&gt;SRC_DIR&lt;span class="p"&gt;)&lt;/span&gt;/&lt;span class="k"&gt;*&lt;/span&gt;.png&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;TARGETS&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;patsubst &lt;span class="p"&gt;$(&lt;/span&gt;SRC_DIR&lt;span class="p"&gt;)&lt;/span&gt;/%,&lt;span class="p"&gt;$(&lt;/span&gt;OUT_DIR&lt;span class="p"&gt;)&lt;/span&gt;/%.webp,&lt;span class="p"&gt;$(&lt;/span&gt;SOURCES&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="nl"&gt;.PHONY&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;webp clean&lt;/span&gt;
&lt;span class="nl"&gt;webp&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$(TARGETS)&lt;/span&gt;

&lt;span class="nl"&gt;$(OUT_DIR)/%.webp&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;$(SRC_DIR)/%&lt;/span&gt;
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;OUT_DIR&lt;span class="p"&gt;)&lt;/span&gt;
    cwebp &lt;span class="nt"&gt;-quiet&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;QUALITY&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&amp;lt;&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;@&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"→ &lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt; (&lt;/span&gt;&lt;span class="p"&gt;$$(&lt;/span&gt;&lt;span class="s2"&gt;du -h "&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;" | cut -f1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;)"&lt;/span&gt;

&lt;span class="nl"&gt;clean&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; &lt;span class="p"&gt;$(&lt;/span&gt;OUT_DIR&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  3. Run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make webp              &lt;span class="c"&gt;# convert everything at q80&lt;/span&gt;
make webp &lt;span class="nv"&gt;QUALITY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;90   &lt;span class="c"&gt;# higher quality&lt;/span&gt;
make clean             &lt;span class="c"&gt;# nuke the output dir&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because targets depend on their source files, &lt;code&gt;make&lt;/code&gt; only reconverts images that changed — so re-running it in a build pipeline is basically free.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Serve with a fallback
&lt;/h2&gt;

&lt;p&gt;Let the browser pick WebP when it can, JPEG when it can't:&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;picture&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;source&lt;/span&gt; &lt;span class="na"&gt;srcset=&lt;/span&gt;&lt;span class="s"&gt;"/assets/webp/hero.jpg.webp"&lt;/span&gt; &lt;span class="na"&gt;type=&lt;/span&gt;&lt;span class="s"&gt;"image/webp"&lt;/span&gt;&lt;span class="nt"&gt;&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;"/assets/hero.jpg"&lt;/span&gt; &lt;span class="na"&gt;alt=&lt;/span&gt;&lt;span class="s"&gt;"Hero"&lt;/span&gt; &lt;span class="na"&gt;width=&lt;/span&gt;&lt;span class="s"&gt;"1200"&lt;/span&gt; &lt;span class="na"&gt;height=&lt;/span&gt;&lt;span class="s"&gt;"600"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/picture&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  When you just need one file, fast
&lt;/h2&gt;

&lt;p&gt;The Makefile is for repos and CI. When I just need to shrink or convert a single image by hand — pick a quality, preview the result, download — I use &lt;a href="https://quickshrink.orthogonal.info/?ref=devto-webp" rel="noopener noreferrer"&gt;QuickShrink&lt;/a&gt;, which does the compression in the browser (the file never leaves your machine). Same privacy property as the local &lt;code&gt;cwebp&lt;/code&gt; approach, no install.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why bother
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Smaller payload&lt;/strong&gt; → faster LCP, better Core Web Vitals.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Local&lt;/strong&gt; → client images never hit a third-party server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Incremental&lt;/strong&gt; → &lt;code&gt;make&lt;/code&gt; skips unchanged files, so it's cheap in CI.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole thing. No SaaS, no upload, ~15 lines of Makefile.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>tutorial</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Auto-Optimize Images in a Git Pre-Commit Hook (Local, No Upload)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Mon, 20 Jul 2026 15:00:47 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/auto-optimize-images-in-a-git-pre-commit-hook-local-no-upload-m28</link>
      <guid>https://dev.to/orthogonalinfo/auto-optimize-images-in-a-git-pre-commit-hook-local-no-upload-m28</guid>
      <description>&lt;p&gt;Every repo I inherit has the same problem: someone dragged a 4.2 MB hero.png straight out of Figma into &lt;code&gt;/public&lt;/code&gt;, committed it, and now every clone and every deploy drags that weight around forever. Git never forgets, so even after you shrink it later, the fat blob lives in history permanently.&lt;/p&gt;

&lt;p&gt;The fix isn't "remember to compress images." Humans don't remember. The fix is a &lt;strong&gt;pre-commit hook&lt;/strong&gt; that optimizes any staged image &lt;em&gt;before&lt;/em&gt; it ever enters a commit — locally, with no upload to a third-party service.&lt;/p&gt;

&lt;p&gt;Here's a setup you can paste in today.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one-file version (no dependencies beyond what's already on your machine)
&lt;/h2&gt;

&lt;p&gt;If you have &lt;code&gt;pngquant&lt;/code&gt; and &lt;code&gt;jpegoptim&lt;/code&gt; installed (both are in Homebrew and apt), this is the whole thing. Save it as &lt;code&gt;.git/hooks/pre-commit&lt;/code&gt; and &lt;code&gt;chmod +x&lt;/code&gt; it:&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;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="c"&gt;# Optimize staged images before they enter a commit. All local, no uploads.&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="c"&gt;# Only look at files that are staged (added/modified/copied)&lt;/span&gt;
&lt;span class="nv"&gt;staged&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git diff &lt;span class="nt"&gt;--cached&lt;/span&gt; &lt;span class="nt"&gt;--name-only&lt;/span&gt; &lt;span class="nt"&gt;--diff-filter&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;ACM&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="nv"&gt;changed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nv"&gt;IFS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; file&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;continue
  case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;file&lt;/span&gt;&lt;span class="p"&gt;,,&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;.png&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nv"&gt;before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
      pngquant &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;--skip-if-larger&lt;/span&gt; &lt;span class="nt"&gt;--quality&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;65-90 &lt;span class="nt"&gt;--output&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
      &lt;/span&gt;&lt;span class="nv"&gt;after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$after&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$before&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;git add &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;changed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  png  &lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;before/1024&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;KB -&amp;gt; &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;after/1024&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;KB"&lt;/span&gt;
      &lt;span class="k"&gt;fi&lt;/span&gt;
      &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;*&lt;/span&gt;.jpg|&lt;span class="k"&gt;*&lt;/span&gt;.jpeg&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="nv"&gt;before&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
      jpegoptim &lt;span class="nt"&gt;--strip-all&lt;/span&gt; &lt;span class="nt"&gt;--max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;85 &lt;span class="nt"&gt;--quiet&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
      &lt;/span&gt;&lt;span class="nv"&gt;after&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &amp;lt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
      &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$after&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-lt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$before&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
        &lt;/span&gt;git add &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nv"&gt;changed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
        &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"  jpg  &lt;/span&gt;&lt;span class="nv"&gt;$file&lt;/span&gt;&lt;span class="s2"&gt;  &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;before/1024&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;KB -&amp;gt; &lt;/span&gt;&lt;span class="k"&gt;$((&lt;/span&gt;after/1024&lt;span class="k"&gt;))&lt;/span&gt;&lt;span class="s2"&gt;KB"&lt;/span&gt;
      &lt;span class="k"&gt;fi&lt;/span&gt;
      &lt;span class="p"&gt;;;&lt;/span&gt;
  &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$staged&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$changed&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-eq&lt;/span&gt; 1 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Images optimized and re-staged."&lt;/span&gt;
&lt;span class="nb"&gt;exit &lt;/span&gt;0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Runs only on &lt;strong&gt;staged&lt;/strong&gt; images, so it never rewrites files you didn't touch.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--skip-if-larger&lt;/code&gt; / the size check means it never makes a file &lt;em&gt;bigger&lt;/em&gt; (compression can occasionally do that on already-tiny PNGs).&lt;/li&gt;
&lt;li&gt;Re-stages the optimized file with &lt;code&gt;git add&lt;/code&gt; so the smaller version is what actually gets committed.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;strip-all&lt;/code&gt; removes EXIF — which also quietly deletes GPS coordinates from phone photos before they hit a public repo. (That alone has saved me from committing my home address in a screenshot's metadata.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Make it team-wide with pre-commit
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;.git/hooks&lt;/code&gt; isn't version-controlled, so a raw hook only protects &lt;em&gt;your&lt;/em&gt; machine. To enforce it for everyone, use the &lt;a href="https://pre-commit.com/" rel="noopener noreferrer"&gt;&lt;code&gt;pre-commit&lt;/code&gt;&lt;/a&gt; framework and commit the config:&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;# .pre-commit-config.yaml&lt;/span&gt;
&lt;span class="na"&gt;repos&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;repo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
    &lt;span class="na"&gt;hooks&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;optimize-images&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;optimize staged images&lt;/span&gt;
        &lt;span class="na"&gt;entry&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bash scripts/optimize-images.sh&lt;/span&gt;
        &lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;system&lt;/span&gt;
        &lt;span class="na"&gt;types_or&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;png&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;jpeg&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
        &lt;span class="na"&gt;pass_filenames&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;pre-commit install&lt;/code&gt; wires it up for every contributor, and CI can run &lt;code&gt;pre-commit run --all-files&lt;/code&gt; to catch anyone who skipped the hook with &lt;code&gt;--no-verify&lt;/code&gt;.&lt;/p&gt;

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

&lt;p&gt;The lazy alternative is a SaaS compressor: upload the image, download the smaller one. That's fine for a one-off marketing PNG. It's a bad default inside an automated dev workflow for three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Latency + rate limits.&lt;/strong&gt; A network round-trip per image in a pre-commit hook makes committing feel broken. The free tiers (TinyPNG = 500/mo) run out fast in CI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets/NDA exposure.&lt;/strong&gt; Product screenshots, client mockups, internal dashboards — a lot of images in private repos are things you contractually cannot ship to a random third-party server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Determinism.&lt;/strong&gt; A hook that depends on someone's API being up is a hook that will fail your commit at the worst possible moment.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;code&gt;pngquant&lt;/code&gt;/&lt;code&gt;jpegoptim&lt;/code&gt; run on your machine, offline, deterministically. That's the right tool for the &lt;em&gt;automated&lt;/em&gt; path.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you just need a quick manual squish
&lt;/h2&gt;

&lt;p&gt;Hooks are for the repo. For the one-off "designer Slacked me a 6 MB PNG and I need it web-ready right now," a CLI install is overkill. I built a browser-only compressor for exactly that case — it runs the compression in the tab via the Canvas API, nothing is uploaded, and it has web/social/email presets so it's one click:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://quickshrink.orthogonal.info/?ref=devto-precommit" rel="noopener noreferrer"&gt;QuickShrink&lt;/a&gt;&lt;/strong&gt; — drop an image, get a smaller one, close the tab. Same privacy property as the CLI (no upload), just without installing anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaway
&lt;/h2&gt;

&lt;p&gt;Don't rely on discipline to keep image weight out of your repo — automate it at the commit boundary. A ~30-line pre-commit hook using local tools will quietly shave megabytes off every PR, strip EXIF/GPS as a bonus, and never leak a private image to a third party. Ship the hook once; benefit on every commit forever.&lt;/p&gt;

&lt;p&gt;If you're already doing this in CI, I'd like to hear how — especially anyone running it as a required GitHub Action gate rather than a local hook. What breaks at scale?&lt;/p&gt;

</description>
      <category>git</category>
      <category>devops</category>
      <category>webdev</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your Photos Are Broadcasting Your Home Address (Strip EXIF GPS in the Browser)</title>
      <dc:creator>Max</dc:creator>
      <pubDate>Tue, 14 Jul 2026 17:01:32 +0000</pubDate>
      <link>https://dev.to/orthogonalinfo/your-photos-are-broadcasting-your-home-address-strip-exif-gps-in-the-browser-3fcj</link>
      <guid>https://dev.to/orthogonalinfo/your-photos-are-broadcasting-your-home-address-strip-exif-gps-in-the-browser-3fcj</guid>
      <description>&lt;p&gt;A friend sent me a photo of their new apartment and asked me to guess the neighborhood. I opened the JPEG in a terminal, ran &lt;code&gt;exiftool&lt;/code&gt;, and read back their street address to two decimal places of latitude. They had never posted the location. The phone did it for them.&lt;/p&gt;

&lt;p&gt;Most photos off a modern phone carry a GPS block inside the file the exact coordinates where the shutter fired, down to a few meters. Share that JPEG anywhere that doesn't re-encode it, and you're publishing your home, your kid's school, your office desk. Here's what's actually in that metadata, which platforms strip it and which don't, and why browser-only is the right fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's actually inside the file
&lt;/h2&gt;

&lt;p&gt;EXIF is a block of tags glued into the JPEG right after the start-of-image marker. Designed for camera settings shutter speed, ISO, focal length. But the spec also carries an entire GPS sub-directory, and phones fill it in by default.&lt;/p&gt;

&lt;p&gt;What a single iPhone photo typically hands over:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GPS Latitude    : 37 deg 46' 29.88" N
GPS Longitude   : 122 deg 25' 9.84" W
GPS Altitude    : 14.2 m Above Sea Level
Create Date     : 2026:07:04 18:32:11
Make            : Apple
Model           : iPhone 15 Pro
Software        : 17.5.1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lat/long pair drops a pin within ~5 meters. The timestamp says when you were standing there. The device model and OS version are a bonus for anyone fingerprinting you. None of it is visible in the picture. It rides along silently.&lt;/p&gt;

&lt;p&gt;Under the hood: GPS coordinates are stored as three rational numbers (degrees, minutes, seconds), each a pair of 32-bit integers, referenced by an offset pointer in the main tag table. Tidy little format which is exactly why it's easy to both read and remove.&lt;/p&gt;

&lt;h2&gt;
  
  
  The "but platforms strip it" myth
&lt;/h2&gt;

&lt;p&gt;The common reassurance is that social networks scrub metadata on upload. Some do. Many don't, and the behavior is inconsistent enough that I don't trust any of it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Facebook, Instagram, Twitter/X:&lt;/strong&gt; re-encode and drop EXIF. Generally safe but they replace it with their own tracking, and the re-encode wrecks quality.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Discord:&lt;/strong&gt; keeps full EXIF on direct image attachments. That coordinate block ships straight through.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack:&lt;/strong&gt; preserves the original file for downloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Email attachments:&lt;/strong&gt; untouched. Whatever your camera wrote lands in the recipient's inbox.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own site / self-hosted gallery:&lt;/strong&gt; serves the raw file unless you strip it yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud share links (Dropbox, Drive):&lt;/strong&gt; hand over the original bytes.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure mode that bit my friend was a real-estate listing tool that just re-served the uploaded JPEGs. Coordinates intact. "The platform handles it" is not a plan. Stripping at the source is.&lt;/p&gt;

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

&lt;p&gt;The obvious fix is &lt;code&gt;exiftool&lt;/code&gt;, which is excellent. But telling a non-technical person to install a Perl utility and run &lt;code&gt;exiftool -all= photo.jpg&lt;/code&gt; is a non-starter. The alternatives most people reach for are worse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Online EXIF removers:&lt;/strong&gt; you upload your geotagged photo to some stranger's server to have the location removed. Read that sentence again you just handed the coordinates to exactly the party you were hiding them from.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Desktop apps:&lt;/strong&gt; fine, but overkill for "clean these 8 photos before I text them."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Phone share-sheet toggles:&lt;/strong&gt; iOS has a "Remove Location" option buried in the share sheet's Options menu. Works, but only for location, only on Apple's terms, and most people never find it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The mechanism for doing it client-side is simple enough to describe in a paragraph. A JPEG is a series of segments, each marked by &lt;code&gt;0xFF&lt;/code&gt; followed by a marker byte. EXIF lives in the APP1 segment (&lt;code&gt;0xFFE1&lt;/code&gt;). To strip it, you parse the segment list, drop APP1 (and optionally APP0, XMP, and any color-profile junk), and re-concatenate the rest. The image pixels sit in the scan data, untouched so unlike the social-network approach, there's &lt;strong&gt;zero quality loss&lt;/strong&gt;. No re-encode, no recompression artifacts. Same pixels, minus the tracking. Read the file with &lt;code&gt;FileReader&lt;/code&gt;, walk the markers, rewrite without the metadata block all in the tab, no upload, no server round-trip, no log file with your coordinates in it.&lt;/p&gt;

&lt;p&gt;I wrote up the full teardown plus a live browser tool that does exactly this here: &lt;a href="https://orthogonal.info/your-photos-are-broadcasting-your-home-address-strip-exif-gps-in-the-browser/" rel="noopener noreferrer"&gt;Strip EXIF GPS in the Browser&lt;/a&gt;. Drag a photo in, download the clean copy, verify with &lt;code&gt;exiftool&lt;/code&gt; if you're paranoid (I was the GPS block is gone, the pixels are byte-identical in the scan segment).&lt;/p&gt;

&lt;h2&gt;
  
  
  Fix it at the camera if you shoot a lot
&lt;/h2&gt;

&lt;p&gt;Stripping after the fact works, but the cleaner move for anything sensitive is to not write the coordinates in the first place:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;iOS:&lt;/strong&gt; Settings &amp;gt; Privacy &amp;amp; Security &amp;gt; Location Services &amp;gt; Camera &amp;gt; Never.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Android:&lt;/strong&gt; the camera app's own settings, usually "Location tags" or "Save location."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You lose the "photos on a map" feature that's the tradeoff.&lt;/p&gt;

&lt;p&gt;But for the 90% case a few photos, right now, before you hit send a browser tab that never phones home is the right tool.&lt;/p&gt;

&lt;p&gt;What's the sketchiest "upload it here to make it private" tool you've seen someone actually use?&lt;/p&gt;

</description>
      <category>security</category>
      <category>javascript</category>
      <category>webdev</category>
      <category>privacy</category>
    </item>
    <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>
  </channel>
</rss>
