For a few days this month, every customer who pressed Pay by card on an invoice in CloseQuotely landed on a bare page that said Forbidden. Stripe was never contacted. Nothing in our tests failed. The cause was two security measures, each reasonable on its own, cancelling each other out.
Measure 1: keep private links private
Invoices and quotes live at unguessable links like /i/<token>. Anyone holding the link can see the bill, so we don't want it leaking to other sites through the Referer header. Those pages set:
<meta name="referrer" content="no-referrer"> Measure 2: refuse cross-site money requests
Endpoints that start a payment check where the request came from, as a CSRF defence:
export function assertFinancialRequestOrigin(headers: Pick<Headers, "get">) { const origin = headers.get("origin"); if ( headers.get("sec-fetch-site") === "cross-site" || (origin && origin !== new URL(env.APP_URL).origin) ) throw new AppError("forbidden", "..."); } Together: Origin: null
The Fetch spec has a rule that's easy to miss: when a request's referrer policy is no-referrer, a POST's Origin header is serialized as the string null. So the customer's own browser, posting the Pay form from our own page, sent:
Origin: null Sec-Fetch-Site: same-origin "null" isn't our origin, so the check threw. We reproduced it in Chromium on a local build: POST /i/<token>/pay got a 403 with exactly those headers.
Why nothing caught it
- Integration tests call the route handler directly, with no browser and no
Originheader, so the check never ran against real browser headers. - Browser tests run without Stripe configured, and without Stripe the Pay button isn't shown, so no test ever pressed it.
- The refusal wasn't logged, so the only signal was a customer saying "the link is broken".
The fix
- Invoice and quote pages now use
Referrer-Policy: same-origin. Other sites still get no referrer, so the private link stays private, and same-site POSTs carry the realOrigin. - The check accepts
Origin: nullonly when the browser also says the request came from the same site:
const site = headers.get("sec-fetch-site"); const fromOurOwnPage = origin === "null" && site === "same-origin"; if ( site === "cross-site" || (origin && origin !== new URL(env.APP_URL).origin && !fromOurOwnPage) ) - A refusal now sends the customer back to the bill with a message, and logs why.
Takeaways
- If you set
no-referreron a page with a form, test that form's POST in a real browser against your origin checks. -
Sec-Fetch-Siteis a better signal thanOriginalone: browsers set it, and pages can't override it. - Make your security refusals loud in logs. A silent 403 looks like a broken link to everyone, including you.
I build CloseQuotely, quoting and invoicing for small trade businesses. Customers accept quotes and pay deposits online, which is exactly the path this bug broke. Card payments have been working again since the fix, and we've since run a real card payment through it end to end.


