Category: Tools & Setup

Tools & Setup is where orthogonal.info curates practical, battle-tested guides on developer productivity tools, CLI utilities, self-hosted software, and environment configuration. Whether you are bootstrapping a new development machine, evaluating self-hosted alternatives to SaaS products, or fine-tuning your terminal workflow, this category delivers step-by-step walkthroughs grounded in real-world experience. Every article is written with one goal: help you build a faster, more reliable, and more enjoyable development environment.

With over 25 in-depth posts and growing, Tools & Setup is one of the most active categories on the site — reflecting just how much time engineers spend (and save) by getting their tooling right from day one.

Key Topics Covered

Command-line productivity — Shell customization (Zsh, Fish, Starship), terminal multiplexers (tmux, Zellij), and CLI utilities like ripgrep, fd, fzf, and bat that supercharge daily workflows.
Self-hosted alternatives — Deploying and configuring tools like Gitea, Nextcloud, Vaultwarden, and Uptime Kuma so you own your data without sacrificing usability.
IDE and editor setup — Configuration guides for VS Code, Neovim, and JetBrains IDEs, including extension recommendations, keybindings, and remote development workflows.
Development environment automation — Using Ansible, Homebrew, Nix, dotfiles repositories, and container-based dev environments (Dev Containers, Devbox) to make setups reproducible.
Git workflows and tooling — Advanced Git techniques, hooks, aliases, and GUI clients that streamline version control for solo developers and teams alike.
API testing and debugging — Hands-on guides for curl, HTTPie, Postman, and browser DevTools to debug REST and GraphQL APIs efficiently.
Package and runtime management — Managing multiple language runtimes with asdf, mise, nvm, and pyenv, plus dependency management best practices.

Who This Content Is For
This category is designed for software engineers, DevOps practitioners, system administrators, and hobbyist developers who want to work smarter, not harder. Whether you are a junior developer setting up your first Linux workstation or a senior engineer optimizing a multi-machine workflow, you will find actionable advice that respects your time. The guides assume basic command-line comfort but explain advanced concepts clearly.

What You Will Learn
By exploring the articles in Tools & Setup, you will learn how to automate repetitive environment tasks so a fresh machine is productive in minutes, not days. You will discover modern CLI replacements for legacy Unix tools, understand how to evaluate self-hosted software against its SaaS equivalent, and gain confidence configuring complex development stacks. Each guide includes copy-paste commands, configuration snippets, and links to upstream documentation so you can adapt the advice to your own infrastructure.

Start browsing below to find your next productivity upgrade.

  • Your Password Generator Is Only as Good as crypto.getRandomValues

    A few weeks ago I watched a teammate generate a “secure” password with a little snippet he’d written years ago. It looked fine — 16 characters, mixed case, symbols. Then I asked him what was seeding it. He shrugged: Math.random(). That password had far less real randomness than it looked like, and neither of us could tell by staring at it. That’s the whole problem with password strength — the weakness is invisible.

    So I want to walk through what actually makes a generated password strong, using PassForge, a browser-only password generator I use. It runs entirely client-side, and because the whole thing is a single HTML file with the logic in plain view, it’s a good way to show the math instead of hand-waving about it.

    Why Math.random() is quietly broken for passwords

    JavaScript’s Math.random() is a pseudo-random number generator. It’s fast, it’s fine for shuffling a card animation, and it is not cryptographically secure. The output is deterministic given the internal state, and in V8 that state is only 128 bits seeded in a way that was never meant to resist an attacker. If someone can observe a few outputs, they can predict the rest.

    The fix is the Web Crypto API. Here’s exactly how PassForge pulls a random integer — no library, just the browser’s CSPRNG:

    function cryptoRandInt(max) {
      const arr = new Uint32Array(1);
      crypto.getRandomValues(arr);
      return arr[0] % max;
    }

    crypto.getRandomValues is backed by the operating system’s secure random source. That’s the same class of entropy your TLS handshakes use. The % max introduces a tiny modulo bias when max doesn’t evenly divide 2³², but for a 26- or 33-character pool that bias is negligible — we’re talking a fraction of a fraction of a bit. For a password generator, this is the correct baseline, and it’s the first thing I check before trusting any generator.

    Entropy is the only number that matters

    “Strong password” is a marketing phrase. Entropy in bits is the actual measurement. It answers one question: how many guesses, on average, before an attacker lands on your password?

    For a random character password, entropy is length × log2(poolSize). PassForge computes the pool the honest way — by inspecting which character classes are actually present:

    function calcEntropy(password) {
      let poolSize = 0;
      if (/[a-z]/.test(password)) poolSize += 26;
      if (/[A-Z]/.test(password)) poolSize += 26;
      if (/[0-9]/.test(password)) poolSize += 10;
      if (/[^a-zA-Z0-9]/.test(password)) poolSize += 33;
      return Math.floor(password.length * Math.log2(poolSize));
    }

    So a 16-character password using all four classes has a pool of 95, and log2(95) ≈ 6.57 bits per character — about 105 bits total. Drop the symbols and you’re at a pool of 62, roughly 95 bits. That 10-bit gap is a factor of 1,024 in guessing difficulty, from one design choice most people never think about.

    Passphrases: fewer characters, more entropy per word

    Here’s the part that surprises people. A four-word passphrase like correct-horse-battery-staple feels weaker than Kx9$mQ2!, but it usually isn’t. PassForge uses the EFF short wordlist — 1,296 words — and each randomly chosen word contributes log2(1296) ≈ 10.34 bits:

    function passphraseEntropy(wordCount, addNumber, addSymbol) {
      let bits = wordCount * Math.log2(WORDS.length);
      if (addNumber) bits += Math.log2(100);
      if (addSymbol) bits += Math.log2(8);
      return Math.floor(bits);
    }

    Six words gives you about 62 bits before any decoration — and it’s a string you can actually retype from memory when a password manager isn’t handy. The key word is randomly. If you pick the words yourself, the entropy math collapses, because human word choice is predictable. The security comes entirely from crypto.getRandomValues picking the index for you.

    The crack-time number, and its honest assumptions

    PassForge turns entropy into a human-readable estimate, and I like that it states its threat model in the code instead of hiding it:

    function crackTimeStr(bits) {
      // Assume 10 billion guesses/sec (modern GPU cluster)
      const guesses = Math.pow(2, bits);
      const seconds = guesses / 1e10;
      ...
    }

    Ten billion guesses per second is a reasonable stand-in for an offline attack against a fast hash. That assumption matters. Against a properly slow hash like bcrypt or Argon2, the attacker’s rate drops by orders of magnitude, so the tool is being conservative — it’s modeling the worst realistic case, not the best. I trust a calculator that’s pessimistic on my behalf more than one that shows “3 trillion years” for a mediocre password.

    At 10 billion guesses/sec, a 60-bit password falls in a few years, an 80-bit password holds for millions of years, and once you clear 128 bits you’re past “longer than the age of the universe.” That’s why the target isn’t a length — it’s a bit count.

    The small touches that show someone thought about it

    Two details I appreciated. First, the ambiguous-character filter: O0lI1| get stripped when you ask for it, so you never squint at a password wondering if that’s a one or a lowercase L. Second, the generator guarantees at least one character from each selected class and then shuffles the result with a crypto-backed Fisher-Yates, so the guaranteed characters don’t always land in front. Skipping that shuffle is a classic subtle bug that leaks a little entropy and makes output patterns predictable.

    Why browser-only is the right call here

    A password generator that sends anything over the network is a contradiction. PassForge is one static HTML file — you can open the page, hit “save,” disconnect from Wi-Fi, and it keeps working. Nothing to install, nothing phoning home, and you can read the entire source before you trust it. That’s the same reason I use its siblings for other one-off tasks: HashForge for hashing and Base64Lab for encoding, all client-side. I wrote separately about why I stopped uploading files to free online tools — the logic applies double to secrets.

    If you’d rather not trust any web page with generation at all, a hardware option is the cleanest boundary: I keep a YubiKey 5 NFC for the accounts that matter, and for storing what I generate a manager like 1Password is worth the price. Full disclosure: those are affiliate links.

    The takeaway is simpler than the math looks: check that your generator uses crypto.getRandomValues, aim for 80+ bits of entropy, and let the tool pick the randomness — never your own brain. Try PassForge here and watch the entropy number move as you change the settings.


    Join https://t.me/alphasignal822 for free market intelligence.

  • Your Photos Are Broadcasting Your Home Address — Strip EXIF GPS in the Browser

    A friend sent me a photo of their new apartment last year and asked me to guess the neighborhood. I opened the JPEG in a terminal, ran exiftool, and read back their street address to two decimal places of latitude. They had never posted the location. The phone did it for them.

    That is the whole problem PixelStrip exists to fix. Most photos coming 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. This post is about what’s actually in that metadata, which platforms strip it and which don’t, and why I built a browser-only tool instead of telling people to install desktop software.

    What’s actually inside the file

    EXIF (Exchangeable Image File Format) is a block of tags glued into the JPEG right after the start-of-image marker. It was designed for camera settings — shutter speed, ISO, focal length. Useful stuff. But the spec also carries an entire GPS sub-directory, and phones fill it in by default.

    Here’s what a single photo off an iPhone typically hands over:

    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

    That latitude/longitude pair drops a pin within about 5 meters. The timestamp tells anyone reading it when you were standing there. The device model and OS version are a nice bonus for anyone building a fingerprint of you. None of it is visible when you look at the picture. It rides along silently.

    If you want the gory byte-level details of how those coordinates get packed into IFD structures, I wrote a separate teardown: How EXIF GPS Data Is Stored in a JPEG. The short version: 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. It’s a tidy little format, which is exactly why it’s easy to both read and remove.

    The “but platforms strip it” myth

    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:

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

    The failure mode that bit my friend was a real-estate listing tool that just re-served the uploaded JPEGs. Coordinates intact. So “the platform handles it” is not a plan. Stripping at the source is.

    Why browser-only, and why that matters here

    The obvious fix is exiftool, which is excellent. But telling a non-technical person to install a Perl utility and run exiftool -all= photo.jpg from a terminal is a non-starter. The alternatives most people reach for are worse:

    • Online EXIF removers: 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.
    • Desktop apps: fine, but overkill for “clean these 8 photos before I text them.”
    • Phone share-sheet toggles: iOS has a “Remove Location” option buried in the share sheet’s Options menu. It works, but only for location, only on Apple’s terms, and most people never find it.

    PixelStrip runs entirely in your browser. The photo never leaves your device — there’s no upload, no server round-trip, no log file with your coordinates in it. When you drop a JPEG in, the JavaScript reads the file with the FileReader API, walks the EXIF markers, and rewrites the file without the metadata block, all client-side. You can literally pull your network cable and it still works.

    The mechanism is simple enough to describe in a paragraph. A JPEG is a series of segments, each marked by 0xFF followed by a marker byte. EXIF lives in the APP1 segment (0xFFE1). To strip it, you parse the segment list, drop APP1 (and optionally APP0, XMP, and any color-profile junk you don’t need), and re-concatenate the rest. The image pixels sit in the scan data, untouched — so unlike the social-network approach, there’s zero quality loss. No re-encode, no recompression artifacts. Same pixels, minus the tracking.

    How I actually use it

    Three cases come up for me weekly:

    1. Before texting photos of anything at home. Package on the porch, a receipt, my desk setup — all geotagged with my address. Drop, strip, send.
    2. Before uploading to my own blog. WordPress will happily serve the original file. I strip first so a right-click-save doesn’t leak where I live.
    3. Selling stuff online. Marketplace photos of your living room, tagged with your home coordinates, sent to strangers. Strip every one.

    You can try it here: PixelStrip. Drag a photo in, download the clean copy, verify with exiftool if you’re paranoid (I was — the GPS block is gone, the pixels are byte-identical in the scan segment).

    If you shoot a lot, fix it at the camera

    Stripping after the fact works, but the cleaner move for anything sensitive is to not write the coordinates in the first place. On iOS: Settings > Privacy & Security > Location Services > Camera > set to Never. On Android it’s under the camera app’s own settings, usually “Location tags” or “Save location.” You lose the nice “photos on a map” feature, which is the tradeoff.

    For anyone doing serious photography who still wants a reliable offline scrub across hundreds of files, a small NAS or even a Raspberry Pi running a scheduled exiftool job on an import folder is the setup I’d build. If you’re speccing a cheap always-on box for that kind of home-automation chore, the Raspberry Pi 5 (8GB) is what I’d grab, paired with a decent Samsung microSD card so the write-heavy batch jobs don’t chew through cheap flash. Full disclosure: those are affiliate links — I only link gear I actually run.

    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. That’s the whole pitch.


    Related reading on this site: the byte-level EXIF teardown and why I stopped pasting JWTs into online decoders — same browser-only, nothing-leaves-your-machine principle.

    Join https://t.me/alphasignal822 for free market intelligence.

  • The Treasury FiscalData API: Pull the U.S. National Debt as JSON (No Key)

    Last month I was building a dashboard that needed the actual interest rate the U.S. government pays on its debt. Not a headline number from a news site, not a scraped table — the real figure, updated monthly, that I could pull programmatically and trust. I went looking for an API and braced myself for the usual: sign up, get a key, hit a rate limit at 500 calls, upgrade to a paid tier.

    Then I found the Treasury FiscalData API. No key. No signup. No rate limit worth mentioning. Just clean JSON straight from the U.S. Department of the Treasury, covering everything from the daily national debt to the average interest rate on every class of Treasury security. I’ve been using it for weeks now and it’s become my default source for macro data. Here’s how it works and why I trust it more than most paid feeds.

    Why a government API beats the finance data vendors here

    If you’ve ever priced out Bloomberg or even a mid-tier data vendor, you know macro data gets expensive fast. And the free tiers (Alpha Vantage, some of the FRED wrappers) either throttle you hard or wrap the numbers in their own formatting layer. The FiscalData API is the primary source — it’s the Treasury publishing its own books. When I pull the total public debt, I’m reading the same figure the Bureau of the Fiscal Service uses internally.

    The base URL is https://api.fiscaldata.treasury.gov/services/api/fiscal_service, and every dataset hangs off that. No auth header. Here’s the debt outstanding as of last week, pulled live:

    import urllib.request, json
    
    BASE = "https://api.fiscaldata.treasury.gov/services/api/fiscal_service"
    
    def get(endpoint, params):
        q = "&".join(f"{k}={v}" for k, v in params.items())
        req = urllib.request.Request(f"{BASE}{endpoint}?{q}",
                                     headers={"User-Agent": "Mozilla/5.0"})
        with urllib.request.urlopen(req, timeout=25) as r:
            return json.load(r)
    
    debt = get("/v2/accounting/od/debt_to_penny", {
        "fields": "record_date,tot_pub_debt_out_amt",
        "sort": "-record_date",
        "page[size]": "1",
    })
    row = debt["data"][0]
    print(f"As of {row['record_date']}: "
          f"${float(row['tot_pub_debt_out_amt'])/1e12:.2f} trillion")

    Run that and you get: As of 2026-07-06: $39.39 trillion. That’s the actual number, to the penny, in the tot_pub_debt_out_amt field — the endpoint is literally called debt to the penny. I ran this exact script before writing this paragraph.

    The query language does the filtering for you

    What sold me was that I don’t have to pull a whole dataset and filter client-side. The API takes fields, filter, sort, and pagination params, so I fetch exactly the rows I want. Say I need the average interest rate the Treasury pays, broken out by security type, for the most recent month:

    rates = get("/v2/accounting/od/avg_interest_rates", {
        "fields": "record_date,security_desc,avg_interest_rate_amt",
        "filter": "record_date:eq:2026-06-30",
        "sort": "-avg_interest_rate_amt",
    })
    for r in rates["data"][:5]:
        print(f"{r['security_desc']:35} {r['avg_interest_rate_amt']}%")

    Output, straight from the June 2026 books:

    Domestic Series                     7.577%
    United States Savings Inflation...  4.418%
    Treasury Bills                      3.706%
    Treasury Floating Rate Notes (FRN)  3.512%
    Treasury Bonds                      3.430%

    That T-Bill line — 3.706% — is the weighted average rate across every outstanding bill. If you’re modeling the government’s interest burden or trying to sanity-check where short rates actually sit, this is the ground truth. The filter syntax is field:operator:value, and the operators you’ll use most are eq, gt, gte, lt, lte, and in. Chain them with commas for an AND.

    The datasets I actually reach for

    There are over a hundred datasets, which is honestly the intimidating part. After a few weeks, these are the four I keep going back to:

    • Debt to the Penny (/v2/accounting/od/debt_to_penny) — daily total public debt, split into public vs intragovernmental holdings. Updated every business day.
    • Average Interest Rates (/v2/accounting/od/avg_interest_rates) — monthly, by security type. The one I used above.
    • Treasury Reporting Rates of Exchange (/v1/accounting/od/rates_of_exchange) — the official USD conversion rates for every foreign currency, quarterly. If you do any cross-border accounting, this is the rate auditors expect.
    • Monthly Treasury Statement — federal receipts and outlays, the government’s income statement. Good for tracking the deficit trend without waiting for a news writeup.

    One gotcha that cost me twenty minutes: the pagination params use square brackets — page[size] and page[number] — and if you’re building the URL by hand in some HTTP clients you’ll need to URL-encode them as page%5Bsize%5D. In Python’s requests or with a params dict it’s handled for you, but raw curl on certain shells will choke on the brackets. Default page size is 100; max is 10,000.

    A pattern I use: cache the daily pull

    Because the debt figure only changes on business days, I don’t hammer the API on every dashboard load. I pull once, cache the JSON, and refresh on a cron. Here’s the shape of it:

    import json, os, datetime
    
    CACHE = "/tmp/treasury_debt.json"
    
    def cached_debt():
        today = datetime.date.today().isoformat()
        if os.path.exists(CACHE):
            data = json.load(open(CACHE))
            if data.get("fetched") == today:
                return data["value"]
        row = get("/v2/accounting/od/debt_to_penny", {
            "fields": "tot_pub_debt_out_amt",
            "sort": "-record_date",
            "page[size]": "1",
        })["data"][0]
        value = float(row["tot_pub_debt_out_amt"])
        json.dump({"fetched": today, "value": value}, open(CACHE, "w"))
        return value

    That’s it. One network call a day, and the API has been up every single time I’ve hit it. No 429s, no key rotation, no vendor emails about my “usage tier.”

    Where it fits with the other free finance APIs

    FiscalData isn’t a stock quote API — it won’t give you AAPL’s last price. It’s macro and government fiscal data. I pair it with a couple of other keyless sources depending on what I’m building. For company financials and filings, I use the SEC EDGAR XBRL API, which hands you any public company’s numbers as JSON with no key either. And when I need full-text search across filings, EDGAR’s efts.sec.gov endpoint covers that. Between Treasury FiscalData for macro and SEC EDGAR for corporate, you can build a genuinely capable market data layer without paying for a single API key.

    If you’re going deeper on building signals from this kind of raw data, I put together a longer algorithmic trading engineering guide that walks through turning these feeds into something you can actually trade on.

    If you want to run this on your own hardware

    I run my data-pull crons on a small always-on box rather than paying for a cloud VM — a keyless API plus a $100 mini PC gets you a surprisingly capable homelab data node that never sleeps. If you’re setting one up, I’ve been happy with a Beelink mini PC for exactly this kind of lightweight always-on job, and a Samsung T7 external SSD to hold the cached datasets and historical pulls. Full disclosure: those are Amazon affiliate links, so I earn a small cut if you buy through them — I only recommend gear I actually run.

    The whole point of an API like this is that the interesting work happens on your side — the modeling, the charting, the alerts. The data itself should be boring, reliable, and free. FiscalData is all three, and after a few weeks of leaning on it I’ve stopped reaching for the paid vendors for anything macro.

    Start with the debt-to-the-penny endpoint, get one number printing, then explore the dataset catalog from there. The pattern is identical across all of them.


    Join https://t.me/alphasignal822 for free market intelligence.

  • Check If a Password Was Breached Without Sending It (HIBP k-Anonymity)

    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: POST the plaintext password to Have I Been Pwned and show a warning if it came back dirty. I stopped him before the PR got anywhere. Sending a user’s raw password to a third party to prove it’s not compromised is the kind of irony that ends up in a postmortem.

    The good news is that HIBP solved this exact problem years ago with a technique called k-anonymity, and it’s genuinely clever. You can check any password against 900+ million breached credentials without ever sending the password, its full hash, or anything that identifies it. I’ll 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.

    The problem with a naive breach check

    The obvious design is: hash the password, send the hash, get back a 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 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8, the server (or anyone sniffing the request) can reverse it in microseconds against a rainbow table. That hash is literally the word password. You’ve leaked the credential.

    You need a way to ask “is this password in your list?” where the server learns nothing useful about which password you asked about. That’s what k-anonymity buys you.

    How the range API actually works

    The trick is to send only the first 5 characters of the SHA-1 hash. Here’s the full flow for the password password:

    SHA-1("password") = 5BAA61E4C9B93F3F0682250B6CF8331B7EE68FD8
                        └─┬─┘└──────────────┬──────────────────┘
                       prefix (5)        suffix (35)
    
    GET https://api.pwnedpasswords.com/range/5BAA6

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

    003D68EB55068C33ACE09247EE4C639306B:29
    00658BFD1E05761042698D19D32CD9F1A8F:15
    ...
    1E4C9B93F3F0682250B6CF8331B7EE68FD8:52372427
    ...

    That last line is the one you care about. Your browser (not the server) scans the response for your suffix 1E4C9B93F3F0682250B6CF8331B7EE68FD8, finds it, and reads the count: 52,372,427. The word “password” has appeared in 52 million breached records.

    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 anonymity set.

    Doing it yourself in ~15 lines

    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:

    import hashlib, urllib.request
    
    def pwned_count(password):
        h = hashlib.sha1(password.encode()).hexdigest().upper()
        prefix, suffix = h[:5], h[5:]
        url = f"https://api.pwnedpasswords.com/range/{prefix}"
        req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
        body = urllib.request.urlopen(req).read().decode()
        for line in body.splitlines():
            s, count = line.split(":")
            if s == suffix:
                return int(count)
        return 0
    
    print(pwned_count("password"))                  # 52372427
    print(pwned_count("123456"))                     # 210461208
    print(pwned_count("correcthorsebatterystaple"))  # 4173
    print(pwned_count("xK9#mQ2vLp8$wZ4nR7tB"))       # 0

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

    The JavaScript version is nearly identical, using the built-in crypto.subtle.digest("SHA-1", ...). This is exactly the kind of thing SubtleCrypto is good at — unlike MD5, which the Web Crypto API flatly refuses to compute. (I wrote a whole teardown of why Web Crypto won’t do MD5 if you want that rabbit hole.)

    The padding option most people miss

    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. HIBP added a fix: send the header Add-Padding: true and the server pads every response with a random number of fake, zero-count entries.

    curl -s "https://api.pwnedpasswords.com/range/5BAA6" \
         -H "Add-Padding: true" -H "User-Agent: Mozilla/5.0"
    
    # ...real entries...
    DBB7A2BC0BCFAC5BF1E8B50FFC97A118303:0   ← decoy
    ...

    When I added the header, the response grew from 1,977 to 2,122 lines — 144 of them decoys with a count of :0. 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, turn padding on. It costs a few KB.

    Why browser-only matters here

    k-anonymity protects you from the HIBP server, but it doesn’t protect you from your own backend 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. That’s the same principle behind every tool I build here — the file, the password, the hash never touches a server I control.

    Our HashForge hash generator computes SHA-1 (and SHA-256, and yes, even MD5) locally in the browser, which is exactly the primitive you need for the prefix step. Pair it with a real random generator instead of a memorable-but-guessable passphrase, and you close the loop. Our password generator uses crypto.getRandomValues() rather than Math.random() — the difference between those two is a genuinely scary gap I’ve written about before.

    One gotcha: this is a filter, not a verdict

    A count of zero doesn’t mean a password is strong — it means it hasn’t leaked yet. Tr0ub4dor&3 might return zero and still fall to a targeted attack in seconds because its structure is predictable. Breach-checking is a floor, not a ceiling. Use it to reject known-compromised passwords at signup, then rely on length and true randomness for actual strength. NIST’s SP 800-63B guidance says exactly this: screen against breach corpuses, drop the forced-rotation and complexity theater, and let users pick long random strings.

    If you want to run this at scale in your own infra, a hardware security key makes the whole password question moot for the accounts that matter. I keep a YubiKey 5 NFC on my keychain for exactly that reason (full disclosure: affiliate link — it’s the one I actually carry). For everything else, the k-anonymity check is 15 lines and a free API away.

    Go hash something. Start with your own most-reused password and see what number comes back. If it’s not zero, you’ve got a weekend project.


    Want more field notes on security, tooling, and markets? Join https://t.me/alphasignal822 for free market intelligence.

  • How One Regex Took Down Cloudflare: Catastrophic Backtracking, Tested in Your Browser

    A single line of validation code once took down half of Cloudflare. On July 2, 2019, a regular expression pushed to their WAF spiked CPU to 100% across their global network and knocked a chunk of the internet offline for about 27 minutes. The regex looked harmless. It contained a pattern that backtracks exponentially, and one crafted request was enough to melt a core.

    I keep RegexLab open in a pinned tab specifically to catch this class of bug before it ships. It’s a browser-only regex tester, which matters here for a reason most people miss: when you’re testing a pattern that can hang for 13 seconds, you really don’t want that pattern running on someone else’s server. In RegexLab it runs on your machine, in the same V8 engine your Node backend uses, so the timing you see is the timing you’ll get in production.

    What catastrophic backtracking actually is

    Most regex engines (JavaScript, Python’s re, Java, PCRE) use backtracking. When a pattern can match the same input more than one way, the engine tries one path, and if that fails, it walks back and tries another. Usually that’s fine. The problem starts when the number of possible paths grows exponentially with input length.

    The textbook example is a quantifier inside a quantifier:

    /^(a+)+$/

    Feed it a string of a characters followed by one b. The b guarantees the match fails at the end, but before the engine gives up, it tries every way to split those as between the inner a+ and the outer +. That’s 2^n splits. Each extra character doubles the work.

    I benchmarked it on my machine (Node 24, plain V8 — same engine Chrome runs) with /^(a+)+$/ against n copies of “a” plus a trailing “b”:

    n=15   0.39 ms
    n=20   10.6 ms
    n=22   42.3 ms
    n=24   169 ms
    n=26   585 ms
    n=28   2,516 ms

    Read that again. Going from 26 to 28 characters — two bytes — took the match from half a second to two and a half seconds. At n=32 you’re looking at ~40 seconds for one call. An attacker doesn’t need a botnet. They need one text field and a 32-character string.

    The version that bites real apps

    Nobody writes /^(a+)+$/ on purpose. The dangerous ones look reasonable. Here’s a pattern shaped like a thousand email and username validators I’ve seen in the wild:

    /^([a-zA-Z0-9]+)*@/

    Looks like it’s checking for alphanumeric characters before an @. It also has a + nested inside a *, which is the same exponential trap wearing a nicer suit. I fed it a long run of letters with no @ so the match fails:

    n=20   13 ms
    n=25   434 ms
    n=28   3,303 ms
    n=30   13,225 ms

    Thirty characters. Thirteen seconds. If that regex sits on a login or signup endpoint, one request ties up a worker for 13 seconds. Send twenty of them and your event loop is done. This is a denial-of-service that ships as “input validation.”

    Spotting it before it ships

    The tell is any place where two quantifiers can fight over the same characters. Watch for these shapes:

    • (x+)+ — nested quantifiers, the classic
    • (x*)* — same idea
    • (x+)* and (x*)+ — mixed, still exponential
    • (a|a)+ or (a|ab)+ — alternation where branches overlap
    • (\s+)+, (\w+)* — the real-world disguises

    The fix is almost always to remove the redundancy. The outer quantifier in ([a-zA-Z0-9]+)* does nothing the inner one can’t — [a-zA-Z0-9]+ already matches one or more characters. Drop the wrapper:

    /^[a-zA-Z0-9]+@/

    I reran the safe rewrite /^a+$/ against inputs up to 100,000 characters:

    n=28       0.066 ms
    n=1,000    0.010 ms
    n=100,000  0.216 ms

    Linear time. A hundred thousand characters finishes faster than the evil version handles 20. That’s the whole point — the pattern that looks more permissive is thousands of times faster because there’s only one way to match.

    Why I test these in the browser, not on a server

    Here’s the part that ties back to how RegexLab is built. To confirm a regex is vulnerable, you have to actually run it against a malicious input and watch it hang. If you do that in an online tester that processes patterns server-side, you’re either (a) DoS-ing their box, which is rude, or (b) hitting a timeout that hides the problem from you.

    RegexLab runs the match with the native RegExp engine right in your tab. Nothing is uploaded. Your patterns — which for a lot of us encode business logic, internal formats, sometimes secrets baked into validation rules — never leave the machine. When a match hangs, it hangs your tab, which is exactly the feedback you want. You can feel the 3-second pause and know you found something. I wrote more about why I stopped trusting server-side dev tools with sensitive input in this piece on pasting data into online tools.

    My workflow in RegexLab is simple:

    1. Paste the pattern.
    2. Add a normal test case — confirm it matches what it should.
    3. Add an evil case: a long run of the character class the pattern repeats, ending with something that forces a failed match.
    4. If the result is instant, you’re probably fine. If the tab stalls, you have a ReDoS.

    The multi-case runner is handy here because you can keep the “good” input and the “attack” input side by side and re-run both after every edit to the pattern. I keep a small library of attack strings — 30 identical chars plus a mismatch — for exactly this. For a broader take on using the tool for security work, I wrote up regex patterns that catch real security bugs.

    The structural fixes worth knowing

    Rewriting to remove nested quantifiers covers most cases, but two other tools help:

    Atomic groups and possessive quantifiers. These tell the engine “match this and never give it back,” which kills the backtracking. JavaScript didn’t support them for years, but modern V8 (Node 18+ / recent Chrome) does via (?>...) and a++. So /^(?>a+)+$/ won’t blow up. Check your runtime before relying on it — if you’re on an older engine it’ll throw a syntax error.

    Switch engines for untrusted input. Rust’s regex crate and Go’s RE2 use a finite-automaton approach with no backtracking at all, so ReDoS is impossible by construction. The tradeoff is they drop backreferences and lookaround. If you’re validating user input at scale, that tradeoff is usually worth it. Google built RE2 for exactly this reason after backtracking engines kept taking down services.

    If you want to go deep on how these engines actually differ, Jeffrey Friedl’s Mastering Regular Expressions is still the reference — it’s the book that made the backtracking-vs-automaton distinction click for me (affiliate link, full disclosure). Russ Cox’s free regexp article series covers the RE2 side if you prefer the theory online.

    Test the regex you shipped last month

    Pull up your codebase and grep for )+, )*, and any validation regex on an input field. Drop each one into RegexLab, hand it 30 repeated characters ending in a mismatch, and watch the clock. It takes about ten seconds per pattern, it runs entirely in your browser, and it might save you from a 2 AM page when someone finds the same field an attacker would.

    The Cloudflare outage cost real money and made global news. The bug was one unbounded quantifier next to another. That’s a five-second check you can run right now.


    Join https://t.me/alphasignal822 for free market intelligence.

  • I Stopped Pasting JWTs Into Online Base64 Decoders — Here’s the Browser-Only Fix

    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 that neither of us will ever see.

    That’s the quiet problem with online base64 tools, and it’s why I keep pointing people at Base64Lab instead. It does the same decode, except the bytes never leave the tab. No upload, no round trip, no log entry on someone else’s box. Below is what actually happens under the hood, why the “URL-safe” toggle matters more than people think, and where the browser’s built-in tools fall on their face.

    Why pasting a JWT into a random decoder is a credential leak

    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 the point is the whole string is the credential. If your decoder runs server-side, you just POSTed a working bearer token to a third party.

    Most “free online” decoders are server-side. You can tell because they work even with JavaScript disabled, or because the network tab shows a request firing on every keystroke. 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 `.env` file.

    Base64Lab is the opposite by construction. Open the network tab, decode a 2 MB file, and you’ll see exactly zero requests carrying your data. The only ping it makes is a one-pixel image hit to a counter endpoint — tool name plus a timestamp, no input, no payload. Everything else is `atob`, `btoa`, and a `TextDecoder`, running in your tab.

    The URL-safe gotcha that breaks the browser console

    Here’s the part that trips up even experienced devs. You might think “I don’t need a tool, I’ll just run `atob()` in the console.” Try it on a real JWT payload and watch it throw.

    // A JWT payload segment is base64URL, not standard base64
    atob("eyJzdWIiOiIxMjM0NTY3ODkwIn0")
    // Works here, but feed it bytes that encode to + or /
    // and the url-safe variant uses - and _ instead:
    atob("-_-_Pj_4")
    // Uncaught DOMException: Failed to execute 'atob':
    // The string to be decoded is not correctly encoded.

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

    The fix is a normalization step the tool does for you on every decode:

    function decode(str) {
      let n = str.replace(/-/g, '+').replace(/_/g, '/').replace(/\s/g, '');
      while (n.length % 4 !== 0) n += '=';   // re-add stripped padding
      const raw = atob(n);
      try { return decodeURIComponent(escape(raw)); } // UTF-8 aware
      catch { return raw; }                            // fall back to raw bytes
    }

    I tested this against the standard JWT from jwt.io. The header decodes to {"alg":"HS256","typ":"JWT"} and the payload to {"sub":"1234567890","name":"John Doe","admin":true,"iat":1516239022} — and the same input throws an `Invalid character` exception through bare `atob`. That `replace`/repad dance is the whole reason a dedicated tool beats the console.

    The UTF-8 trap, and the emoji that proves it

    The second thing naive decoders get wrong is multi-byte text. `atob` hands you a binary string where each character is one byte. If the original was UTF-8 — anything with an accent, a CJK character, or an emoji — you need to reassemble those bytes back into code points. Skip that step and “café” comes back as “café”.

    The decodeURIComponent(escape(raw)) trick handles it: `escape` percent-encodes each byte, then `decodeURIComponent` reads those percent groups as UTF-8. Encoding runs the mirror image with btoa(unescape(encodeURIComponent(data))). It’s an old idiom, but it round-trips correctly, and the `try/catch` means raw binary that isn’t valid UTF-8 falls through untouched instead of corrupting silently. I checked a string of emoji through encode then decode — byte-identical out the other side.

    Where it beats the command line too

    I live in a terminal, so I’ll be honest about when `base64 -d` is the right call: scripting, pipes, CI. But three things push me back to the browser tab more often than I expected.

    • It auto-detects direction. Paste base64, it decodes; paste plain text, it encodes. No flipping a -d flag and re-running.
    • Per-line mode. Got a file of base64 strings, one per line? Toggle per-line processing and each row decodes independently instead of the whole blob being treated as one stream. macOS `base64` won’t do that without a `while read` loop.
    • It previews images. Paste a data:image/png;base64,... URI and it renders the actual image, which is the fastest way I know to sanity-check an inline asset.

    And because it’s a PWA with a service worker, it works offline. Load it once, kill your wifi, and it still decodes — which is exactly the posture you want for a tool that touches secrets. I’ve written before about why I stopped uploading files to free online tools; this is the same principle applied to text.

    The honest limitation

    Base64 is encoding, not encryption. 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. Base64Lab’s lane is fast, private, correct decode/encode of text and files. It stays in that lane on purpose.

    If you handle tokens and config blobs all day, a mechanical keyboard with proper n-key rollover genuinely cuts down on the typo-induced “why won’t this decode” rabbit holes — I use a Keychron K2 mechanical keyboard (full disclosure: affiliate link) and the tactile feedback alone has saved me from more than one mispasted credential. For the security-minded, a YubiKey 5 hardware key (affiliate link) is the right answer for the auth flows those JWTs come from in the first place.

    Try the tool here: Base64Lab. If you want more like it, HashForge does the same browser-only treatment for hashing, and RegexLab for regex testing — all of them in the free tools collection.


    Join https://t.me/alphasignal822 for free market intelligence.

  • The SpaceX 424B Prospectus Is Free on SEC EDGAR — Here’s What It Says and How to Pull It

    The day SpaceX priced its IPO, half the finance Twitter accounts I follow linked to a paywalled news story. The other half linked to a screenshot of a screenshot. Almost nobody linked to the one document that actually mattered: the SpaceX 424B prospectus sitting on SEC EDGAR, free, with every number you could want. So here’s the filing, the terms straight off the cover page, and a 20-line Python script that pulls the document URL for any company without you clicking through EDGAR’s 1990s interface.

    The final prospectus — the Form 424B4 — was filed on June 12, 2026 under accession number 0001628280-26-042639. If you just want to read it, here’s the direct link to the document on SEC EDGAR:

    SpaceX 424B4 final prospectus (sec.gov)

    Fair warning before you click: that HTML file is about 11.9 MB because the prospectus is stuffed with full-page photos of Starship and Falcon boosters. Your browser will chew on it for a second.

    What a 424B actually is (and why it’s the one you want)

    People search for “424B” without always knowing why it’s different from the S-1 everyone talks about. The short version:

    • S-1 is the registration statement a company files to start the IPO process. SpaceX filed its original S-1 on May 20, 2026, then amended it twice (S-1/A on June 1 and June 3) as the SEC and the market pushed back on the draft.
    • 424B4 is the final prospectus, filed after pricing under Rule 424(b)(4). This is the one with the real numbers — the actual offering price, the exact share count, the underwriting discount. The S-1 has blanks where those go. The 424B fills them in.

    So when you want the truth about what a deal priced at, the 424B is the document. The S-1 tells you what the company hoped for. I learned this the annoying way years ago, quoting a price range from an S-1 that turned out to be 20% off the final price.

    The numbers off the SpaceX cover page

    Everything below is lifted straight from the cover of the 424B4. No analyst spin, just what the filing says:

    • Shares offered: 555,555,555 shares of Class A common stock
    • IPO price: $135.00 per share
    • Gross raise: $74,999,999,925 — call it $75 billion
    • Ticker: SPCX on Nasdaq (and Nasdaq Texas)
    • Underwriting discount: $0.90 per share, or $500,000,000 total
    • Net proceeds to SpaceX: $134.10 per share, about $74.5 billion before expenses
    • Settlement: shares ready for delivery on or about June 15, 2026

    A $75 billion raise is not a normal IPO. For scale, that’s larger than the entire 2025 US IPO market combined in most tallies. The lead underwriters are the usual heavyweight syndicate — Goldman Sachs, Morgan Stanley, BofA Securities, Citigroup, J.P. Morgan, Barclays, and a long tail behind them.

    The detail that matters more than the price: voting control

    If you only read the cover, you’d miss the part that actually governs this company. SpaceX went public with a dual-class structure:

    • Class A (the shares you can buy): 1 vote per share
    • Class B (insider shares): 10 votes per share

    The prospectus states that immediately after the offering, Elon Musk will hold approximately 82.4% of the voting power — roughly 82.3% even if the underwriters exercise their over-allotment option in full. You are buying economic exposure to SpaceX, not a say in how it’s run. That’s not a knock; it’s just a fact the filing spells out, and it’s exactly the kind of thing buried 40 paragraphs deep that retail buyers skip. Read the risk factors before the photos.

    On use of proceeds, the filing is specific for once: fund the growth strategy including expansion of AI compute infrastructure, launch infrastructure and vehicles, scaling the satellite constellations, and general corporate purposes. The AI compute line is the new tell — this is no longer just a rockets-and-Starlink story.

    Pull the filing yourself with 20 lines of Python

    Clicking through EDGAR by hand is fine once. If you track filings regularly, automate it. SEC publishes a clean JSON endpoint for every company’s filing history — no scraping, no API key. The only rule: you must send a descriptive User-Agent header with contact info, or EDGAR returns a 403 throttle page instead of data. I left out a real UA on my first try and spent ten minutes confused by an “Undeclared Automated Tool” message.

    This uses only the Python standard library — no requests, no pip install:

    import json, urllib.request
    
    # SEC requires a descriptive User-Agent or it returns a 403 throttle page.
    UA = {"User-Agent": "Jane Dev [email protected]"}
    CIK = 1181412  # SpaceX (SPCX)
    
    def get_json(url):
        req = urllib.request.Request(url, headers=UA)
        with urllib.request.urlopen(req, timeout=30) as r:
            return json.load(r)
    
    # 1) Full filing history, newest first
    sub = get_json(f"https://data.sec.gov/submissions/CIK{CIK:010d}.json")
    rec = sub["filings"]["recent"]
    
    # 2) Walk the parallel arrays, grab the 424B4 (the final prospectus)
    for form, date, acc, doc in zip(
            rec["form"], rec["filingDate"],
            rec["accessionNumber"], rec["primaryDocument"]):
        if form == "424B4":
            folder = acc.replace("-", "")
            print(f"{form}  filed {date}")
            print(f"https://www.sec.gov/Archives/edgar/data/{CIK}/{folder}/{doc}")
            break

    Run it and you get:

    424B4  filed 2026-06-12
    https://www.sec.gov/Archives/edgar/data/1181412/000162828026042639/spaceexplorationtechnologi.htm

    The structure is worth understanding because it generalizes. The submissions endpoint returns filings as parallel arraysform[i], filingDate[i], and accessionNumber[i] all line up by index. Zip them together and filter on whatever form type you care about: 10-K for annual reports, 8-K for material events, SC 13D for activist stakes. Change the CIK and the same script works for any filer. Once you have the filing, you can go one step further and pull that company’s financials as structured JSON with the XBRL API.

    Finding a company’s CIK is the one manual step. Search the company name at EDGAR company search, or hit the full-text search API directly — I wrote a separate teardown of EDGAR’s full-text search endpoint (efts.sec.gov) if you want to find filings by keyword instead of CIK.

    One gotcha: the throttle and the rate limit

    Two things will bite you if you scale this up. First, the User-Agent rule above — non-negotiable. Second, SEC asks you to stay under 10 requests per second. For pulling one filing that’s irrelevant, but if you loop over a watchlist of 200 tickers, add a small time.sleep(0.15) between calls. Get greedy and your IP eats a temporary block. The data is free; the courtesy is the price.

    If you’d rather not hit EDGAR at all and just want pre-IPO valuation context before deals like this hit the tape, I covered tracking pre-IPO valuations for SpaceX, OpenAI and Anthropic with a free API in an earlier post.

    If you’d rather read filings on paper

    I read short filings on screen, but for a 200-page prospectus I print the risk factors and use of proceeds sections and mark them up. A cheap monochrome laser printer pays for itself fast if you do this often — the Brother HL-L2350DW is the one sitting next to my desk, and for marking up dense documents a basic set of highlighters beats squinting at a tablet. Full disclosure: those are Amazon affiliate links — they help keep this blog running and cost you nothing extra.

    That’s the whole thing. The SpaceX 424B prospectus is public, the terms are a $135 IPO price on 555.5M shares for a ~$75B raise, and you can pull any company’s filing URL with standard-library Python in under a second. Stop trusting screenshots. Go to the source.

    If you came here for the primary-source habit, the same logic applies to Congress: pull the latest House stock trades yourself straight from the Clerk of the House instead of a dead aggregator.


    Join https://t.me/alphasignal822 for free market intelligence.

  • Why the Web Crypto API Won’t Compute MD5 (and How HashForge Does It in Your Browser)

    Last week I needed an MD5 checksum to verify a file against a vendor’s published manifest. Old habit kicked in: open devtools, reach for the Web Crypto API, type one line. It failed on the spot:

    await crypto.subtle.digest('MD5', new TextEncoder().encode('abc'))
    // DOMException: Algorithm: Unrecognized name MD5

    No MD5. Not deprecated-with-a-warning — just absent, like it was never on the menu. That single rejection is the whole reason HashForge, the in-browser hash generator I keep bookmarked, ships its own MD5 routine instead of asking the browser. Here’s why the browser says no, and how HashForge works around it without uploading your file anywhere.

    The Web Crypto API blocks MD5 on purpose

    The digest side of the Web Crypto API supports exactly four algorithms: SHA-1, SHA-256, SHA-384, and SHA-512. That list is fixed in the W3C spec. MD5 isn’t missing because nobody filed a ticket — the working group left it out, along with MD4, because shipping a broken hash through an API named “crypto” invites people to misuse it.

    MD5 has had practical collision attacks since 2004, when Wang and Yu produced two different inputs with the same digest by hand-tuning the message. By 2008 researchers used MD5 collisions to forge a rogue CA certificate. The hash is finished for anything where an attacker controls the input.

    Here’s the part I find funny: the browser still lets you compute SHA-1, which Google and CWI fully collided in 2017 with the SHAttered attack. SHA-1 stayed in the spec for backward compatibility with existing protocols. MD5 never made the cut at all. The vendors drew a line, and MD5 landed on the wrong side of it.

    I agree with that call for new code. The catch is that the rest of us still bump into MD5 constantly, and almost never for security:

    • Vendor downloads still publish an MD5 next to the file
    • S3 ETags are the MD5 of the object for single-part uploads
    • Legacy rows store md5(email) for Gravatar-style lookups
    • Plenty of internal tools fingerprint content with MD5 because it’s fast and short

    So you hit a wall. The data is MD5, the browser refuses to compute MD5, and you would rather not paste a confidential file into some random “free MD5 online” site that ships it off to a server you’ve never audited.

    How HashForge fills the gap

    HashForge splits the work in two. For the SHA family it calls the native API — fast, audited, hardware-accelerated on most machines:

    const ALGOS = ['MD5','SHA-1','SHA-256','SHA-384','SHA-512'];
    
    async function hashText(text, algos, enc='hex'){
      const encoded = new TextEncoder().encode(text);
      const out = {};
      for (const algo of algos){
        if (algo === 'MD5'){
          out[algo] = formatHash(md5(encoded.buffer), enc);     // pure JS
        } else {
          const hash = await crypto.subtle.digest(algo, encoded); // native
          out[algo] = formatHash(hash, enc);
        }
      }
      return out;
    }

    For MD5 it falls back to a self-contained JavaScript implementation — the classic safeAdd / bitRotateLeft / md5cmn routine you’ve seen in a dozen libraries, working directly on an ArrayBuffer. No dependency, no network call, a couple hundred lines of code.

    Why MD5 is small enough to ship inline

    MD5 is a Merkle–Damgård construction. It pads the message to a multiple of 512 bits, then chews through it one 512-bit block at a time, updating four 32-bit state words across 64 operations grouped into 4 rounds. The whole thing is integer addition, bit rotation, and a handful of boolean mixing functions. That’s it — no S-boxes, no lookup tables, no big constants beyond a sine-derived table you can generate in one line.

    Because the algorithm is so plain, a correct MD5 fits in a few hundred bytes of minified JavaScript. SHA-512 by hand would be heavier and slower in JS, which is exactly why HashForge doesn’t reimplement the SHA family — the native crypto.subtle path is both faster and already vetted. You only drop to hand-rolled code for the one algorithm the platform won’t give you.

    The privacy detail that actually matters

    Files go through the same split. The page reads the file with file.arrayBuffer() and hands the raw bytes straight to either the native digest or the JS MD5:

    const buf  = await file.arrayBuffer();
    const hash = await crypto.subtle.digest('SHA-256', buf);

    That arrayBuffer() call is the whole privacy story. The bytes are read into memory inside your tab and never touch a network socket. Open the Network panel while you hash a 200 MB ISO and you’ll see zero requests. Pull your wifi and it keeps working, because there was never a server in the loop. Compare that to the typical “online hash calculator,” which POSTs your file to a backend and trusts you to believe their retention policy.

    Verify the output yourself in ten seconds

    Don’t take my word that the MD5 path is correct — a hash tool that quietly mis-pads is worse than no tool. Hash the empty string and abc, then check against the canonical test vectors:

    MD5("")        = d41d8cd98f00b204e9800998ecf8427e
    MD5("abc")     = 900150983cd24fb0d6963f7d28e17f72
    SHA-256("abc") = ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad

    Type abc into HashForge and you’ll get those exact bytes. I cross-checked them against md5sum and sha256sum on a Linux box before trusting the tool with anything real. Two-minute habit, and it catches a surprising number of broken implementations.

    HMAC is native-only, and that’s the right limit

    One place HashForge refuses to fill a gap: HMAC. It offers HMAC-SHA1/256/384/512 and stops there, because Web Crypto’s importKey plus sign('HMAC', ...) only accepts the SHA family. There’s no HMAC-MD5 button.

    That’s correct, not lazy. If you’re computing an HMAC you’re authenticating something, and HMAC-MD5 has no place in new code. The tool steers you to SHA-256 by simply not offering the broken option — the same stance the browser takes on raw MD5, applied one layer up.

    Which hash for which job

    A quick field guide, because this question comes up every week:

    • Matching a published checksum: use whatever the publisher used, MD5 or SHA-256. You’re catching accidental corruption, not an attacker, so a broken hash is fine here.
    • Content fingerprint, cache key, dedup: SHA-256 if you have a free choice; MD5 only to match an existing system.
    • Passwords: none of these. Use Argon2 or bcrypt. A raw SHA-256 of a password is still a leak waiting to happen.
    • Tokens and signatures: HMAC-SHA256 at minimum.

    If you want the actual math behind why MD5 fell and SHA-256 holds, Serious Cryptography by Jean-Philippe Aumasson is the clearest book I’ve found on collision attacks without drowning you in proofs. For the engineering side — where each primitive shows up in TLS, signatures, and storage — Real-World Cryptography by David Wong is the one I lend out most. Full disclosure: both are Amazon affiliate links.

    Why I keep it bookmarked

    The pitch is narrow and that’s the point. I need a hash, I can’t install a CLI on a locked-down work laptop, and I really don’t want to upload a file to a stranger’s server. HashForge does that one job: it computes all five digests at once, outputs hex or Base64, and runs on a text string or a dropped file. It pairs with the other browser-only tools I reach for — Base64Lab when I need to decode a token and PassForge when I need a random key — none of which phone home.

    Try it: HashForge. Hash something, open your Network tab, and watch nothing happen.

    Related reading: How a secure password generator actually works, catching leaked secrets in your git history, and why your online SQL formatter might be logging your data.


    Join https://t.me/alphasignal822 for free market intelligence.

  • How a Secure Password Generator Actually Works (and Why Math.random() Fails)

    Last week I was reviewing a small auth service and found this one-liner generating reset tokens:

    const token = Array.from({length: 16}, () =>
      CHARS[Math.floor(Math.random() * CHARS.length)]
    ).join('');

    It runs. It produces things like xK9$mLp2@nQ7vR4w. It also happens to be a real security bug. That exact pattern is the one I deliberately avoided when I built our free password generator — and the reason is worth 1,200 words, because almost every “roll your own” password snippet on the web gets it wrong in the same way.

    Here’s what’s broken about Math.random() for passwords, the fix, and the two gotchas that bite people who try to fix it themselves.

    Math.random() is predictable by design

    In V8 — the engine behind Chrome and Node — Math.random() has used an algorithm called xorshift128+ since version 4.9.40, shipped in late 2015. (Before that it was MWC1616, which was worse: only about 232 possible outputs.) xorshift128+ has 128 bits of internal state, a period of 2128 − 1, and it passes the TestU01 statistical suite. Statistically, the numbers look random.

    But “looks random” and “unpredictable” are different properties. xorshift128+ is a pseudo-random generator: every output is a deterministic function of that 128-bit state. And the state is recoverable. Feed enough consecutive outputs into a system of linear equations and you can solve for the internal state — there are public tools on GitHub that recover it from as few as 64 to 128 consecutive Math.random() calls. Once an attacker has the state, every future output is known. Every “random” password you generate after that point is predictable.

    For a UI animation or a Monte Carlo sim, who cares. For a password, an API key, or a session token, that’s the whole ballgame.

    crypto.getRandomValues() is the actual fix

    Browsers ship a cryptographically secure RNG (CSPRNG) through the Web Crypto API: crypto.getRandomValues(). It pulls from the operating system’s entropy pool (/dev/urandom on Linux, BCryptGenRandom on Windows) and is built so that observing past output tells you nothing about future output. There’s no recoverable 128-bit state to solve for.

    The function our generator uses is four lines:

    function secureRandom(max) {
      const arr = new Uint32Array(1);
      crypto.getRandomValues(arr);
      return arr[0] % max;
    }

    Read a fresh 32-bit unsigned integer from the CSPRNG, reduce it into the range you need, done. Swap Math.random() for this and the prediction attack above is gone. But notice that % max — that’s gotcha number one.

    Gotcha 1: modulo bias is real (but size matters)

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

    expected per character:  100,000
    lowest-frequency char:   ~96,900 hits
    highest-frequency char: ~121,400 hits
    ratio: 1.25

    That’s a 25% skew. It happens because 256 % 62 = 8, so byte values 0–7 each give one extra shot to the first eight characters. With a single byte feeding a 62- or 94-character set, the bias is large and easy to measure.

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

    But here’s the part the “always use rejection sampling” advice skips: the bias depends entirely on how big your random integer is relative to the alphabet. Our generator doesn’t read a single byte — it reads a full Uint32 (range 0 to about 4.29 billion). For a 94-character symbol set, Uint32 % 94 makes the favored characters more likely by roughly 1 part in 45 million — a bias of 0.0000022%. For a password, that’s noise far below anything that matters. So I skipped rejection sampling on purpose and kept the code simple, because a 32-bit draw already makes the bias irrelevant. If I were minting cryptographic keys I’d add the rejection step; for human passwords, a wide draw is enough.

    Gotcha 2: the 64KB quota wall

    The second surprise showed up while I was running that bias test. My first attempt asked getRandomValues() to fill one big buffer:

    crypto.getRandomValues(new Uint8Array(620000));
    // QuotaExceededError: The requested length exceeds 65,536 bytes

    getRandomValues() refuses any request over 65,536 bytes (64 KB) in a single call. It’s in the spec and every browser enforces it. If you’re generating one 16-character password you’ll never hit it, but the moment you batch-generate or fill a large buffer, you have to chunk:

    function fillSecure(buf) {
      for (let i = 0; i < buf.length; i += 65536) {
        crypto.getRandomValues(buf.subarray(i, i + 65536));
      }
    }

    Undocumented in most tutorials, and a hard failure rather than a silent one — which is at least honest of it.

    Why browser-only matters here

    Our generator runs entirely in your browser. The password is built on your machine from your OS entropy and never touches a network. That’s not a tagline — it’s the only design that makes sense for a secret. A “password generator” that does the work server-side is a service that has seen your password in plaintext, which is the same trust problem I wrote about with online SQL formatters quietly logging queries. Open the dev tools, watch the Network tab while you click generate, and you’ll see exactly zero requests.

    You can try it here: the orthogonal.info password generator. Slide to 16+ characters, toggle the symbol set, copy, done.

    One layer is never enough

    A strong, truly-random password fixes the “guessable” problem. It does nothing about phishing, reused credentials, or a leaked database. After the LastPass mess I moved my own vault into KeePassXC and put a hardware key on every account that supports one. A YubiKey 5 NFC turns a stolen password into a useless string, because login also needs the physical key in my pocket. Full disclosure: that’s an affiliate link — but it’s also literally what’s on my keyring. Generate unique passwords, store them in a real manager, and gate the important accounts with hardware 2FA. Three cheap layers beat one strong one.

    The lesson I keep relearning: in security, the code that “works” and the code that’s correct are often the same length and completely different. Math.random() works. crypto.getRandomValues() is correct.


    Want signal instead of noise on markets and tech? Join https://t.me/alphasignal822 for free market intelligence.

  • How EXIF GPS Data Is Stored in a JPEG — A Byte-Level Teardown

    Last week I wanted to prove a point to a friend who insisted his vacation photos were “fine to post.” So I opened one of his JPEGs in a hex editor, scrolled about 40 bytes in, and read his hotel’s GPS coordinates straight off the screen — no tools, no library, just the raw bytes. That’s the thing nobody tells you about EXIF: it isn’t encrypted, hashed, or hidden. It’s sitting near the front of almost every photo your phone takes, in a format you can decode by hand once you know the layout. This post is the byte-level teardown, and at the end I’ll show why PixelStrip removes that data without touching a single pixel.

    A JPEG is just a stream of markers

    Every JPEG starts with two bytes: FF D8, the Start Of Image marker. After that the file is a sequence of segments, and every segment begins with FF followed by a marker byte. The one we care about is FF E1 — that’s APP1, where EXIF lives.

    Here’s the front of a real photo, annotated:

    FF D8              SOI (start of image)
    FF E1              APP1 marker  <- EXIF starts here
    00 84              segment length = 0x0084 = 132 bytes (big-endian, always)
    45 78 69 66 00 00  "Exif\0\0"
    49 49              "II" = Intel / little-endian byte order
    2A 00              42, the TIFF magic number
    08 00 00 00        offset to first IFD = 8

    Two details trip people up here. First, that segment-length field is always big-endian, because it’s part of the JPEG container, not the EXIF payload. Second, the byte order flag (II for little-endian, MM for big-endian) only applies to everything after the Exif\0\0 header. From that point on, every multi-byte number flips based on those two bytes.

    The TIFF header and IFD entries

    What follows Exif\0\0 is a tiny TIFF file. All internal offsets are measured from the start of the byte-order mark — not the start of the file. Forget that and every pointer you read lands in the wrong place. I’ve debugged this exact off-by-six error more times than I’d like to admit.

    The 4-byte offset (here 08 00 00 00 = 8) points to the first Image File Directory, or IFD0. An IFD is dead simple:

    • 2 bytes: how many entries follow
    • 12 bytes per entry
    • 4 bytes at the end: offset to the next IFD (0 means stop)

    Each 12-byte entry breaks down as: a 2-byte tag ID, a 2-byte data type, a 4-byte value count, and a 4-byte field that holds either the value itself (if it fits in 4 bytes) or an offset to where the value actually lives. GPS coordinates don’t fit in 4 bytes, so they’re always stored by offset.

    The tag we hunt for in IFD0 is 0x8825 — the GPS IFD pointer. Its value is an offset to a separate sub-directory holding the location tags. Jump there and you find the payload.

    Decoding latitude by hand

    The GPS sub-IFD uses a handful of tags. The important ones:

    • 0x0001 GPSLatitudeRef — ASCII “N” or “S”
    • 0x0002 GPSLatitude — three RATIONAL values: degrees, minutes, seconds
    • 0x0003 GPSLongitudeRef — “E” or “W”
    • 0x0004 GPSLongitude — three more RATIONALs

    A RATIONAL is two 4-byte unsigned integers: a numerator followed by a denominator. So latitude is three of them — 24 bytes total. Here’s the actual block from that photo, little-endian:

    25 00 00 00  01 00 00 00   ->  37 / 1   = 37 degrees
    2E 00 00 00  01 00 00 00   ->  46 / 1   = 46 minutes
    C4 0B 00 00  64 00 00 00   ->  3012 / 100 = 30.12 seconds

    Convert degrees-minutes-seconds to decimal: 37 + 46/60 + 30.12/3600 = 37.7750° N. Pair that with the longitude block and you have a point accurate to roughly three meters. That’s precise enough to land on a specific building. My friend went quiet after I read his back.

    A 40-line parser in the browser

    You don’t need a library to do this. Browser DataView reads typed values out of an ArrayBuffer with explicit endianness, which is exactly what EXIF needs. Here’s the core of finding the APP1 segment and its byte order:

    function findExif(view) {
      let offset = 2; // skip the FF D8 SOI
      while (offset < view.byteLength) {
        if (view.getUint8(offset) !== 0xFF) break;
        const marker = view.getUint8(offset + 1);
        const size = view.getUint16(offset + 2); // big-endian on purpose
        if (marker === 0xE1) {
          const tiff = offset + 10;            // skip marker, length, "Exif\0\0"
          const le = view.getUint16(tiff) === 0x4949; // "II"
          return { tiff, littleEndian: le, app1Start: offset, size };
        }
        offset += 2 + size; // jump to the next segment
      }
      return null;
    }

    Note that getUint16 defaults to big-endian, which is correct for the JPEG segment length. Once you have the littleEndian flag, you pass it to every read inside the TIFF block. Reading a RATIONAL is two reads and a divide:

    function readRational(view, pos, le) {
      return view.getUint32(pos, le) / view.getUint32(pos + 4, le);
    }

    That’s the whole trick. Walk the IFD entries, find tag 0x8825, jump to the GPS sub-IFD, pull the latitude and longitude rationals, and apply the N/S/E/W sign. About 40 lines, no dependencies, runs offline.

    Two ways to strip it — and why they differ

    Now the part that actually matters. There are two ways to remove this metadata, and they are not equal.

    Re-encode the whole image. Draw the photo onto a <canvas> and call toBlob(). The new file is built from raw pixels, so it carries no EXIF at all. Clean — but every pixel gets recompressed, which means slight quality loss and a completely different byte layout. That’s the approach my QuickShrink compressor takes, and I wrote up the mechanics in how browser image compression actually works. Good when you also want a smaller file.

    Splice out the segment. If all you want is to delete the metadata and keep the image untouched, you cut the APP1 segment out of the byte stream and leave everything else identical:

    const out = new Uint8Array(bytes.byteLength - (2 + size));
    out.set(bytes.subarray(0, app1Start));
    out.set(bytes.subarray(app1Start + 2 + size), app1Start);

    The pixels stay bit-for-bit identical. No recompression, no quality loss, no visible change — just the location data gone. That’s what PixelStrip does.

    One gotcha worth knowing: a single JPEG can carry more than one metadata block. EXIF lives in APP1, but XMP often rides in a second APP1, Photoshop data sits in APP13, and the EXIF thumbnail in IFD1 can hold its own copy of the GPS tags. A parser that removes only the first APP1 it sees will miss the rest. A real stripper loops over every APPn segment, which is the unglamorous part most “remove EXIF” snippets skip.

    What to actually do with this

    If you only remember one rule: platforms are inconsistent. Twitter and iMessage scrub metadata on upload; Discord, email attachments, Slack file shares, and most forums pass it through untouched. Assume the worst and clean photos before they leave your machine.

    For a one-click clean that keeps your image quality intact, drop the photo into PixelStrip — it runs entirely in your browser, so the file never uploads anywhere, and it surgically removes EXIF, GPS, and XMP without recompressing. If you want the privacy reasoning rather than the byte layout, I covered that in how to strip EXIF data before sharing. The rest of the browser tools follow the same no-upload rule.

    If you want to go deeper than a hex editor, file-format forensics books cover exactly this kind of byte-level metadata extraction across image, document, and filesystem formats — a solid digital forensics reference is what I keep on the shelf for the weird edge cases (full disclosure: Amazon affiliate link). It’s the difference between guessing at an offset and knowing why it’s there.


    Join https://t.me/alphasignal822 for free market intelligence.

Also by us: StartCaaS — AI Company OS · Hype2You — AI Tech Trends