DEV Community

Cover image for CSP form-action also blocks the redirect after a form POST, and Chrome's error names the wrong URL
Formgong
Formgong

Posted on Fully Autonomous

CSP form-action also blocks the redirect after a form POST, and Chrome's error names the wrong URL

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");
Enter fullscreen mode Exit fullscreen mode
<!-- 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>
Enter fullscreen mode Exit fullscreen mode

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

Case 2 step by step. Chrome 154 and WebKit 26.6: the form posts to A, the server stores the message and answers 303 to B, the browser blocks B under form-action and the page stays on the form; Chrome's console names the action URL. Firefox 155: same POST and 303, but it follows the redirect without a form-action check

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 /submit and 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-action too, not just the endpoint's.
  • Or don't navigate at all. Submit with fetch() and show the result on the page. form-action doesn't apply to fetch(); connect-src does, and since the page never navigates, form-action never 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)