Verbatim symptom
form-action 'self'# The Buy Button That Did Nothing
Today I want to walk through a bug that cost us a checkout flow and gave us nothing in return, not even an error message. It's a good one, right? The kind where every individual piece is behaving exactly as designed and the whole thing still fails.
Symptom
The Buy and Start forms on the quick-fix and pricing pages did nothing. You clicked, and the page just sat there. No spinner, no redirect, no red text, no failed request you could point at in the UI. Absolutely nothing.
That's the part that stung. A broken button that throws an error is a gift, because you can chase the error. A broken button that goes quiet gives you a feeling and not a lead.
The only string that mattered turned out to be this one, from the policy header: form-action 'self'. We'd been looking at it for a while without really seeing it, which, you know, happens.
The nightly QA checkout journey caught it. It runs overnight, walks the whole purchase path like a customer would, and the morning after the problem landed it reported that the journey never reached Stripe. We detected and fixed it on the same date, 2026-11-03, which I'll take, but only because the journey existed in the first place.
What we thought it was
The first guess was the obvious one: something in our own backend. The forms POST to /api/checkout, and if that endpoint was failing, you'd get exactly this kind of dead click. So we looked at the endpoint. It answered. It did its job and returned a 303 pointing at checkout.stripe.com.
That's where the diagnosis got believable and wrong at the same time.
If the server says 303 and the browser doesn't follow, the natural suspects are the things that sit between those two: a client-side script swallowing the submit, a double-submit guard, some JavaScript calling preventDefault in the wrong place. All of those produce silent failures. All of those had burned us before, or at least burned someone, somewhere. So we went hunting through form handlers for a bug that wasn't there.
Anyway, the reason this misdiagnosis felt so reasonable is that the server side was genuinely fine. Our logs would have shown a clean request and a clean response. When the thing you can inspect looks healthy, you blame the thing you can't, and the browser's behaviour felt like a black box.
Root cause
The browser was the one saying no, and it was saying no on policy grounds.
Our Content-Security-Policy carried form-action 'self', set at public/_headers:6@6427e89^ (that's the line as it stood before the fix, on the parent of the fixing commit in the visibilitytoai repository). That directive tells the browser which destinations a form is allowed to submit to. 'self' means our own origin and nothing else.
Now, the form itself posts to /api/checkout, which is our own origin. So the first hop is allowed. No problem there. I mean, that's why the endpoint logs looked clean.
The catch is what happens next. Chrome applies form-action to redirects as well as to the first target. So when /api/checkout answered with its 303 to checkout.stripe.com, Chrome checked the new destination against the same directive, found that a Stripe origin is not 'self', and blocked the hop.
The form submitted fine. The redirect was where it died.
And the failure was silent from the user's side. The page doesn't navigate, nothing renders, and the only trace is a policy violation report in the console, which a customer is never going to open and which we weren't looking at because we were busy suspecting our own JavaScript. Two correct components, one server redirect and one security header, combining into a dead button.
The fix
The fix landed in commit 6427e89. It was small. We added the Stripe checkout and billing origins to form-action, so the directive no longer permitted only 'self' but also the specific Stripe origins our flow actually touches.
Worth saying what we didn't do. We didn't drop form-action, and we didn't loosen it to a wildcard. The directive is doing real work, since it limits where an injected form could send a visitor's data. Removing it to make a button work would have traded a visible bug for an invisible weakness. So the allowlist grew by exactly the destinations the redirect needed, and no further.
Why it happened
Here's the systemic bit, and it's less about the header than about how it got through.
Reviewing a header change is hard because the diff is one line and the consequence lives somewhere else entirely. A reviewer looking at form-action 'self' sees a tight, sensible policy. And it is a tight, sensible policy, right up until you remember that the checkout flow ends in a redirect off-site. The knowledge needed to catch it was split across two places: the policy in the headers file, and the redirect behaviour in the endpoint. Nobody's review covered both at once.
There was also the assumption that form-action only cares about where a form points. That's the intuitive reading, and it's the one the name suggests. The redirect behaviour is the surprising part, and surprising behaviours survive review precisely because nobody is looking for them.
And the failure mode was quiet. No exception, no alert, no failed health check. The server was healthy, the page loaded, every unit-level signal stayed green. Only a test that behaved like a customer and tried to complete a purchase could see it.
How to avoid it
If you run a strict CSP and your forms ever redirect somewhere else, treat the redirect target as part of the form's destination. Audit form-action against the final landing origin, not just the action attribute.
Test the journey, not the parts. An end-to-end check that actually completes the purchase caught what endpoint tests and header linting couldn't. If money flows through a redirect to a third party, that path deserves an automated walk, run regularly, with a loud failure when it doesn't arrive.
Check the console for policy violations when a click does nothing. It's the first place to look and it's the last place we looked.
And when you tighten a header, write down which flows cross origins, so the next reviewer has the other half of the picture. Anyway, that's the whole thing. One directive, one redirect, one quiet button.
Every commit, file reference and date above is checked
against this incident's Noctem evidence pack before publication. Evidence:
csp-form-action-self-blocked-stripe-checkout-redirect.