
Every publication has a story about the tool it writes with. Ours started with a bug.
Webitlio launched as Curious Signal, running on a content editor called Keystatic. It worked until it didn’t: an optional field in the schema — whether a story should be spotlighted — couldn’t be reliably left blank. Small bug, real consequence. We couldn’t trust the editing surface, so we stopped patching around it and built our own.
That decision turned into a project bigger than the one bug it was meant to fix.
Starting from what the site already believed
The public site had a rule we didn’t want to break: no server, no database, no user input. Every page is generated at build time and served as a static file. It’s fast, it’s simple to reason about, and it removes almost every classic web attack surface just by not existing.
An admin panel is the opposite of that — a server, authentication, a database of one kind or another, exactly the things the public site was built to avoid. So we drew a hard line: the admin panel would be the only part of the site allowed to break the zero-server rule, and everything about it would say so out loud. Every exception got written down in a security document before it got written in code, and every exception was scoped as narrowly as we could make it.
Authentication came from Clerk. Writing to the actual article files came from a GitHub App with exactly one permission — write access to content, nothing else — instead of a personal access token that would have carried far more than the panel needed.
The rule that took longest to earn trust
From day one, the panel could create and edit drafts. It could not publish them. Publishing stayed a deliberate, separate action: a command run by a human, at a terminal, who had to type their own name as the reviewer. No script, no button, no way to skip that step.
That rule got tested harder than almost anything else we built. Every draft-editing route was checked, structurally and behaviourally, to prove it could never produce a published article — not through the normal form, and not through a crafted request pretending to be one. For a long stretch of this project, that boundary was one of the few things we treated as permanently settled.
It didn’t stay settled. More on that below.
Getting the security headers wrong, three times
Content-Security-Policy headers are the kind of thing that look simple until a real browser disagrees with your assumptions. We got them wrong three separate times before the panel would even load its own login screen.
The first fix was a plausible theory that turned out to be a typo — a single mistyped character in a domain name, invisible at a glance. The second was a more serious hypothesis: that Vercel’s routing wasn’t excluding the admin path the way we expected. It was a reasonable theory, and it was wrong. The real answer, found by actually inspecting what the browser received rather than reasoning about what it should receive, was that our own framework was quietly generating and attaching a different security policy than the one we’d written — overwriting ours before it ever reached the page.
Once we found where the extra policy was coming from, the fix was a few lines. Finding it wasn’t a few lines. It took reading the actual served response, byte for byte, instead of trusting that our source code and the live site agreed with each other. They didn’t, for a while.
Small bugs, taken seriously
Some of the more interesting bugs weren’t security issues at all — they were about encoding.
A file edited on a Windows machine can end its lines differently than one edited elsewhere. That mismatch quietly broke our publishing script: it couldn’t find the sections of a file it needed to update, so instead of editing them, it added duplicates. The fix mattered less than the discipline behind finding it — reproducing the exact failure on a clean copy of the file before touching any code, so the fix could be tested against the real defect instead of a guess at it.
A similar pattern showed up later in the admin panel itself: uploading a photo while creating a new draft could silently drop other fields you’d already filled in — a category, a caption — because the article was being saved before the system knew where the photo would end up. Small timing problem, real content loss if we hadn’t caught it. We did, and we wrote a test that reproduces the exact sequence, so it can’t come back unnoticed.
Building the things we said we wouldn’t buy
Two-factor authentication for the admin account was one of the first things we wanted — and one of the first things we had to give up, because the authentication provider only offered it on a paid tier we didn’t want to pay for.
Instead of leaving that gap open, we built our own: a standard time-based code system, the same kind used by most authenticator apps, backed by nothing more than a library for the math and a signed cookie for the session. No new subscription, no new vendor. Setting it up meant scanning a QR code the very first time you sign in — which also meant, satisfyingly, that the first real test of the whole system was pointing a phone at the screen and watching it work.
The trade we made with our eyes open
Later, we changed the publish rule.
The terminal-only requirement had been one of our firmest decisions — a second, independent barrier between a compromised password and a live article. We kept it for a long time. Eventually the daily cost of it — always needing a laptop, a clone of the repository, a command line — outweighed the protection, and we replaced it with a publish button, restricted to a single account, that records the actual signed-in person as the reviewer rather than a typed name.
We didn’t pretend that was free. Removing the terminal step also removed a real layer of protection, and we said so plainly, in the same documentation that once explained why the step existed in the first place. Some changes deserve an explanation months later, not just a commit message today.
Everything else that shows
The parts a reader can actually see came last: a search that runs entirely without a database, checking article text directly at request time; a full rebrand down to three colours and a new mark; and, notably, a rule that no search a reader types is ever stored anywhere, in any form — not logged, not counted per term, not kept.
None of it happened in one pass. Almost everything here went through a first version that didn’t quite work, a real test that caught why, and a second version that did. That’s not a complaint about the process. It’s more or less the whole method: build the narrow thing, try to break it, fix what breaks, write down why.
The newsroom is still small. The bug list, happily, is shorter than it used to be.







































