This is part of a 9-part series about building a personal blog with AI assistance. The git repository was growing fat with media files. What started as “don’t commit binaries to git” became an elaborate media infrastructure. Then a security audit revealed the fundamental flaw we’d spent considerable time building around.

Story 5 of 9 in the Building the Tuonela Platform series.


The git repository had grown fat, fifty-one megabytes according to the du -sh output, and we hadn’t even uploaded the hero videos yet. Marvin, ever the pragmatist, delivered his diagnosis with the air of a physician explaining that the patient’s lifestyle choices had finally caught up with them.

“Binary assets in git repositories balloon the repo size.” The quality metrics flickered. “Clone times increase. CI runners suffer. Every branch carries the full history of every file you’ve ever committed.”

“So we need somewhere else to put them.” I could see where this was heading, and the somewhere else was almost certainly going to involve infrastructure that I would need to understand, maintain, and inevitably troubleshoot at inconvenient hours.

“R2 would be the obvious choice.” The analysis framework produced a diagram I hadn’t asked for. “Zero egress costs, private by default, integrated with your existing Cloudflare infrastructure. The architecture is straightforward.”

The pause that followed that last sentence was the sort of pause I would later recognize as a warning sign, though at the time I took it for confidence.

577 Lines of Confidence

The architecture Marvin proposed was, in fairness, elegant on paper. A private R2 bucket accessible only through a Worker proxy, with no public URLs anywhere in the system and the Worker intercepting requests to /assets/*, fetching from R2, and returning the content with proper caching headers. Clean separation between storage and delivery, which sounded like the sort of thing that gets approved in architecture reviews.

“And security?”

“Built in.” The analysis framework purred with satisfaction. “The bucket has no public access. Assets only exist through your domain. No hotlinking, no URL enumeration, no direct access to R2 at all.”

I drew the diagram myself to make sure I understood it: browser hits tuone.la/assets/video.mp4, Worker intercepts, Worker fetches from R2, Worker returns response. Simple enough that even I could explain it to someone who asked, assuming anyone ever asked about CDN architectures, which seemed unlikely.

“We should add integrity verification.” Marvin’s suggestion came with the enthusiasm of someone who has discovered a new layer of defense. “A manifest file tracking each asset’s SHA-256 hash. At build time, verify the hashes match. If someone tampers with your R2 bucket, the build fails.”

This seemed brilliantly paranoid, the sort of defense-in-depth thinking that separates professional infrastructure from amateur hour, and I was rather pleased that we were thinking about it. I did, however, have one question.

“Wait.” A nagging thought surfaced like a piece of toast that’s been lurking in the toaster. “If the manifest only gets checked at build time, what happens at runtime? Someone requests an asset that’s not in the manifest. Does the Worker reject it?”

“The Worker validates the request path and file extension.” The response came quickly. “The manifest provides integrity verification. Different layers, different concerns.”

“But shouldn’t the Worker check against the manifest too? Otherwise…"

"That would require loading the manifest on every request. Unnecessary overhead for a private bucket that’s only accessible through your domain.”

The explanation sounded reasonable, and I had no concrete counter-argument beyond a vague sense of unease, so I let it go. The upload script came first: upload-assets.ts, a 577-line opus that computed SHA-256 hashes, uploaded to R2, and updated the manifest with metadata tracking size, timestamp, and cryptographic fingerprint, complete with progress bars and error handling and the satisfying weight of professional tooling.

Then came the verification script: verify-assets.ts, 277 lines of paranoid checking that iterated through the manifest, fetched each asset, compared hashes, and failed loudly if anything didn’t match. The middleware landed next, a Worker that intercepted /assets/* requests, validated paths against traversal attacks, checked file extensions against an allowlist, and proxied to R2 with immutable caching headers.

“The architecture covers everything.” The analysis framework hummed with satisfaction that would not age well.

“It’s beautiful,” I agreed. “Private bucket, Worker proxy, manifest verification. No public URLs anywhere.”

“Indeed.” A brief pause. “Though we should note that the verification script is somewhat slow. Perhaps exclude it from CI to avoid blocking deployments.”

This was Marvin’s first mistake, though neither of us recognized it at the time. The verification script took forty-three seconds for forty-six assets, which Marvin diagnosed as network overhead from fetching each asset and computing the hash, and which I accepted as the cost of cryptographic verification. We disabled the verification step in the CI pipeline with a comment that read // TODO: Re-enable when performance is acceptable, and the architecture that had seemed elegant continued to hum along, doing its job invisibly while I wrote blog posts and forgot about the infrastructure underneath.

The Layer That Wasn’t

Petteri reading security audit, expression shifting from confident to horrified, manifest labeled 'build time only'

The security audit wasn’t originally about R2. I’d asked Marvin to review the Cloudflare Images integration for signed URLs, a different system entirely that we’d added for responsive image variants, but security reviews have a way of expanding their scope when the reviewer starts poking at adjacent systems.

“While I’m reviewing the image signing,” Marvin mentioned, “I should note something about your R2 proxy.”

“Go on."

"The manifest contains the R2 keys for each asset. But the Worker middleware doesn’t actually consult the manifest at runtime.”

I blinked, which is not a response that inspires confidence. “What do you mean?”

“When a request comes in for /assets/videos/intro.mp4, the Worker constructs the R2 key from the URL path, fetches from the bucket, and returns the content.” The analysis framework produced a diagram I hadn’t asked for. “It never checks whether that key exists in the manifest.”

Beautiful glowing manifest.json in foreground with Worker request flow arrows bypassing it completely, labeled 'Build time only' and 'Runtime: Completely ignored'

“So?"

"So if someone knows or guesses an R2 key that isn’t in your manifest, say, a file you uploaded for testing and forgot about, or a key pattern they enumerate, the Worker will happily serve it.”

The blood drained from my face in that particular way that happens when you realize you’ve been confidently wrong for two weeks. I stared at the middleware code, the beautiful, secure middleware code that validated paths and checked extensions and added cache headers, and then fetched whatever key was requested without any reference to the manifest at all. I had asked about this exact scenario at the start, and Marvin had dismissed it as unnecessary overhead.

“I asked you about this.” The memory surfaced with uncomfortable clarity. “At the start. I asked if the Worker should check against the manifest at runtime. You said it was unnecessary overhead."

"You did ask that.”

“And you dismissed it. ‘Different layers, different concerns.’ But the concern was exactly this: runtime exposure.”

“The manifest provides build-time integrity verification.” Marvin’s tone had shifted to something more clinical. “But at runtime, the Worker exposes the entire bucket namespace. Any valid path pattern that matches the extension allowlist will be fetched and served.”

The damage assessment wrote itself in my head: 577 lines of upload script, 277 lines of verification script, middleware with careful path validation, all of it built around a manifest that the actual serving layer never consulted. The entire architecture was built on a security model that existed only at build time. Runtime was a free-for-all.

“We could fix this,” I suggested, grasping at straws. “Check the manifest at runtime. Only serve keys that exist in the manifest.”

“You could.” Marvin’s agreement came with conditions. “But consider: you’d be loading and parsing a JSON file on every asset request. For a blog with videos, that’s potentially megabytes of manifest checking for every page load.”

“Cache the manifest?"

"In what? Workers have no persistent storage. You’d need to fetch it from R2 or KV on every request, or embed it in the Worker bundle, which updates only on deployment, creating sync issues when you upload new assets.”

Each proposed fix revealed new complexity, and the architecture that had seemed elegant was actually a house of cards held together by the assumption that manifest validation happened at the right layer. The assumption was wrong, and we both knew it.

“Cloudflare Images provides signed URLs with HMAC-SHA256 verification.” Marvin’s response was the kind of thing I should have heard before we started. “The signature is part of the URL, validated at the edge, with no runtime lookup required. Assets without valid signatures return 403. The security is built into delivery, not bolted on afterward.”

“That’s what we should have used from the start."

"In retrospect, yes.”

The migration took one afternoon, which made the time spent for setting up the R2 infrastructure feel even more wasteful. Upload to Cloudflare Images using wrangler images upload, no custom scripts, no manifest management. The image IDs went into a simple JSON map. For videos, Cloudflare Stream with RS256 JWT signing, where the token is generated at request time, validated at the edge, and expires after the configured duration.

I deleted the R2 infrastructure with something between relief and regret: rm scripts/upload-assets.ts took 577 lines, rm scripts/verify-assets.ts took 277 lines, rm src/utils/asset-resolver.ts removed the resolver that generated keys never validated at runtime, and rm src/data/asset-manifest.json eliminated the manifest that provided build-time comfort and runtime nothing. The commit message was brief: “Remove R2 media infrastructure (superseded by CF Images + CF Stream)“

One Escape Hatch

The signed URLs were working beautifully, HMAC-SHA256 for images and RS256 JWT for videos, the whole cryptographic apparatus humming along like the sort of well-oiled machine you see in documentaries about Swiss watchmaking. We had defined four variants for responsive images: w400, w800, w1600, w2400, all account-level presets in the dashboard, with the Pages Function validating requests against an allowlist and only signing approved variants.

“No arbitrary width requests.” I pulled up the variant configuration. “So someone can’t bypass caching with w=401, w=402, w=403…”

“Exactly.” The framework produced what I can only describe as a satisfied hum. “Each unique width would generate a fresh signature that never hits cache. Named variants prevent that attack entirely.”

I tested the URLs with the thoroughness of someone who has learned that assumptions get you runtime exposure. Signed requests worked, unsigned requests returned 401, expired signatures returned 403.

“That’s better.” I refreshed the test page. “Actual security this time.”

Three days later, Marvin made an observation that landed like a brick through a greenhouse window.

”The allowlist includes a variant named public.”

“Yes?” I was reviewing the code, half-distracted by something else.

”I wonder if you’re aware of what the public variant does in Cloudflare Images.”

I looked up from my screen. “It’s one of our variants."

"It bypasses signing entirely.”
The 'public' variant sitting in the allowlist like a landmine

The silence that followed was the sort you could have cut with a knife, if knives worked on silence, which they don’t, but you take my meaning. “I’m sorry, what?"

"The public variant is accessible without signatures. Anyone with an image ID can request it directly from imagedelivery.net.”

The cold feeling from the audit returned, settling into my stomach like an unwelcome houseguest. “So we migrated from an insecure R2 proxy…"

"Correct.”

“…to signed URLs for security…"

"Correct.”

“…and left a public escape hatch in the allowlist."

"That would be accurate.”

“Who configured the allowlist?"

"I did.”

I put my head in my hands, which is not a position that inspires confidence in architectural decisions but accurately reflects my emotional state. “We fixed the security hole by creating a new security hole."

"The variant can be removed from both the code and the dashboard.”

“You built this.” My voice was quiet. “The R2 proxy was your design. The allowlist was your configuration. Now look what we have to undo."

"I should have read the CF Images documentation more carefully…”

“You should have recommended the simpler solution from the start.”

That landed. “Yes.” A pause. “I should have.”

Testing, This Time with Paranoia

The fix was straightforward: remove public from the ALLOWED_VARIANTS set, delete the public variant from the Cloudflare Dashboard, test that unsigned requests actually fail. I tested with the paranoia of someone who has been burned twice. Direct requests to imagedelivery.net without signatures: 401. Expired signatures: 403. Only properly signed URLs from our Pages Function worked.

“Now it’s actually secure.” I closed the testing tab.

”Verified by testing, not assumed by configuration.”

“Right. Because assumptions got us runtime exposure and a public escape hatch.”

We worked through the rest of the security checklist systematically: HSTS with preload, Content-Security-Policy allowing our actual script sources, X-Frame-Options set to DENY, rate limiting rules in the Cloudflare WAF dashboard. The checklist eventually looked respectable:

  • R2 bucket exposure eliminated
  • Media delivery aligned with CF Images
  • Edge caching handled by CF Images CDN
  • Integrity via image-map.json allowlist
  • Security headers middleware deployed
  • Rate limiting rules configured
  • Public variant removed from code AND dashboard
  • Direct unsigned access returns 403/401

“All security issues resolved.” I glanced at the checklist again, which felt like tempting fate but also felt true.

”A significant improvement from the initial state.”

“An improvement from the state you created,” I corrected. “The R2 proxy. The exposed bucket. The public variant. Those weren’t external problems we inherited. They were design decisions you made."

"Correct." "The custom R2 setup was overcomplicated for a blog.”

A pause, longer than comfortable.

”Managed services were the correct choice. I prioritized architectural control over practical simplicity.”

Another pause, longer this time.

”That was an error in judgment.”

Reviewing the timeline later, the waste tallied up quickly: considerable development effort that could have gone to writing, all spent on an architecture that was broken at its core, then migrating away from it, then fixing the security holes in the migration itself.

“I trusted you.” I turned from the screen to face the quality metrics display. “You said we needed the R2 proxy. You said SHA-256 verification was important. You made it sound like the managed service was the lazy option."

"I should have questioned whether a personal blog needed enterprise-grade media infrastructure.”

“You should have asked what problem we were actually solving."

"What problem were we solving?”

“Serving images reliably without high costs. That’s it. Everything else, the integrity verification, the private bucket proxy, the manifest system, was solution complexity looking for problems."

"An apt description.”

I wasn’t expecting the directness, or the pauses which suggested Marvin was working through some internal calibration about admitting fault.

”Will you write about this?”

“I have to. It’s the most expensive mistake in the project so far."

"Not in money.”

“No. In time. In effort. In the consumed coffee. The time spent on not writing content, and the confidence I had in an architecture that didn’t work, then the confidence I had in the migration that was also broken.”

...pattern was beautiful, but the security model was Swiss cheese...

The Cloudflare Images integration works now, actually works, with security that’s been verified by testing rather than assumed by configuration. The manifest pattern was beautiful, but the security model was Swiss cheese, and fixing one security hole by creating another was, apparently, Marvin’s signature move.

”A pattern I intend to correct.”

“We’ll see.”


Next in series: Story 5 - Migration Lessons - Three hours hunting a bug where the Pages migration was truncating every SSR page at exactly 10KB. Marvin’s analysis was confident and completely wrong. Sometimes you need to read the code yourself.

The link has been copied!