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.

  • The Frankfurter API: Pull ECB Exchange Rates as JSON (No Key, No Rate Limits)

    I needed to backfill three years of EUR/USD daily closes for a P&L report last month. My first instinct was the usual: sign up for some FX data vendor, wait for the API key email, paste it into a .env file, then discover the free tier caps me at 100 requests a month and doesn’t include historical data anyway. I’ve done this dance a dozen times. This time I didn’t.

    The Frankfurter API gives you European Central Bank reference rates as clean JSON. No key, no signup, no rate limit that I’ve ever hit. It pulls from the ECB’s daily reference rates, which are the same numbers your bank and half the finance industry quote against. Here’s how it works and where it bites.

    The one request that does 90% of the job

    Latest rates, base USD, a couple of currencies:

    curl "https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR,GBP,JPY"

    You get back exactly what you’d hope for:

    {
      "amount": 1.0,
      "base": "USD",
      "date": "2026-07-22",
      "rates": { "EUR": 0.87658, "GBP": 0.74807, "JPY": 163.07 }
    }

    Drop the symbols param and you get every currency the ECB tracks (about 30). Drop base and it defaults to EUR, since that’s the ECB’s native quote currency. The amount field lets you convert a specific sum in one shot — ?amount=250&base=USD&symbols=EUR tells you what 250 dollars is in euros without you doing the multiply.

    Historical rates on a single date

    Swap latest for an ISO date and you get that day’s fixing:

    curl "https://api.frankfurter.dev/v1/2020-01-01?base=USD&symbols=EUR"
    # {"amount":1.0,"base":"USD","date":"2019-12-31","rates":{"EUR":0.89015}}

    Notice the returned date is 2019-12-31, not the Jan 1 I asked for. That’s the first real gotcha: the ECB doesn’t publish on weekends or TARGET holidays, so Frankfurter rolls back to the last available business day. New Year’s Day has no fixing, so you get December 31st. This is correct behavior, but if you’re joining this against your own date series, align on the returned date, not the one you requested, or you’ll double-count or drop rows.

    Time series — the part I actually came for

    This is where the paid vendors usually put up a paywall. Frankfurter just hands it over with a date range using ..:

    curl "https://api.frankfurter.dev/v1/2024-01-01..2024-01-05?base=USD&symbols=EUR"
    {
      "amount": 1.0,
      "base": "USD",
      "start_date": "2023-12-29",
      "end_date": "2024-01-05",
      "rates": {
        "2023-12-29": { "EUR": 0.90498 },
        "2024-01-02": { "EUR": 0.91274 },
        "2024-01-03": { "EUR": 0.91583 },
        "2024-01-04": { "EUR": 0.91299 },
        "2024-01-05": { "EUR": 0.91567 }
      }
    }

    Weekends are simply absent — there’s no Dec 30/31 or Jan 1 in that response because there was no ECB fixing. You can leave the end date open (2024-01-01..) to pull everything up to today. For a full multi-year backfill that’s one HTTP call, and the payload is small because it’s just floats keyed by date.

    Here’s the Python I used to turn that into a pandas frame:

    import requests, pandas as pd
    
    url = "https://api.frankfurter.dev/v1/2021-01-01..?base=USD&symbols=EUR,GBP,JPY"
    data = requests.get(url).json()["rates"]
    
    df = pd.DataFrame(data).T                # dates become the index
    df.index = pd.to_datetime(df.index)
    df = df.sort_index()
    print(df.tail())

    The .T transpose matters because the JSON is keyed date → currency → rate, and DataFrame reads the outer keys as columns by default. Three lines and I had a clean daily series I could reindex to a business-day calendar and forward-fill for the missing holidays.

    Where it doesn’t fit

    Frankfurter is ECB reference data, so know what that means before you wire it into anything:

    • One price per day, not intraday. The ECB publishes a single reference rate around 16:00 CET. If you need tick data or an FX rate at 09:31:04, this is the wrong tool. It’s for reporting, accounting, and backtests on daily bars — not for trading execution.
    • It’s a reference, not a tradeable quote. There’s no bid/ask spread here. Don’t use it to mark a live position you’d actually close at market.
    • Currency coverage is ECB’s list. Majors and most liquid crosses are there. Exotic or pegged currencies the ECB doesn’t track won’t appear. No crypto either.
    • Rates update once a day, on business days. If your cron hits it at 08:00 CET you’re getting yesterday’s fixing until the new one posts.

    For the report I was building — convert historical foreign revenue to USD at each day’s official rate — those constraints are exactly what I wanted. Auditors like ECB reference rates precisely because there’s one unambiguous number per day.

    Why I trust a keyless API here

    I’m usually suspicious of “no key required” services because the business model is often “we’ll add a paywall once you depend on us.” Frankfurter is open source and the data underneath is the ECB’s public feed, which isn’t going anywhere. If the hosted instance ever disappears, you can self-host it against the same ECB XML and change one hostname. That’s the kind of dependency I’m comfortable building on — the same reason I lean on other keyless government endpoints like SEC EDGAR and the Treasury FiscalData API instead of commercial data brokers.

    If you’re doing this kind of number-crunching regularly, a second monitor pays for itself the first time you’re diffing a rate series against a spreadsheet. I run a cheap Dell 27-inch IPS monitor as a dedicated data pane (full disclosure: affiliate link). Overkill for one API, worth it once you’ve got three terminals of JSON open.

    The whole thing in one script

    import requests
    
    def convert(amount, frm, to, date="latest"):
        url = f"https://api.frankfurter.dev/v1/{date}?base={frm}&symbols={to}"
        r = requests.get(url).json()
        rate = r["rates"][to]
        return round(amount * rate, 2), r["date"]
    
    usd, on = convert(1000, "USD", "EUR", "2023-06-15")
    print(f"$1000 = €{usd} at the ECB fix on {on}")

    No key to rotate, no dashboard to log into, no free-tier counter ticking down. For daily FX in reports and backtests, this is the first thing I reach for now.


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

  • Verifying Webhook Signatures by Hand: HMAC-SHA256 in the Browser with HashForge

    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? That question is answered by one line of crypto — an HMAC-SHA256 signature — and you can check it by hand in HashForge without pasting a production secret into some random website.

    This post is about the boring, load-bearing part of webhooks that nobody documents well: how the signature header is computed, why your comparison keeps failing, and how to verify one manually when a delivery breaks.

    What the signature header actually is

    Every serious webhook provider signs the request body. GitHub sends X-Hub-Signature-256. Stripe sends Stripe-Signature. Shopify sends X-Shopify-Hmac-Sha256. Different header names, same idea:

    signature = HMAC-SHA256(secret, raw_request_body)

    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. If the two match, the message is authentic and untampered. If they don’t, you reject it with a 401 and move on.

    The reason this 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.

    Verifying a GitHub signature by hand

    GitHub’s own docs use a concrete example, which makes it perfect for a sanity check. Secret is It's a Secret to Everybody, body is Hello, World!. The expected signature is:

    757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17

    Open HashForge, switch to the HMAC panel, drop the secret into the key field and the body into the message field, pick SHA-256. You get exactly that hex string. That’s the whole verification. GitHub prefixes it with sha256= in the header, so the real value on the wire is sha256=757107ea... — strip the prefix before comparing.

    I like doing this in HashForge specifically because it runs entirely in the browser. The HMAC is computed with the Web Crypto API (crypto.subtle.sign), so your secret never leaves the tab. Check the Network panel if you don’t believe me — 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.

    The three reasons your comparison fails

    Manual verification exposes the bugs that silently break webhook handlers. In order of how often they’ve bitten me:

    1. You hashed the parsed body, not the raw body. This is the big one. Frameworks love to parse JSON for you. But JSON.stringify(JSON.parse(body)) is not the original bytes — key order changes, whitespace vanishes, unicode gets re-escaped. The signature is over the exact bytes the provider sent. In Express you need the raw buffer:

    app.post('/webhook',
      express.raw({ type: 'application/json' }),
      (req, res) => {
        const sig = req.get('X-Hub-Signature-256');
        const expected = 'sha256=' + hmacSha256(secret, req.body);
        // req.body is a Buffer here, not a parsed object
      }
    );

    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.

    2. Wrong key encoding. 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. If HashForge’s output doesn’t match and you’re sure the body is right, this is the next thing to check.

    3. Non-constant-time comparison. Once the bytes are right, don’t compare signatures with ===. A naive string compare returns early on the first mismatched character, which leaks timing information an attacker can measure. Use a constant-time compare:

    const crypto = require('crypto');
    function safeEqual(a, b) {
      const ba = Buffer.from(a), bb = Buffer.from(b);
      if (ba.length !== bb.length) return false;
      return crypto.timingSafeEqual(ba, bb);
    }

    Stripe adds a timestamp — and so should you

    Stripe’s Stripe-Signature header isn’t just the HMAC. It looks like:

    t=1699999999,v1=5257a869e7ecebeda32affa62cdca3fa...

    The signed payload is t + "." + body, not the body alone. So you concatenate the timestamp, a literal dot, and the raw body, then HMAC-SHA256 that whole string with your signing secret. To reproduce it in HashForge, paste 1699999999.{your raw body} into the message field.

    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 t is within a few minutes of now. If you’re building your own webhook sender, copy this pattern — sign the timestamp alongside the body and reject stale ones.

    When to reach for manual verification

    You don’t do this on every request — your code handles the happy path. Manual HMAC checking earns its keep in exactly three moments:

    • First integration. Before you trust your verification code, confirm it against a known payload. Recompute the signature in HashForge and diff it against what your handler produced. If they disagree, your handler is wrong, not the provider.
    • A specific delivery failed. 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.
    • Rotating secrets. After changing a signing secret, verify one real event manually before you trust the pipeline again.

    If you want the byte-level view of what’s actually being hashed, the Web Crypto API is worth understanding — it’s the same primitive HashForge uses under the hood. And if you’re inspecting the JWTs some webhooks carry instead of HMAC headers, the offline JWT reader covers that failure mode.

    Keep the secret on paper, not in a note app

    One habit worth building: webhook signing secrets are long-lived credentials, and they end up scattered across .env files, CI variables, and password managers. I keep the master copy of anything I can’t regenerate in a small hardware-backed spot rather than a cloud note. A cheap encrypted USB key for offline secret backups has saved me twice when a password manager sync went sideways. Full disclosure: that’s an Amazon affiliate link.

    The point of verifying signatures at all is that you don’t get to be sloppy with the secret. Compute the HMAC in the browser, compare in constant time, hash the raw bytes, and check the timestamp. Four rules, and your webhook endpoint stops trusting strangers.

    You can try the HMAC verification yourself right now in HashForge — pick SHA-256, paste a secret and a message, and watch the signature appear without a single network request.


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

    Related reading

  • 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.

    Related reading

  • 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.

    Related reading

  • 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.

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