
Fix CSP form-action Errors for Contact Form Endpoints
A contact form can look completely normal and still do nothing when someone presses Submit. The fields validate. The button works. The endpoint works in a separate test. Then the browser console gives away the problem: the page's Content Security Policy refused to send the form data.
That failure belongs to the form-action directive. Fixing it usually takes one precise change. The dangerous response is to loosen default-src, add a broad https: source, or remove CSP until the console goes quiet.
Confirm that CSP is the blocker
Open the page in a browser, open Developer Tools, select the Network panel, and submit the form once.
A form-action failure has two useful signs:
- The console names the page's Content Security Policy and the
form-actiondirective. - No request to the form endpoint appears in the Network panel.
The second sign matters. CSP checks the intended navigation before the form request leaves the page. The CSP Level 3 algorithm returns "Blocked" when a form submission target does not match the directive's source list.[3]
If the request does leave the page and comes back with an HTTP status, CSP allowed the destination. A 400 response points to the submitted data or endpoint contract. A 403 may be a domain restriction or another authorization rule. A browser message about CORS is a different problem. Use the Static Forms troubleshooting guide to follow the request past the browser boundary instead of changing the CSP again.
Do not diagnose this by opening the endpoint URL in a tab. A successful GET says nothing about whether the page may submit a POST to that origin.
What form-action controls
form-action limits the URLs that a document may use as form submission targets.[2] In this form:
<form
action="https://api.staticforms.dev/submit/YOUR_API_KEY"
method="post"
>
<label for="email">Email</label>
<input id="email" name="email" type="email" autocomplete="email" required />
<label for="message">Message</label>
<textarea id="message" name="message" required></textarea>
<button type="submit">Send message</button>
</form>The target origin is https://api.staticforms.dev. The path and the public form identifier come after it. Static Forms currently documents the endpoint as https://api.staticforms.dev/submit/<API_KEY> and describes the key as a public identifier, not a private server credential.[1]
The narrow policy for that endpoint is:
Content-Security-Policy: form-action 'self' https://api.staticforms.dev;Keep 'self' if the site also has forms that post to its own origin, such as a search form or an account action. Remove it if every form on the protected pages should post only to Static Forms.
One detail catches people: form-action has no default-src fallback. A policy containing default-src 'self' but no form-action does not restrict form targets through that default. MDN records the fallback as "No" and notes that omitting form-action allows any target.[2] If a form is blocked, inspect every CSP header and <meta http-equiv="Content-Security-Policy"> on the response. Another policy may be applying the restrictive directive.
Change only the destination list
Suppose the current response sends this header:
Content-Security-Policy: default-src 'self'; form-action 'self';Change it to:
Content-Security-Policy: default-src 'self'; form-action 'self' https://api.staticforms.dev;Leave script-src, style-src, connect-src, img-src, and the rest of the policy alone. A native HTML form submission is governed by form-action. Adding the API origin to connect-src does not repair the native submission.
The reverse distinction is useful too. If JavaScript intercepts submit and sends the data with fetch(), that request is governed by connect-src, not form-action, because the browser is making a fetch rather than navigating through the form. A progressively enhanced form may need both directives:
Content-Security-Policy: default-src 'self'; form-action 'self' https://api.staticforms.dev; connect-src 'self' https://api.staticforms.dev;Do not add https: as a shortcut. That would allow every HTTPS origin for the directive. Name the origin the form actually uses. Vercel's CSP guidance makes the same practical recommendation: avoid sources that are broader than necessary, and test a policy before enforcing it.[6]
Set the header on Vercel
A framework may have its own header API, but a plain Vercel deployment can set response headers in vercel.json. Vercel documents headers as an array of route definitions, with a source pattern and response-header key/value pairs.[4]
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"headers": [
{
"source": "/(.*)",
"headers": [
{
"key": "Content-Security-Policy",
"value": "default-src 'self'; form-action 'self' https://api.staticforms.dev"
}
]
}
]
}Merge this directive into the policy you already ship. Do not replace a longer production policy with the short teaching example above. Browsers enforce the whole CSP value, so losing an existing script-src, object-src, or frame-ancestors directive can change unrelated behavior.
After deployment, inspect the actual response rather than trusting the repository file:
curl --head https://example.com/contactRedirects can hide the header you meant to test. Run the command against the final page URL and check the response in the browser as well.
Set the header on Netlify
Netlify supports a plain _headers file in the site's publish directory. Header lines are indented below the route they apply to.[5]
/*
Content-Security-Policy: default-src 'self'; form-action 'self' https://api.staticforms.devThe file has to reach the publish directory. Keeping _headers beside source files is not enough when a build tool only deploys dist, build, or another output folder. Netlify's documentation calls this out directly.[5]
You can express the same rule in netlify.toml:
[[headers]]
for = "/*"
[headers.values]
Content-Security-Policy = "default-src 'self'; form-action 'self' https://api.staticforms.dev"Use one source of truth for the policy. Splitting CSP across a platform file, framework middleware, and an HTML meta tag makes a simple failure surprisingly hard to trace.
Account for more than one CSP
Browsers enforce every Content Security Policy delivered with a page. A second policy can only make the combined result more restrictive; it does not cancel the first one.
This response still blocks the external form:
Content-Security-Policy: default-src 'self'; form-action 'self' https://api.staticforms.dev
Content-Security-Policy: form-action 'self'The first header allows the API origin, while the second does not. Both apply, so the submission fails.
Look for duplicate headers in the Network panel's document response. Then search the rendered HTML for CSP meta tags:
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; form-action 'self'"
/>Headers set by the CDN, framework, reverse proxy, and application can stack. Fix the component that owns the stale policy. Do not try to counter it by adding yet another header.
Roll out the fix without breaking other pages
Changing only one host in form-action is small, but test it as a policy change rather than a copy edit.
Vercel recommends starting with Content-Security-Policy-Report-Only before enforcement so violations can be observed without blocking the page.[6] That advice is useful for a new site-wide policy. For an existing enforced policy that is already breaking a form, keep the enforced policy in place and test the amended value in a preview deployment first.
A practical check looks like this:
- Load the deployed contact page in a fresh browser tab.
- Confirm the document response contains the intended CSP once, or understand why multiple policies exist.
- Submit valid test data through the visible form.
- Confirm a request goes to exactly
https://api.staticforms.dev/submit/YOUR_API_KEY. - Check the returned status and the site's visible success or error state.
- Submit with the keyboard and confirm focus and status text remain usable.
- Try an unapproved test destination in a non-production fixture. The browser should block it.
The final step proves that the allowlist stayed narrow. A successful request to the intended endpoint proves only that the browser allowed that request. It does not prove an email arrived, a webhook ran, or a person read the message.
Keep the form usable and honest
CSP errors are invisible to most visitors. Give the form a real error state instead of leaving a button spinning forever. If JavaScript handles submission, preserve entered values after failure and restore the submit button. Announce the result with an existing status or alert region, as shown in the contact form success message examples.
Keep visible labels on controls. The browser's security policy does not make up for missing names, poor focus behavior, or unclear errors. Those belong in the form itself.
The public form identifier may appear in the page markup. Abuse controls belong elsewhere. Static Forms documents domain restriction and CAPTCHA in form Security settings rather than treating the identifier as a secret.[1] The CAPTCHA comparison covers when those controls fit; CSP decides where the browser may submit, not whether the submission is human.
Mistakes that waste debugging time
Editing default-src and expecting a form-action change
There is no fallback from form-action to default-src.[2] Read the directive that appears in the console and change that directive.
Adding the endpoint to script-src
script-src controls scripts. It does not authorize a native form target. Broadening it creates risk without fixing the form.
Allowing every HTTPS destination
form-action https: is convenient and far wider than a single form API. Use the exact origin.
Treating the API key as a server secret
The identifier is present in the form action, so visitors can see it. Keep private webhook credentials and service tokens out of HTML, while using the product's domain and spam controls for public forms.[1]
Stopping after the console error disappears
The browser can allow a request that the endpoint rejects. Check the Network response and then check the receiving system. If delivery fails after the endpoint accepts the request, the webhook debugging runbook provides a better next path than further CSP changes.
A clean fix is boring: identify the blocked target, add that origin to form-action, deploy, and prove both the allowed and blocked cases. The policy remains useful because you resisted the urge to make it disappear.
Sources
[1] Static Forms form editor documentation
[2] MDN: Content-Security-Policy form-action directive
[3] W3C: Content Security Policy Level 3, form-action
[4] Vercel project configuration: headers
[5] Netlify: Custom headers
[6] Vercel: Content Security Policy
Related Articles
Contact Form Success Message Examples That Help Users
Write clearer contact form success messages with practical examples, accessible live-region markup, honest response states, and a tested HTML pattern.
Capture First-Touch UTM Parameters in Contact Forms
Capture first-touch and latest-touch UTM parameters across pages, send them with your contact form, and test storage, consent, and CRM mapping.
Bearer vs Basic vs Custom Header Auth for Form Webhooks
Choose Bearer, Basic, or a custom header for form webhooks. See exact request formats, safe Node.js validation, token rotation, and 401/403 fixes.