A support message came in that looked, at first, like a display bug: "Submitted from only shows https://canvastemplate.com/, but I have forms on eleven different pages." The obvious suspect was our dashboard - maybe we were truncating a URL somewhere, or reading the wrong field. It wasn't that. We pulled the raw stored value for a batch of recent submissions across different pages on that site, and every single one was the bare origin. Not truncated. Not the wrong field. The full page path had simply never arrived.
That's the moment a bug report turns into an HTTP spec question, because the field in question - the Referer header - isn't ours to fill in. The browser writes it, following whatever referrer policy is active for that request, and the modern default in every major browser is strict-origin-when-cross-origin.
What strict-origin-when-cross-origin actually does
Read literally, it's two rules depending on whether the request stays on the same origin or leaves it. Same-origin request: send the full URL, path and all. Cross-origin request: send only the origin - scheme, host, port - and drop everything after it. A plain HTML form posting to send.webforms.to from canvastemplate.com is, by definition, cross-origin. It doesn't matter that the form's action attribute is right there in the page source, obviously intending to go to us. The browser doesn't reason about intent. It just applies the rule.
This isn't a bug in browsers, and it isn't a bug in the spec. It's a genuine privacy feature, and a reasonable one: without it, every third-party service a page talks to - analytics, ad networks, form backends, anything - would silently learn the visitor's exact URL on every request, including query strings that sometimes carry session tokens, search terms, or worse. The policy exists specifically to stop that leak. We were on the other end of a header that was doing exactly what it's supposed to do.
Why there's no server-side fix
The instinct, when you hit a header that isn't giving you what you want, is to look for a way to ask for it more forcefully - a different Accept header, a preflight, something. There isn't one here, and there can't be. The header is stripped before the request leaves the browser. By the time our API sees anything, the full path is already gone; there's no version of "try harder on the server" that recovers information the client never sent.
The only place this can be fixed is the page doing the submitting, and the only way to fix it is to stop relying on Referer for this and send the value explicitly instead.
The fix: an explicit field, with the old behavior as a fallback
We added a directive - a reserved field that isn't part of your form's data, the same mechanism WebForms already uses for things like a custom subject line or a redirect URL. Set it to the full page URL, and it's stored and shown instead of falling back to whatever's left of the Referer header.
<form action="https://send.webforms.to/wft_your_key" method="POST">
<input type="hidden" name="_page" value="" data-webforms-page>
<!-- your real fields -->
</form>
<script>
document.querySelector('[data-webforms-page]').value = window.location.href;
</script>It's opt-in on purpose. Forms that don't set it keep getting whatever Referer actually sends - which is still the origin, still useful, just not the full page. Nothing that worked before this shipped stops working. The directive validates as a URL, capped at 2000 characters, and gets stored under the same referrer field the dashboard, the email templates and the Submissions API were already reading - so every downstream consumer picked up the fix without a single line of its own code changing.
The actual lesson
The generalizable point isn't about forms. It's that a header set by the browser is not a value you can trust to carry more than the browser's privacy policy allows it to carry, and that policy has gotten stricter over the years for good reasons that have nothing to do with your use case. If a piece of context matters to your application - the referring page, a UTM parameter, a client-side timestamp - and a browser-controlled header happens to carry it today, don't build a permanent assumption on top of that. Send it explicitly, from code you control, and treat the header as a fallback for people who haven't wired up the explicit version yet.