Use this skill when code runs at the edge in front of an origin — a Cloudflare Worker or equivalent that proxies, renders or caches on behalf of a backend. It covers who owns a control when two layers could implement it, cross-site request forgery validated at the edge, the two-hop client-address model, header forwarding and what must never reach the origin, private caching and key design, cross-origin policy, cookies, server-side rendering at the edge, secrets and bypass tokens, and edge rate limiting. Also when the user says a request is blocked and nobody knows by which layer, a header is being spoofed, a cached page showed another user's data, a bypass works with only a user agent, or asks whether a control is really on. Do not use it for the origin application's own review, for CDN configuration, or for edge performance tuning.
An edge worker is not a thin proxy. It terminates the request, decides things the origin will never see,
renders pages, and caches. It is a security boundary of its own — and the failure mode that produces
most of the damage is not a bug in it, it is the ambiguity between it and the origin.
The rule: every control has exactly one owner, written down. When two layers could enforce it, each
assumes the other does, and nobody does.
1. Split-brain is the headline risk
Anti-forgery, header trust, rate limiting, redirect policy, security headers: the edge and the origin can
each implement them. Whenever they both can:
Name the owner in both repositories, in a comment at the place where the other layer would have done
it. A reader of either side must learn, from that side, who actually enforces it.
If both sides keep a list — exclusions, allow-lists, trusted paths — those lists are one artefact with
two copies, and they drift. Align them in the same change, or generate one from the other. An exclusion
added on one side only leaves the route uncovered on both.
Do not assume a control is on. Open the file and read the flag. A switch built as a literal in the
source is the production value; a switch read from configuration may be absent. When the origin delegates
to the edge and the edge is disabled, the control does not exist anywhere — and everything looks normal.
2. Anti-forgery, when the edge is the gate
Six invariants. Each one has been violated in a shipped implementation that looked correct.
Path matching for exclusions is one-way and anchored to a segment. The question is does the request
path fall under an excluded prefix, never the reverse — the reverse excludes the ancestors of every
entry. Anchor on the separator, or /checkout-evil falls under /checkout. Normalise trailing wildcards:
compared as a literal segment, * matches nothing, which is an exclusion that never excluded anything.
The token cookie and the server-side secret share one expiry, read from the store. Recomputing the
cookie's lifetime on every page while the secret keeps its original one makes the token outlive the
secret: every write then fails, permanently, with no way out but clearing cookies. Issue from one
function that re-issues when the secret is gone, rather than returning an orphan token.
Validate before the fetch to the origin. A check after it can only replace the response — the write
already happened. Forgery is blind by definition: the attacker does not need to read the answer, only
to cause the effect.
The token must arrive through a channel a third-party site cannot populate: a request header, or a
form field. A value that comes only from cookies proves nothing, because the browser attaches cookies
to forged cross-site requests too — that is not double-submit, and what was actually blocking those
requests was the cookie's same-site attribute. Check the origin header (falling back to the referrer)
as well, and check provenance before reading the body.
Identity cookie strictly server-only; token cookie deliberately readable. The front end has to read
the token to send it back in a header. That is not weaker than putting it in the page, which is equally
reachable by script — and a cross-site scripting flaw defeats every anti-forgery token in every design.
Constant-time comparison, and a refusal that identifies the layer without describing internal state.
Use 403, never 401 — that one means not authenticated and can trigger login flows or a browser
prompt. Put a marker and a code in the message: a bare 403 on a write is indistinguishable from the
origin's, the firewall's or the platform's, and whoever is debugging cannot even tell which layer
answered. The code says which check failed; it never says what the backend holds.
Before switching it on: the front end must already send the token on every tenant, the exclusion lists must
be aligned, then enable on staging and watch the refusal codes — they distinguish "the channel is missing"
from "the provenance is wrong" immediately.
3. The client address is a two-hop model
There is no single true header for every hop.
Hop
Trust
Because
Client → edge
the header the platform sets itself
it is overwritten at the edge, so an inbound value cannot survive
Edge → origin
the header the edge forwarded
at the origin, the platform's own header now contains the edge's address
An "enterprise" variant that carries the client address is spoofable inbound until a managed transform
is guaranteed to overwrite it; until then, refuse it at the edge.
And the whole model holds only while the origin refuses traffic that did not come through the edge.
Without that, every forwarded address header is attacker-controlled — see padosoft-security-baseline.
4. Forwarding is a deny-list, so every new internal header is a decision
Everything not listed passes to the origin. Therefore:
Strip every worker-only and bypass header before each fetch, from one helper, applied at every
forwarding point — there is always more than one.
Mock-authentication headers must be on that list. If public traffic can forward them, anyone
impersonates any customer or operator. This is the single highest-severity item on the list.
Do not strip the client's genuine authorisation header — it belongs to the origin.
Any new internal header is added to the deny-list in the same change, or it reaches the origin.
Diagnostic headers are stripped globally in production and emitted only behind an explicit gate.
5. Caching, and keys
Anything that depends on a user, a body, a market or a cookie is private and not stored. A shared
cache keyed on the URL alone serves one customer's page to another.
The dynamic subrequest to the origin needs the same policy, not only the response: disable caching,
turn off cache-everything, and strip any caching directives the caller supplied. Remove the downstream
cache-control variants from the response too.
A key built from a client-supplied identifier is an object-reference flaw — see
padosoft-tenant-isolation.
Keys need bounded cardinality. Reject over-long paths, reduce long ones to a digest, and do not write
a persistent entry for a lookup whose input is unbounded — that is a storage amplification anybody can
trigger.
A purge endpoint is authenticated by a header in production, never a query parameter, and its route
match is anchored, never a substring. Prefix purges stay restricted to known prefixes.
Before introducing a genuinely public cache: a complete key (user, market, language) and an isolation test
across users and markets.
6. Cross-origin policy
Never reflect an arbitrary origin with credentials, and never a wildcard with credentials.
Reflection goes through one resolver that accepts only a canonical origin — the parsed origin must
equal the input — then matches exactly against the allow-list, then by host suffix only over HTTPS.
Validate the configured suffixes fail-closed: at least two labels, a restricted character set, no
leading or trailing hyphen, an alphabetic or punycode top label, and refuse bare addresses and top-level
domains.
Emit the allow headers only when the resolver returns a value; on refusal, remove them.
Merge the vary header rather than appending, and put expose-headers on the real response, not only on
the preflight.
The preflight's allowed headers are an explicit allow-list — and internal or mock headers never go in it.
7. Cookies
One factory, with secure on by default, a same-site value, HTTP-only opt-in, and no-store forced when
same-site is none. Sanitise name and value against header splitting.
Identity cookies are HTTP-only; the anti-forgery token cookie is the deliberate exception. And the
encoding of a cookie shared with another system is a contract: changing it unilaterally breaks the reader
on the other side.
8. Rendering at the edge
Pick the sink, not a generic escape: HTML, a value inside a script block, a quoted string in
JavaScript, a URL. They are different encodings, and using one for another is the vulnerability.
Templates escape by default; the raw form is only for content already made safe by those helpers.
Never hand-roll a sanitiser with a regular expression for rich HTML or for URLs. Refuse
script-bearing and data schemes, network-path references, backslashes, embedded credentials, control
characters, and anything over a length cap.
An unknown content component fails closed into a placeholder, with every field truncated before
escaping.
Per-request context, never module-level mutable state. This is the trap specific to a long-lived
isolate: a helper that caches something in module scope leaks it into the next request, which belongs to
somebody else. Carry the context explicitly, or in the runtime's request-scoped storage.
9. Secrets and bypasses
Nothing in the repository, and the secret fields in versioned configuration stay empty strings.
Runtime values come from the platform's secret store, with every variable documented by name in the
example file.
A user agent is never authentication. An identifier that on its own grants access is a password,
and it is one that gets logged, shared and copied into a support ticket.
Verify in this order: length cap → exact format → constant-time comparison. The cap comes first, so a
huge value costs nothing. Salts come from the cryptographic random source, never the ordinary one.
A bypass that has been temporarily reduced to something weaker is a dated debt, not a design. Do not
extend it to a second surface.
10. Rate limiting at the edge
Use the platform's native counters. An in-memory counter protects one isolate; an eventually-consistent
store gives an answer that was true somewhere, a moment ago.
Three modes, and the default is off. A dark rollout touches nothing. A log mode consumes the buckets
exactly as enforcement would and never blocks, which is the only way to learn the real thresholds. Only
then, enforce.
An unrecognised or absent mode value must never block traffic. A typo in a variable is not a reason to
return errors to everybody.
Key on the platform's own address header plus the tenant. Never on a forwarded or client-supplied
value — that is a limiter the client chooses the bucket for.
Verified crawlers bypass every bucket, in every mode. Rate-limiting a verified search crawler is
direct, self-inflicted damage. Accept only the explicit positive signal.
Payment callbacks are exempt from the write bucket and stay under the global cap. An allow-list
derived from the real routes, not a pattern.
In enforcement, a missing binding or a malformed address fails closed with a server error, not a pass.
Starting thresholds are guesses until log mode has shown the peak per address, the shape of
shared-address traffic and the clients' burst behaviour.
Gotchas
Combining a zero cache lifetime with a no-store directive throws in at least one runtime, which turns
a privacy fix into an outage. Set the policy one way.
"It is behind the edge" is a statement about the origin's firewall, not about your code.
There is always more than one forwarding point. The helper that strips headers must be applied at all
of them; grep for the fetch, not for the helper.
A refusal without a layer marker costs hours. Three layers can return the same status for different
reasons on the same request.
A long-lived isolate keeps module state between requests from different people. Everything that looks
like a harmless memo is a cross-request leak.
Configuration built as a literal in the source cannot be overridden at runtime, whatever the
environment suggests — which is good for a security floor and surprising for everything else.
Checklist
Every shared control has one named owner, stated on both sides
Duplicated lists aligned in the same change, or generated from one source
The enabling flag was read, not assumed
Anti-forgery: anchored one-way matching · one expiry from the store · validated before the origin fetch
· token from a non-cookie channel · provenance checked before the body · constant-time · 403 with a marker
Client address: platform header at the edge, forwarded header at the origin, origin closed to the outside
Deny-list applied at every forwarding point; mock-auth headers on it; authorisation not stripped
Diagnostic headers stripped in production
Personalised responses private and not stored, on the response and the subrequest
No key from a client identifier; cardinality bounded; purge endpoint anchored and header-authenticated
Cross-origin: canonical resolver, no reflection with credentials, suffixes validated, vary merged
Cookies through one factory; shared-cookie encoding unchanged
Correct sink per context; no hand-rolled sanitiser; unknown components fail closed
No module-level mutable state shared between requests
Secrets absent from the repository; no user-agent-only bypass; cap → format → constant-time
Rate limiting on native counters, default off, unknown mode never blocks, verified crawlers exempt
1---2name: padosoft-edge-worker-security3description: Use this skill when code runs at the edge in front of an origin — a Cloudflare Worker or equivalent that proxies, renders or caches on behalf of a backend. It covers who owns a control when two layers could implement it, cross-site request forgery validated at the edge, the two-hop client-address model, header forwarding and what must never reach the origin, private caching and key design, cross-origin policy, cookies, server-side rendering at the edge, secrets and bypass tokens, and edge rate limiting. Also when the user says a request is blocked and nobody knows by which layer, a header is being spoofed, a cached page showed another user's data, a bypass works with only a user agent, or asks whether a control is really on. Do not use it for the origin application's own review, for CDN configuration, or for edge performance tuning.4license: MIT5---67# Edge worker security89An edge worker is not a thin proxy. It terminates the request, decides things the origin will never see,10renders pages, and caches. It is a **security boundary of its own** — and the failure mode that produces11most of the damage is not a bug in it, it is the ambiguity between it and the origin.1213**The rule: every control has exactly one owner, written down. When two layers could enforce it, each14assumes the other does, and nobody does.**1516---1718## 1. Split-brain is the headline risk1920Anti-forgery, header trust, rate limiting, redirect policy, security headers: the edge and the origin can21each implement them. Whenever they both can:2223- **Name the owner in both repositories**, in a comment at the place where the other layer would have done24 it. A reader of either side must learn, from that side, who actually enforces it.25- **If both sides keep a list** — exclusions, allow-lists, trusted paths — those lists are one artefact with26 two copies, and they drift. Align them in the same change, or generate one from the other. An exclusion27 added on one side only leaves the route uncovered on **both**.28- **Do not assume a control is on. Open the file and read the flag.** A switch built as a literal in the29 source is the production value; a switch read from configuration may be absent. When the origin delegates30 to the edge and the edge is disabled, the control does not exist anywhere — and everything looks normal.3132## 2. Anti-forgery, when the edge is the gate3334Six invariants. Each one has been violated in a shipped implementation that looked correct.35361. **Path matching for exclusions is one-way and anchored to a segment.** The question is *does the request37 path fall under an excluded prefix*, never the reverse — the reverse excludes the **ancestors** of every38 entry. Anchor on the separator, or `/checkout-evil` falls under `/checkout`. Normalise trailing wildcards:39 compared as a literal segment, `*` matches nothing, which is an exclusion that never excluded anything.402. **The token cookie and the server-side secret share one expiry, read from the store.** Recomputing the41 cookie's lifetime on every page while the secret keeps its original one makes the token outlive the42 secret: every write then fails, permanently, with no way out but clearing cookies. Issue from **one43 function** that re-issues when the secret is gone, rather than returning an orphan token.443. **Validate before the fetch to the origin.** A check after it can only replace the response — the write45 already happened. Forgery is **blind** by definition: the attacker does not need to read the answer, only46 to cause the effect.474. **The token must arrive through a channel a third-party site cannot populate**: a request header, or a48 form field. A value that comes **only from cookies proves nothing**, because the browser attaches cookies49 to forged cross-site requests too — that is not double-submit, and what was actually blocking those50 requests was the cookie's same-site attribute. Check the origin header (falling back to the referrer)51 as well, and check provenance **before** reading the body.525. **Identity cookie strictly server-only; token cookie deliberately readable.** The front end has to read53 the token to send it back in a header. That is not weaker than putting it in the page, which is equally54 reachable by script — and a cross-site scripting flaw defeats every anti-forgery token in every design.556. **Constant-time comparison, and a refusal that identifies the layer without describing internal state.**56 Use `403`, never `401` — that one means *not authenticated* and can trigger login flows or a browser57 prompt. Put a **marker and a code** in the message: a bare 403 on a write is indistinguishable from the58 origin's, the firewall's or the platform's, and whoever is debugging cannot even tell which layer59 answered. The code says *which check failed*; it never says what the backend holds.6061Before switching it on: the front end must already send the token on every tenant, the exclusion lists must62be aligned, then enable on staging and watch the refusal codes — they distinguish "the channel is missing"63from "the provenance is wrong" immediately.6465## 3. The client address is a two-hop model6667There is no single true header for every hop.6869| Hop | Trust | Because |70|---|---|---|71| Client → edge | the header the platform sets itself | it is overwritten at the edge, so an inbound value cannot survive |72| Edge → origin | the header the **edge forwarded** | at the origin, the platform's own header now contains the *edge's* address |7374An "enterprise" variant that carries the client address is **spoofable inbound** until a managed transform75is guaranteed to overwrite it; until then, refuse it at the edge.7677And the whole model holds only while **the origin refuses traffic that did not come through the edge**.78Without that, every forwarded address header is attacker-controlled — see **`padosoft-security-baseline`**.7980## 4. Forwarding is a deny-list, so every new internal header is a decision8182Everything not listed passes to the origin. Therefore:8384- **Strip every worker-only and bypass header before each fetch**, from one helper, applied at *every*85 forwarding point — there is always more than one.86- **Mock-authentication headers must be on that list.** If public traffic can forward them, anyone87 impersonates any customer or operator. This is the single highest-severity item on the list.88- **Do not strip the client's genuine authorisation header** — it belongs to the origin.89- **Any new internal header is added to the deny-list in the same change**, or it reaches the origin.90- **Diagnostic headers are stripped globally in production** and emitted only behind an explicit gate.9192## 5. Caching, and keys9394- **Anything that depends on a user, a body, a market or a cookie is private and not stored.** A shared95 cache keyed on the URL alone serves one customer's page to another.96- **The dynamic subrequest to the origin needs the same policy**, not only the response: disable caching,97 turn off cache-everything, and strip any caching directives the caller supplied. Remove the downstream98 cache-control variants from the response too.99- **A key built from a client-supplied identifier is an object-reference flaw** — see100 **`padosoft-tenant-isolation`**.101- **Keys need bounded cardinality.** Reject over-long paths, reduce long ones to a digest, and do not write102 a persistent entry for a lookup whose input is unbounded — that is a storage amplification anybody can103 trigger.104- **A purge endpoint is authenticated by a header in production**, never a query parameter, and its route105 match is **anchored**, never a substring. Prefix purges stay restricted to known prefixes.106- Before introducing a genuinely public cache: a complete key (user, market, language) and an isolation test107 across users and markets.108109## 6. Cross-origin policy110111- **Never reflect an arbitrary origin with credentials**, and never a wildcard with credentials.112- Reflection goes through one resolver that accepts only a **canonical origin** — the parsed origin must113 equal the input — then matches exactly against the allow-list, then by host suffix **only over HTTPS**.114- **Validate the configured suffixes fail-closed**: at least two labels, a restricted character set, no115 leading or trailing hyphen, an alphabetic or punycode top label, and refuse bare addresses and top-level116 domains.117- Emit the allow headers **only** when the resolver returns a value; on refusal, remove them.118- **Merge the vary header rather than appending**, and put expose-headers on the real response, not only on119 the preflight.120- The preflight's allowed headers are an explicit allow-list — and internal or mock headers never go in it.121122## 7. Cookies123124One factory, with secure on by default, a same-site value, HTTP-only opt-in, and **no-store forced when125same-site is none**. Sanitise name and value against header splitting.126127Identity cookies are HTTP-only; the anti-forgery token cookie is the deliberate exception. And **the128encoding of a cookie shared with another system is a contract**: changing it unilaterally breaks the reader129on the other side.130131## 8. Rendering at the edge132133- **Pick the sink, not a generic escape**: HTML, a value inside a script block, a quoted string in134 JavaScript, a URL. They are different encodings, and using one for another is the vulnerability.135- **Templates escape by default**; the raw form is only for content already made safe by those helpers.136- **Never hand-roll a sanitiser with a regular expression** for rich HTML or for URLs. Refuse137 script-bearing and data schemes, network-path references, backslashes, embedded credentials, control138 characters, and anything over a length cap.139- **An unknown content component fails closed** into a placeholder, with every field truncated **before**140 escaping.141- **Per-request context, never module-level mutable state.** This is the trap specific to a long-lived142 isolate: a helper that caches something in module scope leaks it into the next request, which belongs to143 somebody else. Carry the context explicitly, or in the runtime's request-scoped storage.144145## 9. Secrets and bypasses146147- **Nothing in the repository**, and the secret fields in versioned configuration stay empty strings.148 Runtime values come from the platform's secret store, with every variable documented by name in the149 example file.150- **A user agent is never authentication.** An identifier that on its own grants access **is a password**,151 and it is one that gets logged, shared and copied into a support ticket.152- **Verify in this order: length cap → exact format → constant-time comparison.** The cap comes first, so a153 huge value costs nothing. Salts come from the cryptographic random source, never the ordinary one.154- A bypass that has been temporarily reduced to something weaker is a **dated debt**, not a design. Do not155 extend it to a second surface.156157## 10. Rate limiting at the edge158159- **Use the platform's native counters.** An in-memory counter protects one isolate; an eventually-consistent160 store gives an answer that was true somewhere, a moment ago.161- **Three modes, and the default is off.** A dark rollout touches nothing. A *log* mode consumes the buckets162 exactly as enforcement would and never blocks, which is the only way to learn the real thresholds. Only163 then, enforce.164- **An unrecognised or absent mode value must never block traffic.** A typo in a variable is not a reason to165 return errors to everybody.166- **Key on the platform's own address header plus the tenant.** Never on a forwarded or client-supplied167 value — that is a limiter the client chooses the bucket for.168- **Verified crawlers bypass every bucket, in every mode.** Rate-limiting a verified search crawler is169 direct, self-inflicted damage. Accept only the explicit positive signal.170- **Payment callbacks are exempt from the write bucket and stay under the global cap.** An allow-list171 derived from the real routes, not a pattern.172- **In enforcement, a missing binding or a malformed address fails closed with a server error**, not a pass.173- **Starting thresholds are guesses** until log mode has shown the peak per address, the shape of174 shared-address traffic and the clients' burst behaviour.175176---177178## Gotchas179180- **Combining a zero cache lifetime with a no-store directive throws** in at least one runtime, which turns181 a privacy fix into an outage. Set the policy one way.182- **"It is behind the edge" is a statement about the origin's firewall**, not about your code.183- **There is always more than one forwarding point.** The helper that strips headers must be applied at all184 of them; grep for the fetch, not for the helper.185- **A refusal without a layer marker costs hours.** Three layers can return the same status for different186 reasons on the same request.187- **A long-lived isolate keeps module state between requests from different people.** Everything that looks188 like a harmless memo is a cross-request leak.189- **Configuration built as a literal in the source cannot be overridden at runtime**, whatever the190 environment suggests — which is good for a security floor and surprising for everything else.191192## Checklist193194- [ ] Every shared control has one named owner, stated on both sides195- [ ] Duplicated lists aligned in the same change, or generated from one source196- [ ] The enabling flag was **read**, not assumed197- [ ] Anti-forgery: anchored one-way matching · one expiry from the store · validated before the origin fetch198 · token from a non-cookie channel · provenance checked before the body · constant-time · 403 with a marker199- [ ] Client address: platform header at the edge, forwarded header at the origin, origin closed to the outside200- [ ] Deny-list applied at every forwarding point; mock-auth headers on it; authorisation not stripped201- [ ] Diagnostic headers stripped in production202- [ ] Personalised responses private and not stored, on the response **and** the subrequest203- [ ] No key from a client identifier; cardinality bounded; purge endpoint anchored and header-authenticated204- [ ] Cross-origin: canonical resolver, no reflection with credentials, suffixes validated, vary merged205- [ ] Cookies through one factory; shared-cookie encoding unchanged206- [ ] Correct sink per context; no hand-rolled sanitiser; unknown components fail closed207- [ ] No module-level mutable state shared between requests208- [ ] Secrets absent from the repository; no user-agent-only bypass; cap → format → constant-time209- [ ] Rate limiting on native counters, default off, unknown mode never blocks, verified crawlers exempt210211## Final report212213```214Worker: <name> · origin(s): <…>215Ownership: csrf <edge|origin> · headers <…> · rate limit <…> · redirects <…>216Flags read: <flag> = <value> (from source | config)217Duplicated lists: <which, aligned? how>218Forwarding points: <n> — deny-list applied at all: yes | no219Cache: personalised responses <policy> · subrequest <policy> · keys <shape, cardinality>220Rate limiting: mode <off|log|enforce> · key <…> · exemptions <…>221Origin closed to non-edge traffic: yes | no | unknown ← everything above depends on it222```
Run npx skillmds@latest add padosoft/padosoft-edge-worker-security in your terminal (requires Node.js), paste this page's agent-chat prompt into Claude, Cursor, or any MCP-connected agent, or download the SKILL.md file and copy it into your agent's skills directory.
Use this skill when code runs at the edge in front of an origin — a Cloudflare Worker or equivalent that proxies, renders or caches on behalf of a backend. It covers who owns a control when two layers could implement it, cross-site request forgery validated at the edge, the two-hop client-address model, header forwarding and what must never reach the origin, private caching and key design, cross-origin policy, cookies, server-side rendering at the edge, secrets and bypass tokens, and edge rate limiting. Also when the user says a request is blocked and nobody knows by which layer, a header is being spoofed, a cached page showed another user's data, a bypass works with only a user agent, or asks whether a control is really on. Do not use it for the origin application's own review, for CDN configuration, or for edge performance tuning. It is listed under AI & ML on SkillMD.
This skill has not completed SkillMD's automated safety review yet. Capability flags: makes network calls, reads secrets. SkillMD never runs a skill's scripts for you; review the SKILL.md before installing.
This skill is tagged as working with Claude Code, Claude.ai, OpenAI Codex. SKILL.md is an open format, so most agents that read a skills directory can load it too.
Yes. Installing skills from SkillMD is free. This skill is licensed under MIT.
padosoft (@padosoft) published this skill. Their other Agent Skills are listed on their SkillMD profile.