Disclosure: I build Formgong, a form backend. We hit this bug on our own review page.
We were building a plain HTML page where customers leave a review: stars, a name, a text box, a submit button, no
JavaScript. The page has a Content-Security-Policy with form-action 'self'. The form posts to /submit, and the
server answers with a 303 back to a thank-you page.
In local development the redirect pointed at a different port. We pressed Send and nothing happened. The page didn't
move, the button didn't react, and Chrome's console said that sending form data to our own /submit violated
form-action 'self' and that the request had been blocked.
So we looked for the bug in the form's action URL. There wasn't one: /submit was on the page's own origin, which
form-action 'self' allows. What the browser had refused was the redirect that came after it, and the message didn't
say so.
We then tested this properly on 10 October 2026: seven cases, three browser engines, with the expected result for every
case written down before the first run.
The setup
Three origins on one machine, so that "same origin" and "other origin" are real: A (the page), B (a form endpoint), C
(somewhere else). The endpoint counts every POST it receives, so we know whether the data arrived regardless of what
the page shows.
// one tiny server per origin: A = :4601, B = :4602, C = :4603 (from run.cjs)
http.createServer((req, res) => {
const url = new URL(req.url, origin(name));
if (req.method === "POST" && url.pathname === "/submit") {
posts[name]++; // the data arrived
req.resume();
const to = url.searchParams.get("to");
const code = Number(url.searchParams.get("code") || 303);
if (!to) { res.writeHead(200, { "content-type": "text/html" }); return res.end("<p id=done>received</p>"); }
res.writeHead(code, { location: code === 307 ? `${origin(to)}/submit` : `${origin(to)}/done` });
return res.end();
}
// GET /case?id=N serves the form with that case's Content-Security-Policy header
}).listen(PORTS[name], "127.0.0.1");
<!-- the page, served with e.g. Content-Security-Policy: form-action 'self' -->
<form method="POST" action="http://127.0.0.1:4601/submit?to=B&code=303">
<input name="message" value="hello"><button>Send</button>
</form>
Each case ran in a fresh browser context: Google Chrome 154.0.8037.99, and Playwright's builds of Firefox 155.0 and
WebKit 26.6 (Safari's engine), all driven by Playwright. We clicked Send, waited 2.5 seconds, and recorded the final URL,
the POSTs each origin received, the console, and the securitypolicyviolation event.
Results
"Stays" means the page didn't navigate. The numbers are POSTs the server actually received.
| # | Page CSP | Form posts to | Server answers | Chrome 154 | Firefox 155 | WebKit 26.6 |
|---|---|---|---|---|---|---|
| 1 | form-action 'self' |
A | 303 → A | navigates, A got 1 | navigates, A got 1 | navigates, A got 1 |
| 2 | form-action 'self' |
A | 303 → B | stays, A got 1 | navigates, A got 1 | stays, A got 1 |
| 3 | form-action 'self' |
B | (never asked) | stays, 0 | stays, 0 | stays, 0 |
| 4 | form-action 'self' B |
B | 303 → A | navigates, B got 1 | navigates, B got 1 | navigates, B got 1 |
| 5 | form-action 'self' B |
B | 303 → C | stays, B got 1 | navigates, B got 1 | stays, B got 1 |
| 6 |
default-src 'self' only |
B | 303 → C | navigates, B got 1 | navigates, B got 1 | navigates, B got 1 |
| 7 | form-action 'self' |
A | 307 → B | stays, A 1, B 0 | navigates, A 1, B 1 | stays, A 1, B 0 |
We ran it twice. In the first run Chrome's case 3 timed out, because Playwright waited for a navigation that a blocked
form never starts. We counted that case as void rather than as a result and reran everything. The 20 cases both runs
share came out the same.
Three things that will cost you an afternoon
1. The data arrives, and the page acts as if it didn't. In cases 2 and 5, Chrome and WebKit send the POST, the
server stores it, and the browser then refuses to follow the redirect. The visitor sees the same form with the same text
in it and no error. If they press Send again, that's a second POST, and you store the submission twice.
2. Chrome's message points at the wrong URL. For case 2, Chrome prints:
Sending form data to 'http://127.0.0.1:4601/submit?to=B&code=303' violates the following Content Security Policy
directive: "form-action 'self'". The request has been blocked.
That is word for word what it prints for case 3, where nothing was sent (only the URL differs). The URL it names is the
form's action, which is allowed. The securitypolicyviolation event does the same: its blockedURI is the action URL
in both Chrome and WebKit. Only WebKit's console names the real culprit:
Refused to load http://127.0.0.1:4602/done because it does not appear in the form-action directive of the Content
Security Policy.
3. Firefox didn't check the redirect in any of our cases. Firefox 155 blocked the direct case (3), but in cases 2, 5
and 7 it followed the redirect as if form-action weren't there. With a 307 it went further and re-sent the POST body
to the other origin (case 7, B got 1). If you test in Firefox, the form works. Your Chrome and Safari visitors then get
the frozen page.
One more thing to know, since it shows up in case 6: form-action does not fall back to default-src. A policy with
only default-src 'self' lets a form post and redirect anywhere, in all three engines. MDN's form-action page says
the same: no default-src fallback, and "Not setting this allows anything."
The spec hasn't settled this either. Whether form-action should apply to redirects is
issue #8 in the w3c/webappsec-csp repo, "CSP: form-action and redirects", filed in 2015 and still open when I checked
on 13 October 2026. MDN's page warns that implementations are inconsistent.
Fixing it
form-action has to allow every URL the submission passes through: the action and every redirect after it. In practice:
-
Redirect back to the page's own origin. If the endpoint is yours, send the visitor to a thank-you URL on the same
origin as the form. That is what we did: the form posts to
/submitand returns to the same review page with?sent=1. -
List the redirect target. If the endpoint sends visitors to another domain (a payment page, a hosted thank-you
page), that origin goes into
form-actiontoo, not just the endpoint's. -
Or don't navigate at all. Submit with
fetch()and show the result on the page.form-actiondoesn't apply tofetch();connect-srcdoes, and since the page never navigates,form-actionnever sees a redirect.
Check the result in Chrome or Safari, not Firefox. Watch the server's log as well as the browser's console: if the server
received the POST and the page didn't move, the redirect is the problem, whatever the console says.
We found this while building a review page for Formgong, a form backend for static and
AI-built sites. The test plan was written before the run, and we kept the raw results of both runs, every console line
included.
Your turn
Have you seen this in another browser, or with a different redirect code? Tell me in the comments and I'll add it to the table. And if you try Formgong for your own forms, I'd really like to hear what's missing or confusing.

Top comments (0)