Category: Security

Security is the dedicated cybersecurity category on orthogonal.info, covering everything from application-level secure coding practices to network-layer defenses and zero-trust architecture. In an era where a single misconfigured cloud bucket or unpatched dependency can lead to a headline-making breach, this category provides the practical, hands-on guidance that engineers need to build and maintain secure systems. Each article blends defensive theory with real commands, configurations, and code you can apply immediately.

With 21 posts spanning offensive and defensive security topics, this collection reflects a practitioner’s perspective — not checkbox compliance, but genuine risk reduction.

Key Topics Covered

Application security (AppSec) — Secure coding patterns, input validation, OWASP Top 10 mitigations, and static analysis with tools like Semgrep, Bandit, and CodeQL.
Network security and firewalls — Configuring OPNsense, pfSense, VLANs, WireGuard tunnels, and network segmentation strategies for home and production environments.
CVE analysis and vulnerability management — Dissecting real-world CVEs, understanding CVSS scoring, and building patch management workflows with Trivy, Grype, and OSV-Scanner.
Penetration testing and red teaming — Practical walkthroughs using Nmap, Burp Suite, Nuclei, and Metasploit to identify weaknesses before attackers do.
Zero-trust architecture — Implementing identity-aware proxies, mutual TLS, and least-privilege access using Cloudflare Access, Tailscale, and SPIFFE/SPIRE.
Container and Kubernetes security — Pod security standards, image scanning, runtime protection with Falco, and supply-chain security with Sigstore and cosign.
Secrets management — Storing and rotating secrets with HashiCorp Vault, SOPS, Sealed Secrets, and cloud-native key management services.
Compliance and hardening — CIS Benchmarks, STIGs, and automated compliance scanning for Linux hosts, containers, and cloud accounts.

Who This Content Is For
This category serves security engineers, DevSecOps practitioners, penetration testers, platform engineers, and system administrators who take security seriously without wanting to drown in vendor marketing. Whether you are hardening a homelab, preparing for a SOC 2 audit, or building a secure CI/CD pipeline, the guides here are written by and for people who ship code and defend infrastructure daily.

What You Will Learn
Readers of the Security category will gain the skills to identify and remediate vulnerabilities across the full stack — from source code to running containers to network perimeters. You will learn how to integrate security scanning into CI/CD pipelines, configure firewalls with defense-in-depth principles, analyze CVE disclosures to assess real-world impact, and implement zero-trust networking without crippling developer velocity. Every article prioritizes actionable steps over abstract theory.

Explore the posts below to strengthen your security posture today.

  • What SHA-256 Checksums Prove — Verify Files with HashForge

    Last week I downloaded a command-line release and found the familiar pair of links: a binary and a SHA-256 checksum. I dropped the file into HashForge, got a matching digest, and felt reassured for about five seconds. Then I noticed that the binary and its checksum came from the same server.

    If that server had been compromised, an attacker could have replaced both. The matching hash would still be mathematically correct. It just would not answer the question I actually cared about: did this file come from the real publisher?

    That distinction—integrity versus authenticity—is easy to miss. HashForge makes the integrity check quick and private, but the result is only as trustworthy as the reference hash you compare it with.

    What a matching hash actually proves

    A cryptographic hash turns any number of bytes into a fixed-length fingerprint. SHA-256 always returns 256 bits, usually displayed as 64 hexadecimal characters. Change one bit in the input and the output should change unpredictably.

    When HashForge produces the same SHA-256 value that a publisher lists, you have strong evidence that your local file is byte-for-byte identical to the file the publisher hashed. That catches an incomplete download, disk corruption, a broken mirror, or an accidental replacement.

    HashForge performs that work in your browser. Its live code uses the browser’s crypto.subtle.digest() API for SHA-family hashes, and the file stays on your machine. The Web Crypto digest API exposes SHA-1, SHA-256, SHA-384, and SHA-512. It does not expose MD5, so HashForge includes a local JavaScript MD5 implementation for compatibility checks.

    Integrity is not authenticity

    Imagine a release page serves tool.tar.gz and SHA256SUMS from the same origin. An attacker who controls that origin can upload a backdoored archive, calculate its SHA-256 value, and replace the checksum file. Your comparison passes because the two malicious artifacts agree.

    HTTPS helps protect the connection between your browser and the server. It does not prove that the server, build pipeline, maintainer account, or release artifact was trustworthy before the connection began.

    A checksum becomes more useful when the reference arrives through an independent authenticated path: a signed release manifest, a package manager with signed metadata, a maintainer’s verified channel, or a second domain controlled separately. The independence matters more than the visual length of the hash.

    MD5 and SHA-1 need careful language

    MD5 and SHA-1 are broken for collision resistance. In 2017, Google’s and CWI Amsterdam’s SHAttered demonstration produced two visibly different PDF files with the same SHA-1 digest. MD5 collision attacks had become practical much earlier.

    A collision means an attacker can create two different inputs with the same hash. It does not automatically mean they can take any existing file you choose and manufacture a malicious replacement with the same digest; that is a different problem called a second-preimage attack. Chosen-prefix collision techniques are still enough to make MD5 and SHA-1 unacceptable when an adversary can shape both artifacts.

    I keep MD5 in a tool only for identifying old files or matching a legacy checksum, never as proof against an attacker. SHA-256 remains the sensible default for file integrity. No practical SHA-256 collision is known.

    The download check I use with HashForge

    1. Download the artifact from the publisher’s HTTPS release page.
    2. Find the publisher’s SHA-256 value. Prefer a signed checksum manifest or an independent official channel.
    3. Open HashForge and drop in the file. The browser computes the digest locally; the file is not uploaded.
    4. Paste the expected value into the comparison field. Compare the full digest, not the first or last few characters.
    5. If the project offers a signature, verify it as a separate step. A matching checksum does not replace signature verification.

    The local processing matters when the artifact is proprietary, contains customer data, or comes from an internal build. Uploading a confidential binary to a random checksum site creates a new disclosure risk just to answer a local math question.

    Terminal equivalents for repeatable builds

    HashForge is convenient for an occasional manual check. In a build script, I use the operating system’s command so the expected value can be pinned and the job can fail closed:

    # macOS
    shasum -a 256 tool.tar.gz
    
    # Linux
    sha256sum tool.tar.gz
    
    # PowerShell
    Get-FileHash .\tool.zip -Algorithm SHA256

    Do not paste a shortened digest into CI. A full SHA-256 value is only 64 characters, and truncating it deliberately throws away collision resistance. Store the expected value in reviewed source control or consume a signed manifest.

    When a signature is the real answer

    A digital signature binds a digest to a private key. Verification checks both the file and proof that the signer controlled that key. NIST’s Digital Signature Standard guidance describes that authenticity property; modern software projects may use GPG, platform code signing, or Sigstore.

    The remaining question is how you trust the public key or identity. A signature from an unknown key is not useful. Look for a fingerprint on the project’s established site, a verified maintainer identity, a package ecosystem’s trust root, or Sigstore’s identity and transparency-log checks.

    HMAC is different again. It authenticates data between parties that share a secret, which is why I use HashForge’s HMAC panel to debug webhook signatures. A public download page cannot safely give every visitor the HMAC secret, so HMAC is not a substitute for public-key release signing.

    A five-second threat model

    Risk Does a plain SHA-256 comparison help? Better control
    Accidental corruption Yes Checksum comparison
    Broken or stale mirror Yes, with an independent reference Official checksum
    Network tampering Somewhat HTTPS plus an independent checksum
    Compromised download server No, if it hosts both files Signed release manifest
    Malicious publisher or stolen signing key No Reproducible builds, transparency logs, key revocation

    What I trust in practice

    For a low-risk utility, I want HTTPS and a SHA-256 value from the official release page. For an installer, firmware image, wallet, security tool, or production dependency, I look for a signature or signed package metadata as well. For high-impact infrastructure, I also want reproducible builds or a transparency log.

    Use HashForge when you need a fast browser-only checksum, and keep the claim precise: a match proves that two byte sequences agree. It does not tell you who created those bytes.

    Affiliate disclosure: If you want a deeper treatment of hashes, signatures, and real-world cryptographic failures, this Amazon search for Serious Cryptography uses our affiliate tag. We may earn a commission at no extra cost to you.

    For more browser-only developer tools, see the Orthogonal tools page and my field notes on decoding JWTs locally.

    Alpha Signal: Join the free Telegram channel for 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

  • Reading a JWT Offline: How to Spot alg:none and Algorithm Confusion Before They Bite

    A pentester friend sent me a JWT last month with a one-line note: “spot the bug in 10 seconds.” I pasted the three segments into Base64Lab, flipped on URL-safe decoding, and read the header. The alg field said none. That token had no signature at all, and the backend was accepting it. Ten seconds, exactly.

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

    A JWT is three Base64url blobs, not encryption

    People treat JWTs like ciphertext. They’re not. A JWT is three chunks joined by dots:

    eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NSIsInJvbGUiOiJ1c2VyIn0.dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
    [----- header -----].[--------- payload ---------].[------------- signature -------------]

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

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

    Bug #1: alg is “none”

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

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

    So the first thing I decode is the header. Grab the part before the first dot and decode it:

    // header segment
    eyJhbGciOiJub25lIn0
    // decoded
    {"alg":"none"}

    If you ever see none in production, that’s a critical finding. Your validation library should reject it outright — modern versions of most libraries do, but only if you pin the expected algorithm on the verify call.

    Bug #2: HS256 where you expected RS256

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

    If a verify function is written like this, it’s vulnerable:

    // BAD: trusts whatever alg the token claims
    jwt.verify(token, keyOrSecret);

    Because the library picks the algorithm from the attacker-controlled header. The fix is to pin it:

    // GOOD: server dictates the algorithm
    jwt.verify(token, publicKey, { algorithms: ['RS256'] });

    Reading the header offline tells you instantly which algorithm a token claims. If your service issues RS256 tokens but you’re staring at an HS256 header, someone is probing you.

    Bug #3: secrets and PII sitting in the payload

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

    Decode the middle segment and actually look at what you’re shipping to the client:

    {"sub":"12345","role":"user","email":"[email protected]","iat":1752000000,"exp":1752003600}

    Check the exp claim too. It’s a Unix timestamp. If it’s missing, the token never expires, which turns a single leaked token into permanent access. (If you want to sanity-check those timestamps, our Unix timestamp converter turns 1752003600 into a human date in one paste.)

    Why I decode offline, every time

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

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

    Base64Lab does the decode entirely in the browser. There’s no server round-trip — the JSON never leaves your machine, so a token you paste to inspect stays local. You can confirm it yourself: open the page, kill your network connection, and it still decodes. That’s the property I want from anything touching a credential. I wrote more about that reasoning in why I stopped pasting JWTs into online decoders.

    My 30-second token triage

    When a token lands in front of me, the routine is always the same:

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

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

    If you spend real time in auth code or security reviews, a physical reference beats a browser tab. I keep a copy of Web Application Security by Andrew Hoffman (O’Reilly) on the desk — its token and session chapters are the clearest treatment of this class of bug I’ve found. Full disclosure: that’s an Amazon affiliate link.

    Decode your next JWT offline in Base64Lab — URL-safe toggle on, no upload, no server. Then go check whether your verify calls actually pin the algorithm.


    Want signal without the noise? 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.

  • 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, and DiffLab for comparing text and config files without uploading them — all of them in the free tools collection.


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

    Related reading

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

  • Your Online SQL Formatter Might Be Logging Your Database Password

    Last month I watched a contractor paste a full Kubernetes secret manifest — base64 blobs and all — into the first “free YAML validator” that came up on Google. He just wanted to check indentation. What he actually did was POST a production database password to a server he’d never heard of, run by people he’ll never meet, with a privacy policy he didn’t read.

    That’s the part of online dev tools nobody talks about. A SQL formatter, a YAML validator, a JSON beautifier — they feel disposable, like a calculator. But a huge number of them send whatever you paste to a backend for processing. If that paste contains a connection string, an API key, or a customer record, you just leaked it. No breach required. You handed it over.

    Why “format my SQL” is a data exfiltration path

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

    The problem is what travels in that request body. I tested a handful of popular online formatters with my browser’s Network tab open. Several of them sent the entire input payload to their own domain. One sent it to a third-party API. The query I pasted was harmless test data, but the request was real — my text left my machine.

    Now picture the realistic version. You’re debugging a failing migration at 11pm. You copy the offending query straight out of your ORM logs to “just clean it up.” That query has a hardcoded credential a teammate left in six months ago. You paste, you format, you move on. The credential is now in someone’s request logs, maybe their analytics, maybe an LLM training pipeline if the tool resells data. You will never know.

    This isn’t paranoia. It’s the same threat model that makes pasting code into random pastebins a fireable offense at most security-conscious shops. We just don’t apply it to “format” tools because they feel too small to matter.

    The browser-only alternative

    The fix is structural, not procedural. Don’t rely on remembering to scrub secrets first — use tools that physically can’t send your data anywhere, because all the work happens in your tab.

    That’s the whole reason I built our formatters as single-file, client-side apps. When you use the SQL Formatter, the YAML Validator, or the Diff Checker, the parsing and formatting runs in JavaScript on your device. There is no /api/format endpoint. There’s no backend at all. The text in your textarea never crosses the network, because there’s nowhere for it to go.

    For a diff tool this matters even more. People routinely paste two versions of a config file — say, a working .env and a broken one — to spot what changed. Those files are nothing but secrets. A browser-only diff means you can compare two API keys character by character without either one leaving your laptop.

    How to actually verify a tool is client-side

    Don’t take any tool’s word for it, including mine. Verifying is a two-minute job and every developer should know how.

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

    // What a server-side formatter looks like in Network tab:
    POST /api/format-sql
    Request Payload: { "query": "SELECT * FROM users WHERE token='sk_live_...'" }
    
    // What a client-side tool looks like:
    // (nothing — no request fires when you click format)

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

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

    Where this fits in a real workflow

    A few concrete cases where I reach for browser-only tools specifically because of the data:

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

    The broader point: treat every “paste your text here” box as a potential outbound network call until you’ve proven otherwise. Most of the time it’s fine. The one time it isn’t, it’s a leaked credential you can’t un-leak.

    Defense in depth still applies

    Browser-only tools remove one exfiltration path, but they don’t make you immune to the dumber failure modes — like a secret sitting in your shell history or git log in the first place. If you handle credentials daily, a hardware key cuts a whole class of phishing and credential-theft risk off at the knees. I use a YubiKey 5 Series for exactly this (full disclosure: affiliate link, but it’s the same key I carry on my own keyring). Pair that with the pre-commit secret scanning setup I wrote about earlier, and you’ve closed the two most common ways credentials walk out the door.

    Start with the small habit, though. Next time you reach for an online formatter or diff tool, open the Network tab first. If your text leaves the browser, find one that keeps it home.


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

  • I Switched to KeePassXC After LastPass Got Breached — Here’s My Setup

    Last December I got the email every LastPass user dreaded: my vault backup was part of the breach. The master password was strong, but knowing encrypted blobs of my entire digital life were sitting on some attacker’s disk made me physically uncomfortable. I spent a weekend migrating everything to KeePassXC, and six months later I’m not going back.

    Why Local-First Matters for Passwords

    The LastPass breach exposed a fundamental problem with cloud password managers: your encrypted vault is only as safe as the infrastructure storing it. LastPass used 100,100 PBKDF2 iterations for newer accounts — older accounts had as few as 5,000. That’s crackable with a decent GPU rig.

    KeePassXC stores everything in a single .kdbx file on your machine. No servers, no breach notifications, no third-party trust. The file uses AES-256 or ChaCha20 encryption with Argon2d key derivation — you control the iteration count, memory usage, and parallelism. I run mine at 64MB memory / 10 iterations / 4 threads, which takes about 1 second to unlock on my laptop but would cost serious money to brute-force.

    The Setup That Actually Works Day-to-Day

    The knock against local password managers has always been “but what about sync?” Fair point. Here’s how I solved it without trusting anyone else with my vault:

    # My .kdbx lives in a Syncthing folder shared between:
    # - Work laptop (Linux)
    # - Personal desktop (Windows)
    # - Phone (via Syncthing + KeePassDX on Android)
    
    ~/.local/share/syncthing/vault/
    ├── passwords.kdbx
    └── passwords.kdbx.key   # key file (separate from master password)

    Syncthing handles peer-to-peer sync over my local network and WireGuard tunnel when I’m away. The vault never touches anyone else’s servers. Conflict resolution? KeePassXC handles .kdbx merge conflicts natively since version 2.7 — it’ll prompt you to merge changes if two devices edited simultaneously.

    Hardware Key as Second Factor

    This is where it gets good. KeePassXC supports YubiKey challenge-response as an additional key factor. My unlock requires:

    1. Master password (memorized, 6 random words)
    2. Key file (stored only on my devices, never synced to cloud)
    3. YubiKey HMAC-SHA1 challenge-response (slot 2)

    Setting this up:

    # Program YubiKey slot 2 for HMAC-SHA1 challenge-response
    ykman otp chalresp --generate 2
    
    # In KeePassXC: Database → Database Security → Add Additional Protection
    # Select "Challenge-Response" → pick your YubiKey

    An attacker who steals my .kdbx file needs all three factors. Even if they get my laptop with the key file, they still need the physical YubiKey and the password. I keep a backup YubiKey 5 NFC in my safe — $50 for peace of mind that I won’t lock myself out.

    Browser Integration Without the Extension Tax

    KeePassXC’s browser integration works through a native messaging host — no network calls, no cloud sync of browser state. I tested fill speed across three setups:

    Setup Fill latency Memory overhead
    1Password (extension) 180-400ms ~85MB
    Bitwarden (extension) 120-300ms ~60MB
    KeePassXC (native messaging) 30-80ms ~12MB

    KeePassXC fills faster because it communicates through a Unix socket to the running desktop app — no HTTP round-trips, no extension JavaScript parsing the DOM. The browser add-on is just a thin UI layer.

    # Enable browser integration (Linux)
    # KeePassXC → Tools → Settings → Browser Integration
    # Check "Enable browser integration"
    # Check "Firefox" and/or "Chromium"
    # It writes the native messaging manifest automatically to:
    # ~/.mozilla/native-messaging-hosts/org.keepassxc.keepassxc_browser.json

    Honest Comparison: KeePassXC vs The Cloud Options

    vs Bitwarden — Bitwarden is the closest competitor and genuinely good. It’s open source, self-hostable (Vaultwarden), and the free tier is generous. I’d recommend it to anyone who doesn’t want to manage sync themselves. The tradeoff: you’re trusting their server-side encryption implementation, or running your own server (which means patching, backups, certificates). KeePassXC has no server component to maintain or secure.

    vs 1Password — Polished UI, great team features, expensive ($36/year individual, $60/year family). The “Secret Key” system is clever — it means 1Password can’t decrypt your vault even with a breach. But it’s closed source. You’re trusting their claims. For a solo developer who reads source code, that’s a non-starter for me.

    vs LastPass — Just don’t. After the 2022 breach, the 2023 follow-up showing employee vaults were compromised, and the consistently slow response times… there’s no reason to trust them with anything sensitive.

    The One Thing That Annoys Me

    Mobile is worse than cloud managers. Full stop. KeePassDX on Android works, but auto-fill is flaky on some apps, and you need to manually trigger sync if you added a password on desktop 30 seconds ago. I’ve accepted this tradeoff — I add most passwords on desktop anyway, and the security model is worth the occasional inconvenience on mobile.

    Migration Script

    If you’re coming from LastPass, Bitwarden, or 1Password, KeePassXC imports CSV exports directly. Here’s my cleanup script that runs after import to organize entries:

    #!/usr/bin/env python3
    """Post-import cleanup for KeePassXC CSV import.
    Removes duplicate entries and normalizes URLs."""
    import csv, sys
    from urllib.parse import urlparse
    
    def normalize_url(url):
        parsed = urlparse(url)
        return f"{parsed.scheme}://{parsed.netloc}".lower()
    
    seen = {}
    with open(sys.argv[1]) as f:
        reader = csv.DictReader(f)
        for row in reader:
            key = (row['Username'], normalize_url(row.get('URL','')))
            if key not in seen or len(row.get('Password','')) > len(seen[key].get('Password','')):
                seen[key] = row
    
    print(f"Deduplicated: {len(seen)} unique entries")

    My Recommendation

    If you’re a developer comfortable with file management and want zero cloud trust for your passwords: KeePassXC + Syncthing + YubiKey is the strongest setup I’ve found. Total cost: $50 for the YubiKey (plus a backup), everything else is free and open source.

    If you want something that “just works” across devices without any setup: Bitwarden free tier. No shame in that — it’s genuinely good software.

    For more tools and privacy-focused workflows, check out our security guides and tools section.

    Related reading: how a secure password generator actually works and the pre-commit setup that stopped 14 leaked secrets in my git history.


    Full disclosure: Amazon links above are affiliate links (tag=orthogonalinf-20). I bought my YubiKeys at full price before writing this.

    📡 Join https://t.me/alphasignal822 for free market intelligence — we cover fintech security and trading tools daily.

  • I Caught 14 Leaked Secrets in My Git History — Here’s the Pre-Commit Setup That Stops It

    Last month I ran trufflehog against one of my private repos — a homelab automation project I’d never planned to open-source. It found 14 live secrets. AWS keys, a Telegram bot token, two database passwords, and a Stripe test key that still had access to customer data. All committed between 2022 and 2024, scattered across dozens of commits.

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

    Why Two Tools: git-secrets vs trufflehog

    I use both git-secrets and trufflehog because they work differently and catch different things.

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

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

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

    Setting Up git-secrets as a Pre-Commit Hook

    Install it:

    brew install git-secrets   # macOS
    # or
    git clone https://github.com/awslabs/git-secrets.git
    cd git-secrets && make install

    Register it in your repo:

    cd your-repo
    git secrets --install
    git secrets --register-aws

    That --register-aws flag adds patterns for AWS access keys, secret keys, and account IDs. Now add your own patterns for whatever services you use:

    # Telegram bot tokens (numeric:alphanumeric format)
    git secrets --add '[0-9]{8,10}:[A-Za-z0-9_-]{35}'
    
    # Stripe keys
    git secrets --add 'sk_(live|test)_[A-Za-z0-9]{24,}'
    
    # Generic high-entropy passwords in connection strings
    git secrets --add 'password\s*=\s*[^\s]{12,}'

    Test it works:

    echo "AKIAIOSFODNN7EXAMPLE" > test.txt
    git add test.txt
    git commit -m "test"
    # Output: [ERROR] Matched one or more prohibited patterns

    One gotcha: git secrets --install only sets up hooks in that repo. For global coverage across all repos:

    git secrets --install ~/.git-templates/git-secrets
    git config --global init.templateDir ~/.git-templates/git-secrets

    Adding trufflehog as a Pre-Commit Hook

    I use the pre-commit framework for trufflehog since it handles updates and version pinning:

    # .pre-commit-config.yaml
    repos:
      - repo: https://github.com/trufflesecurity/trufflehog
        rev: v3.78.1
        hooks:
          - id: trufflehog
            entry: trufflehog git file://. --since-commit HEAD --only-verified --fail
            stages: [commit, push]

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

    Install and activate:

    pip install pre-commit
    pre-commit install
    pre-commit install --hook-type pre-push

    The Configuration Mistake That Gives False Confidence

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

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

    Fix: run a full-repo scan on a schedule. I have this in a weekly cron:

    # Scan entire repo history
    trufflehog git file:///path/to/repo --only-verified --json > /tmp/secrets-audit.json
    
    # Scan all current files (not just diffs)
    git secrets --scan

    I pipe the output to ntfy for notifications. If something shows up, I rotate the credential immediately and use git filter-repo to purge it from history:

    git filter-repo --invert-paths --path secrets.env
    # Then force-push and tell collaborators to re-clone

    What About GitHub’s Built-in Secret Scanning?

    GitHub’s secret scanning (free for public repos, paid for private) is solid but it’s a safety net, not prevention. By the time GitHub alerts you, the secret has already been pushed to a remote. If your repo was public for even 5 seconds, bots have already scraped it — I’ve seen AWS keys exploited within 4 minutes of being pushed.

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

    My Full .pre-commit-config.yaml

    Here’s what I run on every project now:

    repos:
      - repo: https://github.com/trufflesecurity/trufflehog
        rev: v3.78.1
        hooks:
          - id: trufflehog
            entry: trufflehog git file://. --since-commit HEAD --only-verified --fail
            stages: [commit, push]
    
      - repo: https://github.com/gitleaks/gitleaks
        rev: v8.18.4
        hooks:
          - id: gitleaks
            stages: [commit]

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

    Total overhead per commit: about 3 seconds. That’s a tiny price for never accidentally leaking credentials again.

    Hardware Keys Add Another Layer

    If you’re serious about credential security, pairing this with a hardware security key like the YubiKey 5 NFC means even if a secret leaks, an attacker can’t use it without physical access to your key. I wrote about my YubiKey migration previously — the short version is it took a weekend and now my GitHub, AWS, and Stripe accounts all require physical touch to authenticate.

    For teams, the YubiKey 5C NFC (USB-C) is the better pick since most developer laptops have dropped USB-A at this point.

    Practical Next Steps

    If you do nothing else today: run trufflehog git file://. in your most-used repo. You might be surprised. I was.

    Then set up the pre-commit hooks. It takes 5 minutes and the muscle-memory of “commit blocked — fix it — re-commit” builds fast. After a month you’ll instinctively reach for environment variables instead of hardcoding strings.

    Related: I previously ran Trivy against my homelab containers and found similar hygiene issues. Security scanning is one of those things where the first run is always humbling.


    Full disclosure: links to YubiKey products above are affiliate links.

    📡 Get free daily market intelligence and trading signals: Join Alpha Signal on Telegram — AI-driven analysis delivered before market open.

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