Contract

Building a Resource Server

What the gate in front of your content must do: the decision order, the refusal rows, what every refusal carries, and how to verify you got it right.

Browse documentation

Who this is for. Anyone putting a licensing gate in front of content
AssetRouter licenses. It is written to be implementable on any stack, in any
language, without reading our code — the contract is HTTP and RSL, not a
library of ours.

What it is not. It is not a description of software we ship. In this
release AssetRouter builds no Resource Server: no middleware, no plugin, no
edge worker. What we provide is this contract and the License Server behind
it; the enforcement point is yours to run, because it sits in your request
path and answers for your content.

A second document covers what changes when a CDN sits in front of your origin:
resource-server-cdn-integration. Read this one first — it is the
behaviour; that one is where the behaviour has to live.


Part 1 — For whoever is deciding

Four things worth knowing before anyone writes code.

What you are building

One gate in front of the content you license. For each request it asks the
License Server a single question — does this token permit this resource right
now
— and then either serves the content or returns a refusal that tells the
agent, in machine-readable form, which licence governs the resource and how to
acquire one.

That last half is the part that is easy to get wrong and easy to skip. An
agent that receives a bare 403 learns nothing and goes away; an agent that
receives the right status with a pointer to your licence can acquire one and
come back paying. The refusal is the sales channel.

What it costs you at runtime

One outbound HTTPS call per protected request, to a single License Server
deployment. Everything else is local.

Three costs follow from that, and none of them is hidden:

  • Latency. Your worst case is the deadline you configure, added to your own
    response time. You choose that number.
  • A dependency. If the License Server cannot be reached, protected content
    is not served — the gate fails closed, deliberately (see Failing closed).
    Unprotected paths are untouched, because the gate never calls out for them.
  • Log volume. Every refusal is a response your own stack will log. Agent
    traffic is high-frequency; expect this to be the first operational question
    someone asks.

A cache is permitted and reduces all three; it costs revocation latency and
measurement accuracy. That trade is stated in full under Caching.

What you must hold up

Two obligations that are yours and cannot be ours:

Availability is yours to decide. We answer whether a licence permits a
resource. Whether you still publish that resource is a question only you can
answer, and a licence never obliges you to keep serving anything.

Abuse protection at your boundary is yours. Traffic reaching our
/introspect arrives under your credentials, so we cannot tell your agents
from a stranger's, and nothing on our side refuses anyone for volume. If a
stranger floods your gate with junk tokens, the cost lands on you and the
evidence is in your logs. Your obligations at the boundary lists what to put
there.

What an offer covers is what your terms say it covers. A rights row may be
sold more than one way — a section subscription beside a single-article
purchase — and each way is one <license> element in your published document,
with its own price. What a purchase buys is not something this server
decides: RSL leaves it to the governing prose, so your terms are where it is
stated. We publish what you wrote and we do not add a convention of our own; a
house rule a crawler cannot see would be no better than none.

What we do enforce is reach, and you set it per offer. An offer that reaches
the region licenses everything its row's pattern covers, less the rows you
sold separately. An offer that reaches the exact resource licenses the one
resource it was bought for, and the token we mint for it is cut to that single
path — so even if your prose is ambiguous, a single-article buyer cannot reach
the section. Your gate does not need to read any of this: the token's region
already carries the answer, which is what the /introspect call is for.

Service responsibilities

AssetRouter authorizes access for free licences and rights confirmed by the publisher. Payments are arranged directly with the publisher.

The publisher serves the content and verifies access tokens on its own infrastructure. Apply the checks below to every protected route.

Authorization records describe requests received and decisions made by AssetRouter. They do not measure successful downloads or subsequent use of content. Publisher-side caching and refusals can produce different totals in the two systems.


Part 2 — The contract

The shape of it

agent ──request, no token──────────────► your gate ──► 401 + where to get a licence
agent ──POST /token───────────────────► License Server ──► token
agent ──request + Authorization: License ─► your gate
                                            │
                                            └─ POST /introspect ──► active? permitted?
                                                                      │
                                            serve ◄───────────────────┘

Your gate speaks to the License Server on exactly one endpoint,
/introspect. The agent speaks to it on /token. You never call /token and
the agent never calls /introspect.

The decision, in order

For each incoming request:

  1. Is this path protected? Decide from your own configuration. If not, serve
    it and stop — make no outbound call, spend nothing.
  2. Is there an Authorization: License <token> header? If absent, return the
    no token row with the licence reference for that scope. Make no outbound
    call
    : you already know the answer, and first contact is the most common
    request an unlicensed crawler makes.
  3. Is the header well formed? Exactly one Authorization header, scheme
    License, one token value. Anything else is the malformed request row.
    Refuse a duplicated header rather than reading the first: a caller that can
    append a second header can choose which one you read.
  4. Ask the License Server. POST /introspect, with your own credentials and
    the exact resource. Bound it in time.
  5. Read the answer. active and permitted select the row. Nothing else
    does — see Selecting the row.
  6. Is the resource still available? Your own state, your own answer. An
    unavailable resource is not a payment problem: return your configured
    unavailable response (404, or 410 if you choose to disclose withdrawal),
    never a licensing refusal.
  7. Serve.

Asking the License Server

POST /licensing/v1/tenants/{tenant_id}/olp/introspect
Authorization: Basic base64(rsl_client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

token=<the token exactly as presented>&resource=<the exact resource URL>

Both parameters are required. Three rules about what you send:

  • resource is the exact URL the agent asked for, built from your own
    configured public origin plus the request path — never from the request's
    Host header or a forwarded-protocol header, both of which arrive from the
    caller. Behind a CDN your origin sees its own internal name, so a value taken
    from the request refuses every good token. A wrong configured value can only
    deny, never grant.

    What we normalise, and what we do not. You do not have to canonicalise
    before sending: we lowercase the scheme and host, resolve a default port to
    its absence, normalise percent-encoding, and remove dot segments. Two things
    survive that and are part of the resource's identity — the query string and
    the fragment
    . So …/report and …/report?ref=x are two resources to us,
    and a token granted for one does not cover the other. Scope selection is a
    different question and looks at the path alone, so both route to the same
    scope and both are protected; it is the identity that differs. Decide
    deliberately whether to send the query string, and send the same thing every
    time — a tracking parameter appended by somebody else's link is the common way
    to turn a good token into a 403. Send no fragment: a fragment never reaches
    a server, so one in your resource came from your own code.

  • token is the value as presented, with no trimming or normalisation. If
    it is not a token we issued, you will be told so — that is the answer, not an
    error.

  • Your credentials are yours, issued to a Resource Server client your tenant
    authorized for the origin you serve. They are not the agent's, and the agent
    never sees them.

  • Reach us over https, with certificate verification on. Your credentials
    travel on every one of these calls as HTTP Basic, which is base64 and not
    encryption, so a client that skips verification hands the pair to whoever
    answers. Several HTTP clients make this a one-line mistake — verify=False,
    rejectUnauthorized: false, curl -k — and it is usually added to get past a
    local certificate problem and then never removed.

Reading the answer

An unusable token — unknown, expired, revoked, presented to the wrong audience,
or whose underlying Grant is no longer good — answers exactly:

{"active": false}

and nothing else. Every one of those cases answers identically, deliberately:
told apart, this endpoint would report which tokens exist in a tenant to
anybody holding one valid credential. Do not try to distinguish them.

A usable token answers:

{
  "active": true,
  "token_type": "License",
  "jti": "…",
  "resource": "https://example.com/reports/q1",
  "permitted": true,
  "exp": 1789000000,
  "license": "<license xmlns=\"https://rslstandard.org/rsl\">…</license>",
  "assetrouter_grant_id": "…"
}
  • active — the token and the authority behind it are live.
  • permitted — whether this licence permits this access. A separate
    question, not a restatement of active: a licence granting rights without
    content access answers active: true, permitted: false on every request, and
    so does a token asked about a resource outside the region it was minted
    over.
  • resource — the resource you submitted, echoed back.
  • exp — the token's own expiry, as a Unix timestamp. Tokens are
    short-lived (600 seconds by default). It bounds any cache you keep.
  • license — the granted <license> element itself, carrying its own
    namespace so you can hand it to an RSL parser as it stands. It is what was
    granted, frozen when the token was issued: a document the publisher later
    republishes, or later withdraws, does not change it.
  • assetrouter_grant_id — ours, namespaced, and you do not need it. It
    exists for reporting in a later release, and it is absent on a token that has
    no Grant behind it, which free-but-governed access does not. Read active
    and permitted.

A not-permitted answer also carries reason, a stable machine string.
Four values reach the wire:

reason What happened
resource_not_covered The token's region does not reach this resource
access_not_licensed The licence grants rights without content access
access_allowance_exhausted A single-fetch allowance is spent
usage_not_covered The licence does not permit the use the token was minted for

Every other reason the server records answers {"active": false} and is
disclosed to nobody.

Do not select your response from reason. It is for your logs and your
support desk. All four map to the same row.

Selecting the row

Two booleans, three reachable rows:

Answer Row
{"active": false} 401 invalid_token
active: true, permitted: false 403 insufficient_scope
active: true, permitted: true serve (subject to your availability check)

Selecting from active and permitted alone is what keeps your gate stable:
a reason added to the protocol later cannot change what you emit.

The refusal rows

Five rows. Compose each one yourself — this is the whole contract:

Condition Status License error
Malformed CAP request, or a duplicated/unsupported authorization parameter 401 invalid_request
Missing, malformed, unknown, expired or revoked token 401 invalid_token
No acquired licence, or required payment/fulfilment incomplete 402 invalid_token
Active token whose licence does not cover this resource or this operation 403 insufficient_scope
Introspection, validation or availability cannot be safely confirmed 503 server_error

The 402 response applies only when the authorization response explicitly identifies an unmet licence or payment requirement. Do not infer that payment is due from a missing or invalid token.

One place this table is wider than the specification's own words, written
down rather than reconciled quietly. RSL §6.3.1 describes 403 as "returned
when the requested use is explicitly prohibited under the license"; the row
above also sends a 403 when an active token's licence does not cover the
resource or the operation. Both are "you are licensed, but not for this", and
the alternative readings are worse: a 401 would tell an agent to acquire a
token it already holds, and a 402 would claim a payment is owed when none is.
Every implementation surveyed makes the same choice. If the specification later
distinguishes them, it will be a new row, not a change to this one.

The 402 is not an x402 payment challenge: it carries licence discovery
and acquisition information, no PAYMENT-REQUIRED header and no accepts
array.

What every refusal must carry

Two requirements, both from RSL §6.3, and both easy to omit without noticing:

On 401 and 402 — a challenge header naming the scheme and the error:

WWW-Authenticate: License error="invalid_token"

On every refusal — the governing licence, by either of two routes:

Link: <https://…/rsl/scopes/…>; rel="license"; type="application/rsl+xml"

or an inline body with Content-Type: application/rsl+xml. An inline licence
is always Cache-Control: no-store.

Without the licence reference an agent has been refused and told nothing. This
is the field most likely to be dropped — by your own code, or by a CDN
configured to strip response headers.

Never in a refusal: a token value, a credential, a beneficiary, a Grant
identifier, or an exception message. A refusal ends up in a response an agent
reads and a log somebody else may see.

"Every refusal" means the five rows above, not everything you ever refuse.
A request you drop at the boundary for volume, shape or bot policy is yours: it
never reached the gate, it has no row here, and this contract does not say what
it answers — a 429 with a Retry-After is the ordinary choice and nothing
here forbids another. Keep that decision at the boundary rather than composing a
licensing refusal there, or the abuse control and the licensing logic stop being
separable. One consequence to accept deliberately: an agent throttled at your
edge learns nothing about the licence, which is the right trade for junk traffic
and the wrong one for a paying agent, so set the ceiling where a legitimate
integration will not reach it.

What you configure, per scope

Two values, handed to you at onboarding:

  • The licence reference — the stable address of the RSL document governing
    that scope. It is keyed on the scope rather than on the document, so it does
    not move when the publisher republishes: you may hold a copy of your no
    token
    response and re-copy it only when we announce a change or the scope is
    withdrawn.
  • The governed regions — which paths that scope licenses through us, each a
    path prefix, with any sub-prefix that is not governed listed beside it as an
    exclusion.

Select a region by longest matching prefix. A path no region covers, or an
exclusion removes, is not protected: serve it and make no call.

That list is narrower than "everything the scope licenses", and deliberately.
A publisher declares rights over a path whether or not a token is required for
it — an open free path is licensed by the published document alone, and no
agent ever calls /token for it — so a gate that protected every declared path
would refuse traffic nobody can obtain a token for. What belongs in your
configuration is the paths where a token is required.

Prefixes follow RFC 9309, the rule RSL delegates to and the same one a
robots.txt author knows: a prefix without an end anchor matches by prefix
(/premium would reach /premiumsecret), and $ anchors the end. Watch that
first rule when a region you were handed does not end in / or $: it is a
prefix, and it reaches every path that starts with those characters.

Failing closed

Any answer that is not a well-formed successful introspection body means you
serve nothing.
Stated by exclusion on purpose, so it covers every case
without enumerating them: an unreachable server, a rejected credential, a
malformed request refusal, an unparseable or oversized body, a 5xx, a
deadline overrun. All of them are the 503 server_error row.

This is the industry default rather than a choice of ours — Envoy's external
authorization defaults to fail-closed and its maintainers issued a CVE against
enabling the opposite; NGINX's auth_request has no fail-open mode at all. The
one well-documented cost of failing closed (Google Cloud, June 2025) came from
a bug in the checking service, and the remedy was to harden the checker rather
than loosen its callers.

Do not add a fallback that serves content when the check fails. It converts an
outage into an unlicensed distribution, silently.

Caching

You may cache an introspection answer. RFC 7662 permits it and bounds it, and
these bounds are not optional:

  • Never past the token's exp.
  • Key on a keyed digest of the token plus the exact resource — never on the
    raw token value, which would put a bearer credential in your cache.
  • Reuse only for the same token, the same exact resource, and the same
    audience.
    A cache that revalidates expiry alone has already produced a
    scope bypass in a shipped product (Ory Oathkeeper, GHSA-qvp4-rpmr-xwrr): a
    token valid for one resource was accepted for another.

Two consequences to accept before you turn one on:

  • Revocation is delayed by up to the remaining token lifetime. Without a
    cache, a revoked Grant stops the very next request.
  • Usage figures undercount. An access served from your cache produces no
    record on our side, so any figure we report is lower than what you served, by
    exactly that amount.

Nothing on our side caches a decision. The cache in front of our own token
lookup holds identity — which row a token value names — and never state, so
every call re-reads whether the token, the client, your authorization of it and
the Grant are still good. That is why a revocation without your cache lands on
the next request: the delay, when there is one, is entirely yours to choose and
entirely yours to disclose.

Your obligations at the boundary

Nothing on our side refuses anyone for volume, and traffic reaching
/introspect arrives under your identity. Four things belong at your edge:

  • A per-IP rate limit on requests carrying Authorization: License.
  • A shape filter rejecting token values that cannot be ours before you spend
    a round trip on them.
  • Bot management or Web Bot Auth as the identity layer. This gate does not
    identify the crawler — RSL pairs CAP with a bot-management service, and that
    is a separate concern from licensing.
  • An idea of the introspection volume you expect, so an anomaly is visible
    as one.

Every platform you are likely to run has all of these; the CDN document lists
the documented feature on each.

Two operational notes

  • Junk tokens are invisible to us. A token we never issued costs one lookup
    on our side and writes no record — deliberately, so a stranger cannot make
    your tenant's audit table grow at their chosen rate. Nothing of ours will
    raise an alarm; your boundary logs are where that traffic is visible.
  • Protected paths are not publicly cacheable. The same discipline as a human
    paywall: a CDN that caches a licensed response serves it to the next agent
    without a token.

Part 3 — Verifying you got it right

A gate that answers the wrong status or omits the licence reference fails
silently
. The agent on the other side is not a person who files a ticket; the
content simply keeps being served or keeps being refused, and the
non-conformance is invisible for as long as nobody looks.

So verify deliberately. For each row, drive a real request through your gate
and check, on the response as it reaches an external client:

  1. The status, exactly.
  2. WWW-Authenticate on 401 and 402, with the right error value.
  3. The licence reference, as a Link header or an inline RSL body.
  4. No token value, credential or internal identifier anywhere in the
    response or in what you logged.
  5. That an unprotected path made no outbound call, and a no-token request
    made none either.

Check at your outermost edge, not at your origin. Conformance is judged on
what reaches the agent. HTTP permits an intermediary to reorder and re-case
header names, which is harmless; some CDNs can be configured to strip
WWW-Authenticate or Link entirely, which is not. A gate that is correct at
the origin and stripped at the edge is a non-conformant gate.

Until you have driven every row through and checked it at that edge, your gate
is unverified, not conformant — those are different words on purpose. The
contract is small enough to implement in an afternoon and it fails silently, so
the comparison is the only thing standing between an implementation that works
and one that merely runs.

For integration questions, contact licensing@assetrouter.eu.

What is worth sending, in rough order of usefulness: a row whose stated
behaviour you could not produce; a case the contract does not cover that your
stack forced you to decide; a sentence you had to read twice. A report that
says "section 4 was ambiguous about X" is worth more to the next reader than a
working implementation we never hear about.


Reference

  • RSL 1.0 Recommendation — §6.3 defines the denial rows, the challenge
    header and the licence reference. §5.5.3 defines the introspection answer.
    https://rslstandard.org/rsl
  • RFC 7662 §2.2 and §4 — introspection response caching and its exp
    ceiling. https://www.rfc-editor.org/rfc/rfc7662.html
  • RFC 9309 §2.2.2 — the path matching rule, including the prefix rule and
    the $ anchor. https://www.rfc-editor.org/rfc/rfc9309.html
  • RFC 9110 §5.1 and §5.3 — what an intermediary may do to header fields.
    https://httpwg.org/specs/rfc9110.html
  • licensing-v1 — the full License Server API, including
    /token for the agent side.
  • resource-server-cdn-integration — where this behaviour lives
    when a CDN is in front of your origin.