Controls that are present and inert
The weakness we report most often is not an absent control, but one that exists, is configured, appears in the evidence pack and does nothing at all. The header is there and the browser ignores it; the policy is there and a second policy overrides it; the allowlist is there and it matches everything. This is the first of two parts — the concept, and the header- and origin-level cases where we see it most.
The control that passes the checklist and stops nothing
A missing control is a simple finding. Someone forgot it, a scanner notices it is absent, the remediation is to add it. Everyone understands the problem within a sentence.
An inert control is a different animal. The configuration is present. The header is emitted on every request. The policy string is long, specific and clearly the product of deliberate work. It appears in the hardening baseline and in the compliance row that says implemented. And at request time, in a real browser, it has no effect on the outcome it was chosen to prevent.
It is harder to find than a missing control because nearly every tool in the chain looks for presence, not effect. A scanner asserts the header exists and parses. A checklist asks whether clickjacking protection is deployed, and the honest answer is yes. An auditor asks for evidence and receives a capture showing the header. Each check is correct individually; none evaluates the control the way a browser does — against the other headers in the same response, against the precedence rules of the specification, against what the application then does. The finding surfaces only when somebody reads the whole response as one artefact and asks what a user agent will do with that combination on that screen. It does not fall out of a scan.
It is also systematically under-reported, for reasons that have nothing to do with competence:
- Tooling scores presence. A header-grader that finds a Content-Security-Policy and an
X-Frame-Optionsin one response reports two controls, not a conflict. - The evidence is self-confirming. The artefact proving the control exists also contains the reason it does not work, and nobody reads the second half of it.
- Nobody owns the interaction. Headers accumulate from a framework default, a proxy, a CDN rule, a security baseline and application middleware. Each owner validates their own contribution; none owns the merge.
- Re-testing looks for regressions, not inertia. A control that never worked is not a change, and change is what verification detects.
- The rating is low, so it is cheap to defer. These land as informational or low, are accepted, and sit for years with the compliance row still reading implemented.
The consequence is a posture overstated in exactly the places where it was most consciously addressed. The team with no clickjacking policy knows it has none. The team with two contradictory ones believes it is protected.
When two headers disagree, the browser picks one
The cleanest example is clickjacking protection declared twice, in two mechanisms, with opposite meanings. A response carries both a Content-Security-Policy with a wide-open frame-ancestors directive and a restrictive legacy header:
Content-Security-Policy: default-src 'self'; script-src 'self'; frame-ancestors *
X-Frame-Options: SAMEORIGIN
Read in isolation, the second line is a correct and conventional control: this document may only be framed by pages of its own origin. Read as a pair, it is decoration.
The precedence rule is explicit in the Content Security Policy specification and implemented consistently across current browser engines: where an enforced CSP containing frame-ancestors is present, the user agent honours that directive and ignores X-Frame-Options entirely. CSP was designed to supersede the legacy header, and the supersession is total rather than a merge — no intersection, no "most restrictive wins", no fallback. The CSP directive is the policy, and frame-ancestors * permits framing by any origin.
So the response advertises two controls and enforces the permissive one. A header-grader awards marks for both. An operator reading the configuration sees SAMEORIGIN and concludes the application cannot be framed. It can be framed by anybody.
We found this independently in separate engagements, with no relationship between the codebases or the teams — which is what makes it worth writing about rather than filing as a one-off. Neither was a marketing site. Both were authenticated control-plane and operations interfaces, where a disguised click is worth something to an attacker. Precisely where framing matters most, the protection had been declared twice and enforced permissively.
The mechanism is mundane: someone unsure which origins legitimately embed the application writes *, the existing X-Frame-Options stays because removing a security header feels like a regression, and nobody connects the two lines.
How to harden this
- Treat
frame-ancestorsas the single source of truth and set it to the real answer —'none'if the application is never legitimately embedded, or an explicit list of origins if it is. Never*. - Keep
X-Frame-Optionsonly as legacy compatibility, and make sure it agrees with the CSP directive. A disagreement is a defect in its own right, whichever one wins. - Assert the combination, not the presence: parse both headers in CI and fail the build when
frame-ancestorsis absent, wildcarded, or inconsistent withX-Frame-Options. Test the response as served, since a proxy, CDN or gateway may rewrite it after the application. - Enumerate legitimate embedders deliberately. A wildcard is almost always a stand-in for "we did not want to find out", and the answer is usually "nothing frames this".
Framing matters more on an operations screen than a brochure
Clickjacking is routinely rated low, and for a large class of pages that is right: on a content page with no authenticated state and no actions, the worst outcome is a confusing user experience. Testers who meet the issue mainly on brochureware internalise the low rating as a property of the class.
It is not a property of the class but of the page. Clickjacking has no impact of its own; it borrows the impact of whatever action the victim can be induced to take while authenticated. The question is never "how severe is clickjacking" but "what is the most damaging single interaction on this screen for a logged-in user, and what is it worth to trigger it silently?"
On an operations interface the answer gets uncomfortable quickly, because the framed action moves money, changes system state or submits personal data on the victim's behalf — with their session, privileges and audit identity attached. Two abstracted examples from our own work:
- A personal-data intake form. Framed beneath attacker-controlled content, it becomes a channel for submitting records under a legitimate operator's session, or — with careful overlay and transparency — for capturing what the user types into what they believe is the attacker's page. That is a data-protection event, not a cosmetic one.
- Payment-engine screens that change state. Where one confirmation click releases, halts, reroutes or approves a transaction, framing is a remote state-change primitive with a plausible audit trail pointing at the wrong person. The defect reads "missing framing protection"; the outcome is an unauthorised financial action attributed to a trusted operator.
Both were protected on paper. The framing policy was declared. It was simply the inert kind.
Reflecting any origin you ask for
The origin-level equivalent is a cross-origin policy that answers every question with yes: the server echoes the request's Origin straight back as Access-Control-Allow-Origin.
GET /api/account/profile HTTP/1.1
Origin: https://attacker.example.test
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://attacker.example.test
Content-Type: application/json
This is, again, a control that exists. There is a CORS configuration, it is not a blanket *, so it reads as a considered allowlist, and it satisfies any checklist item asking whether cross-origin access is restricted. In practice it matches every origin that asks — no allowlist with extra steps, and in one respect worse than *, since a wildcard cannot be combined with credentials whereas a reflected origin can.
We found arbitrary origin reflection in three separate engagements. In each the severity was genuinely bounded, by one fact: Access-Control-Allow-Credentials was absent. Without it the browser omits the victim's cookies, so the attacker's page reads an unauthenticated response — generally the same content they could fetch from their own infrastructure. The reflection is real; the exploitability is thin. Say so plainly, because inflating it damages credibility — then say equally plainly that this is a safety margin, not a fix. What keeps the wrong configuration harmless is a property nobody is deliberately maintaining:
- Credentials get enabled. A developer needs a cookie-authenticated call to work from a front end on another origin, adds
Access-Control-Allow-Credentials: true, and an informational finding becomes read-anything-as-the-victim. The change looks local and reasonable, and nobody re-evaluates the origin policy. - A token moves into a readable response. Bearer tokens, CSRF tokens, session identifiers or signed URLs in a body any origin may read turn an unauthenticated endpoint into a bootstrap for authenticated access.
- The real gate is network position. Where a service trusts whatever reaches it — internal, VPN-only or IP-allowlisted — the victim's browser is inside the perimeter, and reflection lets an external page use it to read responses the attacker could never fetch. No credentials needed.
The same failure of review appears in a narrower, easier-to-dismiss form: a developer localhost origin left in a production allowlist. It survives because every reviewer reads it as a development convenience rather than a production grant. But a service that trusts http://localhost:3000 trusts anything on the victim's machine — another local application binding a port, a name resolving to a loopback address, a tool installed for unrelated reasons. An origin is not under your control simply because it is on their computer.
The root cause is the same in both: an allowlist was written, so the control is present and documented, and nobody re-derived what it permits.
How to harden this
- Never construct
Access-Control-Allow-Originfrom the request. Compare the incomingOriginagainst a static, environment-specific allowlist, echo it only on an exact match, and otherwise omit the header. - Treat
Access-Control-Allow-Credentials: trueas a change that forces the origin policy to be re-reviewed. Credentialled CORS and a permissive allowlist must never meet. - Keep development origins out of production by construction — config per environment, with a deploy check that fails on loopback, private-range and wildcard entries.
- Send
Vary: Originwherever the response depends on the origin, so caches cannot serve one caller's policy to another. - Do not let absent credentials be the control. Fix the allowlist, and assume anything readable cross-origin today will eventually carry something sensitive.
- Test adversarially, not functionally: send an unrelated origin, a
nullorigin, a subdomain, and prefix or suffix variations of a legitimate origin, and confirm each is refused rather than matched.
The check that accepts anything
In one application the anti-CSRF header validator read the header, compared it against the session's expected value, and returned success on every path out of the comparison. No header: accepted. Header set to invalid: accepted. A header copied from another user's session: accepted. The function was an expensive way of writing return true — dead code that reads as a control in review and appears in the threat model as "CSRF protection: header validation". Validators reach that state by accretion, not by one careless commit: written strict, loosened for a legacy client, refactored into a helper that logs rather than throws.
What matters is how it survives testing. Most people verify an anti-CSRF control by removing it: strip the header, replay, expect a rejection. But omission and mismatch are different code paths, and a validator loosened for compatibility keeps the omission branch intact — that error is what developers see when they forget the header. The bypass lives in the comparison, so test the comparison.
POST /api/v1/account/email HTTP/1.1
Host: app.example.test
Cookie: session=<valid session>
X-Csrf-Token: aaaaaaaaaaaaaaaaaaaaaaaa
Content-Type: application/json
{"email":"[email protected]"}
Four variants, every time: header absent; present but empty; present with a valid-looking but wrong value; present with a value harvested from another session. A control that rejects only the first is an error message, and the finding is not "CSRF validation can be bypassed" but "CSRF validation is not performed".
How to harden this
- Make rejection the default: construct the expected value, compare, and let any failure to produce or match it raise rather than fall through. Fail closed on absent, empty and malformed values at the same point, so there is one branch to get wrong.
- Assert on mismatch in tests, not only on omission; a suite that never sends a wrong token cannot notice that the comparison has stopped.
- Enforce in middleware covering all state-changing methods by default, with a reviewed exemption list, not per-handler calls that can be dropped.
A token that travels in the URL
A second application validated its anti-CSRF token properly, with a real comparison, but accepted it as a query-string parameter on requests that changed state over GET:
GET /admin/users/delete?id=4192&csrf=6f1c9d2ab74e HTTP/1.1
Host: app.example.test
Cookie: session=<valid session>
Two errors compound, and they have different fixes. The state change over GET makes the operation reachable from anywhere a URL can be placed — an image tag, an email, a prefetch, a browser restoring tabs — and makes it look idempotent to infrastructure that does not expect a retry to delete a user.
The secret in the URL is the worse half. A query string is written to web server access logs, to reverse-proxy and gateway logs, and onward into whatever aggregation platform those feed, where it is retained for months and readable by anyone with a dashboard. It lands in browser history and the session-restore store on disk, is offered in the Referer header to third-party resources on the resulting page, and appears in bookmarks and pasted links. Its only value is that an attacker cannot predict it, and a token deposited in a dozen places stops meeting that requirement without anyone making a mistake.
Fix both halves: state changes over POST, PUT, PATCH or DELETE, token in a header or body, and the query-string parameter rejected outright. A fallback that is accepted is the behaviour.
A cookie that is the same for everyone
Across three separate applications we found the same pre-authentication cookie, described in the code as a signature and used in the login flow. It was emitted in the served HTML, set before authentication, and byte-for-byte identical for every anonymous visitor: a constant dressed up as a per-visitor value.
The job of such a cookie is binding — tying the browser that started an OIDC flow to the one that returns to the callback, so a callback delivered to the wrong browser is rejected. A constant cannot do that: if the value is the same for you and for the attacker, possession proves nothing and the check always passes. The evidence looked excellent — a cookie set, a signature verified — and the contribution was zero.
A value that binds needs four properties. It must be unique per flow, generated fresh when the flow starts, not once per deployment or per user. It must be unpredictable, from a cryptographically secure source with enough entropy that guessing is not a strategy — 128 bits is the sensible floor. It must be held where only that browser can present it from: a cookie scoped to the application with HttpOnly, Secure and SameSite, or server-side state keyed to the pre-auth session. And it must be consumed once, checked on arrival then invalidated. Fail any of the four and nothing is bound, whatever the variable is named.
Login built without the parts that make it safe
The flow that cookie was supposed to protect turned out to be an OIDC authorisation-code login with no state, no PKCE and no nonce. All three omitted. Users logged in successfully, and the integration had passed design review with a sequence diagram that was, as drawn, correct.
stateprotects the callback against cross-site request forgery. Without it an attacker obtains an authorisation code for their own account and causes a victim's browser to visit the callback with it, grafting the attacker's identity onto the victim's session — or, where accounts are linked there, their credential onto the victim's account.- PKCE protects against interception of the authorisation code, which travels via the browser through redirects, referrers, logs and, on mobile, inter-app URL handling. It binds the code to the client that started the flow, via a verifier that was never transmitted.
nonceprotects against replay and injection of ID tokens: carried into the token and checked on return, it lets the relying party assert the token was minted for this request and not lifted from elsewhere.
The defect persists because it is invisible at the altitude where most reviews happen. A diagram shows the user, the relying party, the provider and the arrows between them, and every one of those arrows existed and was correct. state, PKCE and nonce are parameters on two of them, below the diagram's resolution. You never see them missing in an architecture document; you see it immediately in a proxy log.
How to harden this
- Capture the authorisation request and callback for every login integration and read the parameters; a diagram or a library name is not evidence.
- Require
state,code_challengewithS256andnonceon every authorisation request, and reject at the callback when the value is absent, unrecognised or already used. - Hold the pre-flow values server-side keyed to the pre-auth session, or in a cookie with
HttpOnly,SecureandSameSite, each with a short expiry and single use. - Validate the ID token fully — issuer, audience, signature, expiry,
nonce— not by decoding it and trusting its claims.
Gates drawn in the browser
An administrative interface hid privileged screens from users without the relevant role: navigation entries suppressed, routes refusing to render, buttons not drawn. Every one of those decisions was made in JavaScript, after the server had sent the page, from a role claim the client was holding. The API behind it authenticated the caller and performed the write. The gate was real for anyone using the application as shipped, absent for anyone sending requests directly — and the front end had already documented every privileged endpoint.
A variant appeared elsewhere, and it is the more instructive one because the engineering was genuinely good: thorough tamper detection, multiple independent signals, several awkward to defeat individually, covering different categories of interference. They were then combined into a single boolean in client-side state, which every downstream protection consulted. Patch that value, or stub the function computing it, and all of the detection reported clean.
The root cause in both cases is not a coding error. It is implementing a control where the attacker is in charge. The browser, the mobile client and the desktop application are territory the attacker owns completely: they can change the code, change the data it operates on, and lie about the result. A check executing there can raise the cost of casual abuse, but it cannot enforce, because enforcement requires that the enforcing party decide. The useful question is not "is this check correct?" but "who decides the answer?"
How to harden this
- Authorise on the server, per request, at the point of the write, using identity derived from the session rather than any role value the client supplied.
- Treat client-side hiding as presentation only, and assume every endpoint the front end can reach will be called directly by someone who should not reach it.
- Enumerate the API surface independently of the UI and exercise each privileged operation with a low-privileged and an unauthenticated token.
- Report client-side signals to the server as individual, attributable observations and decide server-side, rather than collapsing them into one client verdict.
How to audit for controls that do nothing
None of this is found by scanning or by reading documentation. It is found by a method that is tedious rather than difficult.
Name what the control is supposed to prevent, then attempt exactly that. Not something adjacent, and not a test for its presence. "This token prevents a cross-site request from changing the user's email address" is a testable claim: craft that request and send it. Controls go inert in the gap between their name and their effect.
Send wrong values, not absent ones. Omission tests a presence check; mismatch tests the validation logic. For every token, signature, header and signed parameter: supply a valid-looking but incorrect value, one from another user or session, one from an expired context, and one already used. Absence tends to fail safe by accident; wrongness is where the control either works or does not.
Test the API directly rather than through the interface. The interface is the control's best defence and the attacker's least likely entry point. Drive the API with a client that does not share the front end's assumptions: no hidden fields, no sequence, no role filtering.
Diff declared policy against observed behaviour. Treat headers, policies, configuration and role matrices as assertions, then measure the running system against each. Declared and delivered diverge constantly: defined but not sent, sent in report-only mode, enforced on one host and not another. The declaration is a hypothesis; the response is evidence.
Treat a control you cannot prove is working as not implemented. This takes organisational nerve rather than technical skill. If nobody can demonstrate the control refusing the attack it exists to refuse, it enters the register as absent, not as implemented with a caveat. The alternative is an inventory that is largely accurate and partly fiction, with no way to tell which is which.
Are your controls actually enforcing anything?
We test what a control is supposed to prevent, by attempting exactly that — which is how dead controls surface.
Request a proposal