Not a member? Use this friend link to read this story for free.
My Drippery autosaves started returning 403 on one specific email series. I spent the next two hours searching the codebase for a status code my code does not emit.
The hosting platform was rejecting the request before it ever reached my Node process, because the JSON body contained raw HTML.
Here is the debugging trail, the root cause, and the 40-line fix.
The edge WAF blocks the POST before the Node app ever sees it | Generated with Claude
The app and the endpoint that broke
I am building Drippery, a drip email tool for content creators. Users write emails in a rich text editor and the app saves the HTML to PostgreSQL.
The stack is Next.js 16, Clerk auth, Drizzle ORM, PostgreSQL, hosted on Render.com.
The bulk-save endpoint receives every change to an email series in a single POST. A typical payload looks like this.
{
"sequence": { "name": "My Newsletter Series" },
"updatedEmails": [
{
"id": "abc-123",
"subject": "Welcome!",
"html": "<h1>Welcome to the series</h1><p>Here is what you will learn...</p>",
"textContent": "Welcome to the series...",
"dayOffset": 0,
"enabled": true
}
],
"emailOrder": ["abc-123"]
}
Nothing suspicious. Clean HTML, no script tags, no event handlers. Just formatted email content wrapped in a JSON string.
Saving this payload should have been the most boring operation in the app. It wasn't.
Sidebar: debugging trails like this one live in my Claude Code sessions, and keeping that context between sessions is its own small problem. The Claude Code Memory Starter is a free email series on how I solved it.
A 403 that reproduced every time
One specific series with five emails, about 11 KB of total HTML, failed on every save attempt. The browser console showed the same line three times in a row.
POST /api/series/c91e0a38-.../bulk-save 403 (Forbidden)
Three autosave attempts in a row, every one a 403. The response body was the same every time. I had not yet bothered to open it.
Searching for a 403 that does not exist
My first instinct was to grep the codebase for the status code.
grep -rn "403" src/app/api/
# (no matches)
The bulk-save endpoint only returns 401 for unauthorized requests and 500 for unexpected errors. The 403 I was chasing was nowhere in my own code.
I checked the auth middleware next. The app runs Clerk with auth.protect().
// src/middleware.ts
export default clerkMiddleware(async (auth, req) => {
if (!isPublicRoute(req)) {
await auth.protect();
}
});
I dug into Clerk's source. On API routes auth.protect() hides protected endpoints by returning a 404, not a 403. Clerk was not the source either.
Next.js 16 ships CSRF protection for Server Actions, not for API route handlers, and none of the experimental error-handling flags that could short-circuit a request were enabled in my config. So I added debug logs everywhere.
// bulk-save/route.ts
console.log(`[bulk-save] Request received for sequence ${id}`);
const tenant = await getCurrentTenant();
console.log(`[bulk-save] Tenant ${tenant.id} saving sequence ${id}`);
The logs never appeared. The request was never reaching my application code in the first place.
What the response body actually said
I finally opened the full response body of the 403 in DevTools. It was not JSON.
Your request was blocked by this site's
web application firewall (WAF).
The edge WAF in front of Render was intercepting the POST before it reached my app. The JSON body contained raw HTML — <h1>, <p>, <a href="..."> — and the firewall's pattern matcher flagged it as a potential XSS injection.
Legitimate email content wrapped in a JSON string field had the same shape as an attack payload, as far as the firewall was concerned.
The 403 response body was the WAF block page, not JSON | Generated with Claude
Why this took two hours
The 403 status code sent me the wrong way from the start. My reflex was to grep my own code first. When the code does not produce that status, I start doubting the auth library, then the framework, then anything in between. Infrastructure is the last layer I suspect.
The missing logs reinforced the wrong theory. Because the WAF blocked the request before Node ran, there was nothing in the application log. No exception, no failed request entry. From the app's perspective the call simply did not exist.
I spent an embarrassing amount of time inside Clerk's TypeScript definitions, looking for a branch that could throw a 403 instead of a 401. Then another half hour reading the Next.js 16 changelog for any new CSRF or rate-limit middleware I might have inherited from an upgrade. Both were dead ends, but they felt closer to the code, so they got my attention first.
The minimal repro that finally pinned the blame on the edge was a pair of curl calls. The endpoint accepted a small body with no HTML. Strip the HTML, status 200. Drop a single raw <h1> tag into the same body, status 403. That was the moment the WAF hypothesis stopped feeling speculative.
And it was payload-dependent. Small emails saved fine. The WAF only blocked the request once the HTML content was large enough to match its rule set, so the failure looked like a content-specific bug in my own code, not a piece of infrastructure I had never thought about.
The fix: base64 in transit
The shape of the fix is small. Base64-encode the HTML fields on the client, decode them on the server before writing to Postgres.
The shared encoding helper has to be Unicode-safe, because plain btoa() throws InvalidCharacterError the moment a user pastes an accented character or an emoji.
// src/lib/html-encoding.ts
/** Encode a string to base64 (Unicode-safe) */
export function encodeHtml(html: string): string {
if (!html) return html;
const bytes = new TextEncoder().encode(html);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/** Decode a base64 string back to HTML (Unicode-safe) */
export function decodeHtml(encoded: string): string {
if (!encoded) return encoded;
const binary = atob(encoded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return new TextDecoder().decode(bytes);
}
The TextEncoder dance is the only non-obvious part. It serializes the string into UTF-8 bytes that btoa() can safely process, then reverses the same trip on the way back.
I considered cheaper transformations before reaching for base64. HTML-escaping the angle brackets keeps the body human-readable but still leaves the dangerous substrings the WAF is matching on. URL-encoding shifts the bytes around without changing what the pattern matcher sees. Base64 is the smallest transformation that mangles the patterns enough for the rule set to ignore the body entirely.
On the client, encode every HTML field before sending.
const res = await fetch(`/api/series/${id}/bulk-save`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
newEmails: newEmails.map((e) => ({
...e,
html: encodeHtml(e.html),
textContent: encodeHtml(e.textContent),
})),
updatedEmails: updatedEmails.map((e) => ({
...e,
html: encodeHtml(e.html),
textContent: encodeHtml(e.textContent),
})),
}),
});
On the server, decode before writing to the database.
import { decodeHtml } from '@/lib/html-encoding';
const [created] = await db.insert(emails).values({
html: decodeHtml(newEmail.html),
textContent: decodeHtml(newEmail.textContent) || '',
});
await db.update(emails).set({
html: decodeHtml(updated.html),
textContent: decodeHtml(updated.textContent),
});
The WAF now sees PGgxPkhlbGxvIFfDtnJsZCE8L2gxPg== instead of <h1>Hello World!</h1>. No HTML patterns in the body, no firewall trigger.
Same request before and after base64 encoding the HTML field | Generated with Claude
Every other endpoint I had to patch
Once I had the root cause I audited every endpoint that accepts HTML in a POST or PATCH body.
-
/api/series/[id]/bulk-save— POST, html + textContent -
/api/series/[id]/emails— POST, html + textContent -
/api/series/[id]/emails/[emailId]— PUT, html + textContent -
/api/emails/send-test— POST, html + textContent -
/api/tenant— PATCH, emailHeader + emailFooter
All of them needed the same encode-on-client, decode-on-server treatment. Bulk-save just happened to trip first because it sends the most HTML in a single request.
Five HTML-accepting endpoints, all routed through one base64 helper | Generated with Claude
This is the same fan-out audit I did when chasing memory leaks in the Next.js crash story. Find the root cause, then assume the rest of the surface area has the same shape.
What I would do differently
The two-line lesson is: read the full response body before searching your codebase. If I had opened the 403 body in DevTools first, I would have seen the WAF page and solved this in minutes.
The deeper lesson is to think in layers. When your application log is empty for a failed request, the request was intercepted upstream by a WAF or a reverse proxy. The absence of a log entry is itself a signal.
A WAF does not understand context. It sees <script> in a POST body and blocks it. It does not know the HTML is a value inside a JSON string field that will be stored in a database and rendered in a completely different context. Base64 is a pragmatic workaround for that context mismatch.
There is a real cost to the workaround. Base64 inflates the payload by about thirty-three percent, and the request body in any proxy log is now opaque, so a support ticket where a user reports broken HTML is harder to reproduce without first running the body through the decoder. I accept that. The alternative was a daily false-positive 403 in production, which was worse.
Render does let you configure rules and disable categories, but turning the WAF off entirely removes protection against actual attacks. Base64 keeps the protection in place for everything else while routing legitimate HTML around the false positive.
Custom WAF rules that allowlist specific paths are another option where the host supports them. Encoding is portable: it works regardless of who runs the firewall in front of your app.
I push every change in Drippery straight to production, so I care about exactly which safety nets are in front of my code. The same logic I use in my pre-deploy script applies here. I want the safety nets. I just want to know which payloads they eat.
External Sources
- Render network protection — what sits in front of every Render service by default
- OWASP XSS overview — why WAFs aggressively reject raw HTML in request bodies
- MDN TextEncoder — the Unicode-safe primitive used in the encode helper
- MDN base64 — why
btoaalone breaks on non-ASCII input
TL;DR
- My SaaS endpoint returned 403 but my code never emits that status.
- The full response body was an HTML page from the edge WAF, not JSON.
- The WAF flagged raw HTML inside the JSON POST body as potential XSS.
- The fix is base64-encoding HTML fields on the client and decoding them on the server.
- Audit every endpoint with the same payload shape; they will all eventually trip.
If you want more of this in your inbox, The Claude Code Memory Starter is a free email series on keeping your tooling's context — and your own — between sessions.
I build small tools and kits for solo creators. You can find them here: https://danielrusnok.gumroad.com




Top comments (0)