CrawlCheck

Findings · 2026-09-30 · By · 0 views

Our security headers skipped every signed-in session

A CSP and a CORS tightening were verified live and called done. For every signed-in session neither was applied, because one async call was not awaited. The defect, the miss, and the rule.

On 24 September 2026 crawlcheck.io deployed a report-only Content-Security-Policy with a per-response nonce and a CORS tightening, verified them on anonymous fetches, and missed that signed-in sessions received neither. An asynchronous step for signed-in responses was called without await, so the two header steps after it received a Promise, found no headers, and passed it through. The runtime resolved the Promise before sending, so pages looked normal. Fixed the same day with one await; the rule now is to test every header change signed in and signed out.

On 24 September crawlcheck.io shipped two security changes in one afternoon: a Content-Security-Policy in report-only mode with a per-response nonce, and a tightening of the CORS headers so that credentialed cross-origin requests were answered only where they were meant to be. Both were verified on the live site before the deploy was called done. Both were verified signed out. For every signed-in session, neither change was applied at all, and the reason was one missing word in our code. This is the defect, why the verification missed it, and the rule it produced.

The pipeline #

Every response passes through a short chain of steps before it is sent. For signed-in sessions, one step first strips the licence key from the body and marks the response private. Then one step adds the Content-Security-Policy header and its per-response nonce, and another sets the CORS headers. Signed-out responses skip the first step.

The missing word #

The signed-in step is asynchronous, because it has to read the response body to rewrite it. In the deploy that introduced the two security changes, it was called without await. In JavaScript that means the variable that was supposed to hold the rewritten response held a Promise of one instead. The CSP step received the Promise, found no headers property to add a policy to, and passed it through. The CORS step did the same. The Promise eventually resolved to the real response, and that is what the browser received: private, licence key removed, and carrying neither the new CSP header nor the tightened CORS headers.

SessionSigned-in stepCSP headerCORS tighteningVerified?
signed outnot applicableappliedappliedyes — this is what was checked
signed in, before the fixapplied (resolved later)absentabsentno
signed in, after the fixappliedappliedappliedyes

Why it was invisible #

Three things hid it. The scrub still worked, because the runtime resolved the Promise before sending; the signed-in page looked and behaved exactly as before. The CSP was in report-only mode, so even where it was applied nothing was blocked, and where it was absent nothing changed. And the verification fetched the changed pages anonymously, read the new headers, and stopped. The functions did not fail; they received an object with no headers and did the correct thing for an object with no headers, which is nothing. A type check would have caught it. A test that fetched one page with a session cookie and read the headers would have caught it. Neither existed.

The fix #

One word, in the build that followed the same day: await in front of the call. The verification was rerun signed in as well as signed out, on a report page and on the dashboard, reading the CSP header, the CORS headers and the nonce in the HTML. All present. When the policy was later switched from report-only to enforced, the switch was tested the same two ways first.

The rule #

Every gate, header or middleware change is tested signed in and signed out, on a page whose content differs between the two. That is now a standing line in the deploy checklist for this service. The general version is worth stating for any pipeline of this shape: a function that reads a property off its argument and does nothing when the property is absent will silently accept an argument of the wrong type, and an unawaited async call produces exactly that argument. The rollout method that surrounded this change, from report-only through enforcement, is in the CSP rollout guide; the headers the site sends now are listed on the security page and the trust center.

How it was found #

Not by an alert and not by a report. The code was re-read the same afternoon while a different change was being prepared, and the line stood out because the step it called could not return a response synchronously. A fetch with a session cookie confirmed it in one request: no policy header, no tightened CORS headers, the placeholder nonce still in the HTML. The window between the two deploys was short, but the length is not the point; the verification method would have missed it for as long as the code stood, because it never looked at a signed-in response.

What this did not affect #

Anonymous visitors, who are almost all of the site's traffic, received both changes from the first deploy. No data was exposed: the step that removes the licence key ran correctly throughout, because the runtime resolved it before sending. The CORS tightening that was absent on signed-in responses restricted credentialed cross-origin reads that no legitimate client makes, so its absence widened nothing that was being used. The defect was a security control not being applied where it was claimed to be, which is reported here because the claim was made publicly and was wrong for the sessions it mattered most for.

Live, as you read this: the corpus now holds 200,867 domains across 3,178 scans. The figures in this piece were measured on the date above; this line is not.

Every figure above came out of this scanner.

Point it at your own domain and see the same measurements, free.

Scan a domain — free

The main product

Found this on your own site? We fix it for $749.

Scan free to see where you stand. The fix is one site, every finding implemented and re-measured, with a sealed before and after.

Questions this post answers

What went wrong?

An asynchronous step for signed-in responses was called without await for one deploy. The two steps after it, which add the Content-Security-Policy and tighten CORS, received a Promise instead of a response, found no headers to change, and passed it through. Signed-in sessions got neither change.

Why did signed-in pages still work?

The runtime resolved the Promise before sending, so the browser received the correct page. Only the two new headers were missing, and the CSP was in report-only mode, so nothing was blocked or visibly different.

Was any licence key or data exposed?

No. The step that removes the licence key from response bodies ran correctly throughout. The missing CORS tightening restricted a class of credentialed cross-origin request that no legitimate client makes.

How was it found?

By re-reading the code after the deploy and noticing that the call could not have returned a response synchronously. A signed-in fetch then confirmed the headers were absent.

What was the fix?

One await, and a re-verification signed in and signed out on pages whose content differs between the two.

What rule came out of it?

Every gate, header or middleware change is tested both signed in and signed out before it is called done. A function that does nothing when a property is absent will silently accept the wrong type.

Related findings

How anything measured in this article was measured15client identitiesone second, one address5machine filesapex and www114named agentsresolved from robots.txt24sections scoredreach, read, quoteHow anything measured here was measured15 client identities5 machine files114 named agents24 sections scoredone second, one addressapex and wwwresolved from robots.txtreach, read, quote
No account, nothing installed, and the same sequence on every domain — which is what makes one scan comparable to another. Run it on your own site.

Comments

Comments are read before they appear. Nothing is published automatically, and no account is needed.

Writing about this? Facts, live figures and marks — every number on that page is dated and traceable to a scan.

All findings · The dataset · How the dataset works