Contrat

Exécuter la barrière derrière un CDN

Où doit vivre le comportement de la barrière lorsqu'un CDN se trouve entre l'agent et votre origine. Le contrat ne change pas : seulement où s'exécute chaque partie.

Parcourir la documentation

Read resource-server-contract first. That document is the
behaviour: the decision order, the five refusal rows, what every refusal must
carry. This one is where that behaviour has to live once a CDN sits between the
agent and your origin, and it changes nothing about the contract — a gate that is
correct at the origin and broken at the edge is a broken gate, and every failure
below is a configuration one.

Who this is for. A publisher running the gate behind Cloudflare, Fastly,
Akamai, Amazon CloudFront, or a self-hosted Caddy. Every vendor claim here is
cited to that vendor's own documentation. Where a behaviour could not be
confirmed from the vendor's own docs it says so rather than generalising, and a
blog post is never treated as documentation.

Checked 2026-09-09. Vendors change defaults. Two of the four failures below
are default behaviour today, not misconfiguration, so re-read your own platform's
current page before you trust this one.


Four failures we have found, in the order they will bite you

Each of these is silent. Nothing logs an error, nothing raises an alarm, and the
agent on the other side is not a person who can complain.

Four is what we found, not what exists. Nobody has run this contract behind a
CDN in production yet, so treat this section as a starting checklist rather than
a coverage guarantee — and see Where else it can go quiet below for the classes
we know we have not mapped.

1. Your gate never sees the token

CloudFront removes the Authorization header before forwarding, by default.
Its own words:

When CloudFront forwards a viewer request to your origin, CloudFront removes
some viewer headers by default, including the Authorization header.

So a gate behind a default distribution never receives a credential, answers the
no token row to every agent including the paying ones, and looks — from the
origin — like it is working perfectly. This is the single most likely way to
deploy a correct gate that licenses nobody.

AWS documents three fixes. Pick one:

  • add Authorization to the cache key with a cache policy, which forwards it
    automatically;
  • add it individually in an origin request policy;
  • use the managed Managed-AllViewer origin request policy.

AWS attaches a warning to the second and third, and it is failure 2 below in the
vendor's own voice:

If you forward the Authorization header to your origin without including it
in the cache key, ensure that your origin does not rely on the Authorization
header for access control of cached content. When the Authorization header is
not part of the cache key, CloudFront can serve the same cached response to
both authorized and unauthorized viewers.

CloudFront also removes or replaces Referer, X-Forwarded-Proto, X-Real-IP
and the Accept-* family, and replaces User-Agent with Amazon CloudFront. If
anything in your gate reads those, it is reading a constant.

Sources: the Authorization behaviour and its three fixes — https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/add-origin-custom-headers.html · the other removed and replaced headers — https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/RequestAndResponseBehaviorCustomOrigin.html

On the other four platforms we found no such default, which is weaker than
saying there is none: no vendor documents stripping Authorization on the way
in, and an absence of documentation is not a guarantee. Item 1 of the checklist
proves it for your own platform in one request, and is worth doing even where
this page says the risk is CloudFront's. See failure 3 for what can be stripped
on the way out.

2. A licensed response is cached and served to the next agent

The contract states the rule: protected paths are not publicly cacheable, the
same discipline as a human paywall. What follows is how each platform can break
it, and what to set.

Fastly diverges from ordinary HTTP intuition, and this is worth reading twice.

The existence of the Authorization header in the request does not prevent a
response from being cached.

An Authorization header suppresses shared caching under RFC 9111; on Fastly it
does not, and this applies to both VCL and Compute services. A second Fastly
default compounds it: a response carrying no cache headers at all gets a TTL of
2 minutes
. So a gate that serves protected content without explicit cache
directives is publishing it to the next caller for two minutes, on a platform
where nothing about the request warns you.

The remedy is explicit and belongs on every protected response:
Cache-Control: private, no-store. Fastly documents that private alone makes
the readthrough cache execute a pass.

Two things can still defeat it, and both are worth a search rather than a
resolution here. beresp.cacheable "will cause otherwise uncacheable responses
to be cached"
, so grep your VCL for it and confirm no rule sets it on a
protected path. And Fastly's TTL precedence puts Surrogate-Control: max-age
above Cache-Control — so a Surrogate-Control set anywhere in your
configuration outranks the header you just added. Check both, then prove it on
the wire with item 2 of the checklist; the proof is what closes this, not the
reading.

Sources: https://www.fastly.com/documentation/guides/concepts/edge-state/cache/cache-freshness/ ·
https://www.fastly.com/documentation/reference/vcl/variables/backend-response/beresp-cacheable/

Cloudflare honours origin directives until a Cache Rule is told not to. The
setting to look for is Edge TTL → "Ignore cache-control header and use this
TTL"
, which overrides the origin outright; "Status code TTLs" behave the same
way. Whether "Eligible for cache" alone overrides private or no-store is
not confirmed from Cloudflare's documentation — do not rely on either
reading, and set the Edge TTL behaviour deliberately for protected paths. Note
also that Origin Cache Control is force-enabled and cannot be disabled on Free,
Pro and Business
; only Enterprise can turn it off.

Source: https://developers.cloudflare.com/cache/how-to/cache-rules/settings/

Akamai gives you the strongest instrument: No store or Bypass cache,
which the documentation says "can't be overridden by any other caching
settings."
Use one of them on protected paths. The trap is the ordinary Cache
behaviour with a Max-age, which sets an edge TTL without consulting origin
headers at all
— Honor no-store, Honor private and Honor no-cache exist
only as sub-options under "Honor origin Cache-Control and Expires."

Source: https://techdocs.akamai.com/property-mgr/docs/caching-2

CloudFront: the managed CachingDisabled policy. Beyond suppressing the
cache it also suppresses request collapsing, which matters because collapsing can
serve one origin response to several waiting viewers.

Source: https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/using-managed-cache-policies.html

Caddy has no upstream cache, so there is nothing to disable.

3. The refusal reaches the agent stripped of its meaning

The contract requires two things on a refusal: WWW-Authenticate on 401 and
402, and the licence reference as a Link header or an inline RSL body. An
agent that receives a bare 403 learns nothing and goes away — the refusal is
the sales channel, and these two fields are what make it one.

No vendor documents stripping WWW-Authenticate or Link by default. This
failure is configuration, not default, and it is worth an explicit check rather
than an assumption. The features that can do it:

Platform Feature that removes a response header
Cloudflare Response Header Transform Rules
Fastly Header objects, or VCL in vcl_deliver
Akamai Modify Outgoing Response Header
CloudFront Response headers policy
Caddy header -Name

One Akamai detail worth knowing before you go looking: WWW-Authenticate and
Link are not on its standard header list, so a rule touching them is written
through the "Other" custom-header field. A rule you did not write cannot
strip them, but a rule somebody wrote for another purpose might.

CloudFront's documented default removals on the response side are Set-Cookie
(conditionally), Trailer, Transfer-Encoding, Upgrade, Vary and Via —
none of ours.

Check at your outermost edge, as the contract says. HTTP permits an
intermediary to reorder and re-case field names, which is harmless
(RFC 9110 §5.1). Removing a field is not.

4. Your rate limit is keyed on a value the attacker chooses

The contract makes abuse protection yours, because traffic reaching
/introspect arrives under your credentials and we cannot tell your agents from
a stranger's. A per-address limit is the first control — and behind a CDN your
origin sees the CDN, so the limit must read the forwarded address.

Read it from the right, not the left. Every proxy appends the peer it
actually saw, so the rightmost entries are the trustworthy ones and a client can
write anything into the left. Count a fixed number of hops from the right, and
make that count configuration, because only your deployment knows it. This is
what our own server does for its public routes, with a default of one hop in
production and zero in development.

Fastly's client-IP header is spoofable by design, and this is the sharpest
trap on this page.
In Fastly's own words, Fastly-Client-IP "is not protected
from modification at the edge of the Fastly network, so if a client sets this
header themselves, we will use it."
The documented fix is one VCL snippet:

if (fastly.ff.visits_this_service == 0 && req.restarts == 0) {
  set req.http.Fastly-Client-IP = client.ip;
}

Treat that snippet as required, not advisory, if you rate-limit on that
header: without it the limit is keyed on an attacker-controlled value, which is
worse than no limit because it looks like one. Place it in vcl_recv ahead of
anything that reads the header, and note that its req.restarts == 0 guard means
it does not fire on a restarted request — see Where else it can go quiet.

Source: https://www.fastly.com/documentation/reference/http/http-headers/Fastly-Client-IP/

Cloudflare sets CF-Connecting-IP on every request. What its documentation
does not say is that a client-supplied copy is overwritten — we looked for
that guarantee and did not find it. So the header is trustworthy to the extent
that nothing but Cloudflare can reach your origin, which is a property of your
network rather than of the header, and yours to arrange.

The one spoofing warning that page does carry is about a different header and a
different case: "If you are using Cloudflare in a stacked CDN and
authenticating HTTP requests based on the IP address value in the
True-Client-IP header, you must add a True-Client-IP header to your
requests. If you do not add this header, its value can be spoofed to any
value."

Two further notes. True-Client-IP is Enterprise-only and documented as
spoofable when not set. And a managed transform named "Remove visitor IP
headers"
strips all three of cf-connecting-ip, x-forwarded-for and
true-client-ip — so a single toggle somebody enabled for privacy reasons takes
away every value your rate limit could key on, at once.

Sources: https://developers.cloudflare.com/fundamentals/reference/http-headers/ ·
https://developers.cloudflare.com/rules/transform/managed-transforms/reference/

Akamai's True-Client-IP is opt-in, not automatic. CloudFront's
CloudFront-Viewer-Address is opt-in through a policy, and CloudFront documents
that it overwrites a client-supplied copy. Caddy is the safest default: it
trusts no proxy and "will ignore their values from incoming requests, to prevent
spoofing"
— behind a CDN you must set trusted_proxies, preferably
trusted_proxies_strict, or client_ip_headers is never consulted.

Sources: https://techdocs.akamai.com/property-mgr/docs/origin-server ·
https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/adding-cloudfront-headers.html ·
https://caddyserver.com/docs/caddyfile/directives/reverse_proxy


Where else it can go quiet

Three classes we know about and have not mapped per vendor. Each is silent in the
same way the four above are, and each is worth ten minutes before you ship.

More than one cache between you and the agent. Failure 2 talks about "the
cache" as though there is one. There is often not: Fastly can put an edge POP in
front of a shield POP, each with its own store, and a CDN in front of your own
reverse proxy adds another. A directive set at one layer does not settle the
others, and the verification below tests from wherever you happen to be
standing — so a response you proved uncached from one location can still be
cached at another. If your platform has shielding or tiered caching, set the
directive at the origin, where every layer sees it, and check from more than one
place.

A request that goes round twice. Retry-on-error logic and internal
redirects re-enter the request path, and a guard written for the first pass may
not fire on the second. The Fastly client-IP snippet in failure 4 is exactly this
shape: it is conditioned on req.restarts == 0, so on a restarted request it
does not run, and whatever reads that header afterwards reads an unpinned value.
Wherever you place a guard, ask what a restart does to it, and place it before
anything that consumes what it protects.

The token's shape is not a contract. The boundary control below asks you to
reject values that cannot be ours before spending a round trip, and we do not
publish a format to filter on — deliberately, because a format we published would
be one we could not change. So build that filter from length and character class
at most, keep it loose, and expect to revisit it. A filter tuned too tightly
refuses valid tokens as malformed, which reaches your logs looking exactly like
the junk-token noise you were told to expect and ignore.

Contact licensing@assetrouter.eu if your CDN requires additional configuration.

The four boundary controls, named per platform

The contract assigns four controls to your boundary. Here is the documented
feature for each, so you can tell what your platform and plan actually give you.

A per-address limit on requests carrying Authorization: License

Platform Feature Availability
Cloudflare Rate limiting rules Keying on a request header requires Enterprise (Advanced Rate Limiting). Free and Pro key on IP only; Business adds IP-with-NAT
Fastly ratelimit.check_rate() in VCL — the entry is any string up to 256 bytes, so IP plus header works. Windows of 1, 10 or 60 seconds (VCL reference) Edge Rate Limiting is separately purchased and must be enabled on your account by Fastly
Akamai Rate Policies, whose clientIdentifiers accept request-header:value Lives under Akamai's security product line rather than plain delivery; the exact product entitlement is not confirmed from its public docs — ask your account team
CloudFront AWS WAF RateBasedStatement with custom aggregation keys, which support HEADER combined with IP (a cap on how many keys may combine is not confirmed from the aggregation-options page) Standard AWS WAF, no separate tier
Caddy Not confirmed. No rate limiting in the standard distribution; the community module is outside this document's sourcing bar —

The Cloudflare line is the one to check before you plan around it: "rate-limit on
IP plus Authorization" is an Enterprise capability there, and a Business-plan
publisher has IP-only.

Sources: https://developers.cloudflare.com/waf/rate-limiting-rules/ ·
https://docs.fastly.com/products/edge-rate-limiting ·
https://techdocs.akamai.com/application-security/reference/put-rate-policy

A shape filter on the token, before you spend a round trip

Platform Feature
CloudFront AWS WAF SizeConstraintStatement against SingleHeader{Name: Authorization}, and RegexPatternSetReferenceStatement for shape
Cloudflare WAF custom rules using len() for length and matches for regex — regex requires Business or Enterprise
Fastly VCL in vcl_recv: std.strlen() and the ~ operator
Akamai RequestHeaderCondition supports wildcards; full regex belongs to WAF custom rules. Length matching on a named header: not confirmed
Caddy header_regexp <name> <field> <regexp> (RE2), header !Foo for absence, or the CEL expression matcher. Header-length matching: not confirmed

CloudFront has its own hard ceilings regardless of WAF: a request including
headers is capped at 32,768 bytes (answered 494) and a URL at 8,192
(answered 414).

Bot identity at the edge

The gate does not identify the crawler. RSL pairs the enforcement contract with a
bot-management service, and that is a separate concern from licensing — the gate
answers is this token good for this resource, never is this caller who it
claims to be
.

Web Bot Auth is the mechanism to prefer, because it is a signature rather
than a name. Verification status today:

Platform Verifies Web Bot Auth? Evidence
Cloudflare Yes Vendor docs, updated 2026-07-01
CloudFront / AWS WAF Yes Vendor docs; announced 2025-11-21
Akamai Blog only Its blog describes edge verification; the product docs are login-gated, so this stays blog-grade
Fastly Not confirmed No vendor documentation found
Caddy No Not documented

Two AWS specifics that will surprise you, both from its own documentation. The
labels are awswaf:managed:aws:bot-control:bot:web_bot_auth: with statuses
verified, invalid, expired and unknown_bot, and they require
AWSManagedRulesBotControlRuleSet Version_4.0 or later, explicitly selected.
And when web_bot_auth:verified is present the category rules stand down —
except CategoryAI, which blocks regardless of verification. So an AWS
publisher who wants to admit paying AI agents must write a custom allow rule on
that label; the default posture refuses them after they have paid.

Bot Control's verification also "uses the IP address from the web request
origin"
and cannot be configured to use AWS WAF's forwarded-IP configuration.

Sources: https://developers.cloudflare.com/bots/reference/bot-verification/web-bot-auth/ ·
https://docs.aws.amazon.com/waf/latest/developerguide/aws-managed-rule-groups-bot.html

On public bot directories. Cloudflare Radar, verifiedbots.dev and their
kind are useful here and nowhere else in this system: as a rubric when you decide
what to allow at your edge. They are lists of names, not of holders — an entry
carries an operator, user-agent patterns and IP or reverse-DNS verification, and
no key. Nothing in AssetRouter is provisioned from one, and a licence is never
granted because a directory listed somebody.

A sense of the volume you expect

So an anomaly is visible as one. Nothing on our side refuses anyone for volume,
and junk tokens are invisible to us by design: a token we never issued costs one
lookup and writes no record, so a stranger cannot make your audit table grow at
their chosen rate. Your boundary logs are where that traffic is visible.


If you run the gate as edge code

The contract's design is one synchronous call per protected request. Where you
run that call is yours to choose, and the subrequest budget is what constrains
it.

Platform Mechanism Budget
Cloudflare Workers fetch() CPU 10 ms Free, 30 s default and 5 min max on Paid. Subrequests 50 Free, 10,000 Paid; no documented limit on an individual subrequest
Akamai EdgeWorkers httpRequest() Basic: 2 subrequests, 10 ms CPU, 4 s wall. Dynamic: 4, 20 ms, 5.5 s. Enterprise: 10, 70 ms, 10 s
CloudFront Lambda@Edge only 30 s timeout; response cap 40 KB on viewer events, 1 MB on origin events
Caddy forward_auth No timeout documented
Fastly Compute Limits not confirmed — retrieve them before designing against them

Two facts shape the design rather than decorate it.

CloudFront Functions cannot do this at all. The runtime "doesn't support
dynamic code evaluation, and it restricts access to the network, file system,
environment variables, and timers."
An auth subrequest on CloudFront must be
Lambda@Edge, which is pinned to us-east-1, has no VPC access and no environment
variables.

Akamai's CPU budget is measured in milliseconds. Even Enterprise allows 70 ms
of CPU and 10 subrequests. The wall-clock allowance is generous enough for one
call; the tier you are on materially constrains the rest of what that code can
do, so name the tier rather than saying "Akamai supports it."

Caddy's forward_auth is the closest fit to this contract, and worth knowing
even if you run a CDN in front of it. It sends a GET request to the auth
service so the original body is not consumed; on a 2xx "the header fields in
copy_headers are copied to the original request, and handling continues"
, and
on anything else "the upstream's response is copied back to the client." That
last property is what a licensing gate wants — your refusal reaches the agent
without you composing it twice.

One caveat before you rely on it: the documentation says the response is copied
back, and does not explicitly say the header fields come with it. Since
WWW-Authenticate and the licence reference are the whole value of the refusal,
confirm on the wire that both survive before treating this as done. It is item 3
of the checklist below.

Sources: https://developers.cloudflare.com/workers/platform/limits/ ·
https://techdocs.akamai.com/edgeworkers/docs/resource-tier-limitations ·
https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-function-restrictions.html ·
https://caddyserver.com/docs/caddyfile/directives/forward_auth


Before you call it done

The contract's Part 3 lists what to verify. Behind a CDN, add these five, and run
them against your public hostname rather than your origin:

  1. A request carrying Authorization: License <token> reaches your gate with
    that header intact.
    On CloudFront this is failure 1 and it is the default.
  2. A protected 200 is not in the cache. Ask for the same URL twice with the
    same token and confirm the second was served by your origin, then ask once
    with no token and confirm you get the no token row rather than the content.
  3. A 401 arrives with its WWW-Authenticate and its licence reference.
    Read them off the wire at the public hostname, not from your origin's logs.
  4. Your rate limit counts the client, not the CDN. Set the ceiling low on a
    test path, then spend it from one address and confirm a second address is
    still served — if the second is refused too, every caller is sharing one
    bucket keyed on the CDN. Then send a request with a forged forwarded header
    naming somebody else's address and confirm it did not spend their budget.
    Whatever your limiter answers is yours to choose; what matters here is which
    caller it counted.
  5. An unprotected path still makes no outbound call. Easy to lose when the
    gate is moved from the origin to edge code that runs on everything.

If the observed behavior differs from these checks, contact licensing@assetrouter.eu with the affected route and response headers, excluding credentials and tokens.


Reference

  • resource-server-contract — the behaviour this document places.
  • RFC 9110 §5.1 and §5.3 — what an intermediary may do to header fields.
    https://httpwg.org/specs/rfc9110.html
  • RFC 9111 — HTTP caching, including the shared-cache rule Fastly documents
    itself as diverging from. https://www.rfc-editor.org/rfc/rfc9111.html
  • RSL 1.0 Recommendation §6.3 — the denial rows, and its pairing of the
    enforcement contract with bot management. https://rslstandard.org/rsl

Vendor pages are cited beside each claim rather than collected here, so a claim
and its source stay together when either changes.