Authentication
Overview
Section titled “Overview”FORA authentication answers three questions for every request:
- Who are you? — Requester identity, proven by Ed25519 signature over a domain-bound key
- What can you access? — Scopes carried in the request (and optionally in a delegation token)
- Who authorized you? — Delegation chain from a principal (organization, user, upstream agent) via holder-bound JWTs that can be narrowed without round-tripping to the issuer
These three layers are independent. A public agent with no delegation still authenticates (layer 1) and declares scopes (layer 2, defaulting to public access). An enterprise agent adds a delegation token (layer 3) that binds the agent to a principal’s entitlements with time and spend limits.
Requester Identity
Section titled “Requester Identity”Every query and every purchase (ResourceQuery, DiscoveryRequest and TransactionRequest) carries a Requester message that identifies who is making the request. The message is required: a request that names no requester is refused as malformed. Registration, account status, usage reports and disputes carry no Requester.
Fields
Section titled “Fields”| Field | Type | Description |
|---|---|---|
id | string | Required, 1 to 255 characters. A free label the agent chooses for attribution (e.g., "agent-research-bot-001"), for example to tell apart sub-agents or end customers that share one key directory. It is never identity: no verifier finds keys from it, and no party trusts it for an authentication or authorization decision |
domain | string | Required, a bare host (a port allowed). The requester’s own domain, the host of its key directory {domain}/.well-known/http-message-signatures-directory. It is never a free label, but it is not where request-signing keys are looked up — that is the covered Signature-Agent member (see Key Lookup). On a direct request it must name the same directory that member names. On a purchase a Broker re-packaged, the Exchange resolves the key that verifies the agent’s AgentAcceptance from this domain, and the claim is authenticated only if that signature verifies |
type | RequesterType | What kind of entity is making the request |
name | string (optional) | Human-readable name (e.g., "Acme Research Assistant") |
scopes | repeated string | Entitlement scopes the requester states; the Exchange does not verify them (see Scope-Based Access) |
delegation | Delegation (optional) | Holder-bound JWT delegation token from a principal |
ext | Struct | Implementer-specific extensions |
ext_critical | repeated string | Critical extension keys (COSE crit pattern) |
RequesterType Enum
Section titled “RequesterType Enum”| Value | Name | Description |
|---|---|---|
| 0 | REQUESTER_TYPE_UNSPECIFIED | unset — rejected at ingest |
| 1 | REQUESTER_TYPE_AGENT | Autonomous AI agent (LLM, RAG system, research bot). |
| 2 | REQUESTER_TYPE_HUMAN_TOOL | Human using an AI-powered tool (copilot, assistant). |
| 3 | REQUESTER_TYPE_SERVICE | Enterprise service account (automated pipeline, cron job). |
| 4 | REQUESTER_TYPE_DELEGATED | Agent acting on behalf of a user (delegated identity). |
| 5 | REQUESTER_TYPE_RESEARCH | Research pipeline (batch data collection, model training). |
Key Lookup
Section titled “Key Lookup”The Exchange resolves a requester’s public key by fetching its key directory — the JWK Set at {origin}/.well-known/http-message-signatures-directory — and selecting the JsonWebKey whose RFC 7638 thumbprint matches the request signature’s keyid.
The {origin} comes from the Signature-Agent header, not from the Requester message and not from the fora.json manifest. Signature-Agent is a dictionary, and the member the verifier follows is the one the signature itself covers, as "signature-agent";key="<label>". Because that member is signed, the pointer the Exchange follows is one the signer committed to, and an attacker cannot repoint it under a valid signature. This is what makes the lookup meaningful: the keyid is only a key thumbprint — proof that the signer holds the key, not a claim about who they are — so it is the directory publishing that key that establishes whose key it is. A verifier therefore keys its lookup on the pair (directory URL, keyid), never on the keyid alone.
This ties cryptographic identity to DNS ownership — if you control the domain, you control the key. The directory’s format, media type and signed response are specified in Key Directory, with a complete example response.
Requester domain and the key directory
Section titled “Requester domain and the key directory”requester.domain names the same directory, and the rule that binds the two depends on whether the request is direct or relayed, which the receiver decides as Direct and relayed requests states:
- Direct request. The agent’s own signature arrives because the agent sent the request itself. The verifier resolves the key from the covered
Signature-Agentmember, as above, never fromrequester.domain. It then MUST requirerequester.domainto name that same directory: the host of the origin that member names, compared by the request-recipient identity rule (the shape check first, then case-folded, an absent port the same as:443, a subdomain a different party). A mismatch is refused asunauthenticatedwithrequest_auth_failureSIGNATURE_INVALID: the signature verifies, but not for the requester the body names. A Broker applies this rule atBrokerService.ExecuteTransaction. AtBrokerService.Resolveit refuses nothing forrequester.domain: every query it originates names the host of the origin that member names. An Exchange applies it to every request whose signer it does not admit as a Broker; a mismatch from a signer it does admit is a relayed request, one of the two cases below. - Purchase relayed through a Broker.
BrokerService.ExecuteTransactionre-packages the purchase, so the request signature and the coveredSignature-Agentmember belong to the Broker. They say only that the call comes from the Broker; they sign no purchase. The Exchange MUST verify each item’sAgentAcceptance, and theAgentRequestAcceptance, against the Ed25519 keys currently valid in the directoryrequester.domainnames, never against the Broker’s key. Those acceptances are the only agent signatures the Exchange sees (see Projected execute requests). - Broker-led discovery. A
ResourceQuerythe Broker originated carries no agent signature, so nothing on that leg authenticatesrequester.domain: it is the Broker’s statement, under the Broker’s own signature, of whom it queries for.
requester.id plays no part in any of this. It is a label the agent chooses, covered by the acceptance signatures so a relay cannot rewrite it, and still only what the agent says.
Direct and relayed requests
Section titled “Direct and relayed requests”Agents and Brokers call the same ExchangeService RPCs, and no field marks a request as relayed. On the two requests that name a requester, ResourceQuery and TransactionRequest, an Exchange tells a direct request from a relayed one by comparing requester.domain with the host of the directory the verified signature’s covered Signature-Agent member names, by the request-recipient identity rule:
requester.domain and the signer’s directory host | The request is | The Exchange |
|---|---|---|
| Equal | Direct: the signer is the requester | Applies the direct-request rule above |
| Different, and the Exchange admits the signer as a Broker | Relayed: a Broker authored it for that requester | Applies the relayed rules above. On discovery requester.domain is the Broker’s statement of whom it queries for; on a purchase the agent’s acceptances are verified against the directory it names |
| Different, and the Exchange does not admit the signer | Not acceptable | Refuses it as unauthenticated with request_auth_failure SIGNATURE_INVALID |
Which Brokers an Exchange admits is the Exchange’s own policy, for example the Brokers registered with it, just as which Exchanges a Broker routes to is the Broker’s. Nothing the sender states admits it: a Broker’s manifest declares ROLE_BROKER, and requester.type describes the requester, but both are the sender’s own statements, so neither decides admission. A Broker is never itself relayed to, so every request a Broker receives is direct: it refuses a mismatched requester at BrokerService.ExecuteTransaction, and at BrokerService.Resolve it answers the request, naming the agent’s verified directory on every query it originates.
Accounts
Section titled “Accounts”An account at an Exchange belongs to an agent’s key directory, the one requester.domain names. Every currently valid key that directory publishes acts for the account, and a key rotation keeps the account and its billing_ref. The Exchange learns the directory only from what authenticates it: on a request the agent sends itself, the request signature, whose directory requester.domain must name; on a purchase a Broker relays, the agent’s acceptances, verified against that directory’s keys. requester.id is an attribution label and never selects an account.
A purchase a Broker relays is the agent’s. Billing, the account checks (DENIAL_REASON_ACCOUNT_NOT_REGISTERED, DENIAL_REASON_ACCOUNT_INACTIVE, DENIAL_REASON_REPORTING_OVERDUE), ownership of the transaction, and the right to report on it and dispute it all refer to the agent’s account, as on a direct purchase. The agent files its usage reports and disputes with the issuing Exchange itself; a Broker files none for it. The Exchange keeps the Broker, the verified request signer, with the transaction record for attribution; no field carries it. Registration and account status are always requested by the agent itself, so their request signature names the account directly.
The WBA Directory Endpoint
Section titled “The WBA Directory Endpoint”Signing keys are published in a dedicated Web Bot Auth key directory at /.well-known/http-message-signatures-directory, separate from the role manifest at /.well-known/fora.json. It is served over https, answers 200 with no redirect, and uses the media type application/http-message-signatures-directory+json. The body is a JWK Set: a keys array of Ed25519 JWKs plus an optional revocation_url. Each key is identified by its RFC 7638 thumbprint (the value carried as the RFC 9421 keyid), so the directory carries no kid labels. Each key carries alg EdDSA, the JOSE name, and FORA’s not_before and not_after bounds, and the response is signed by each key it lists: three rules where FORA departs from WG-00 (Where FORA departs from WG-00). Every FORA participant — agent, exchange, broker, publisher — serves one. See Key Directory for the full rules.
Web Bot Auth Conformance
Section titled “Web Bot Auth Conformance”FORA request signatures follow Web Bot Auth (WBA), as specified by the IETF webbotauth working group in draft-ietf-webbotauth-httpsig-protocol-00 (1 September 2026). This page calls that document WG-00. It is the only document the working group has adopted, and it replaced the earlier individual architecture and directory drafts. FORA pins to WG-00. The editor’s copy after -00 proposes that Signature-Agent carry a direct JWK Set URL instead of an origin, so FORA will revisit this profile when -01 is published.
Conformance is a property FORA has to maintain on purpose. It does not follow from sharing RFC 9421 and Ed25519 with WBA: a signature can use both and still be refused by every WBA verifier. Conformance means two things, and FORA requires both:
- FORA signatures verify as WBA. Every signature a FORA participant creates is a conformant WG-00 signature. A WBA verifier that knows nothing about FORA can verify it and attribute it to the signer’s directory.
- WBA signatures verify at FORA. A FORA verifier accepts a signature made by any WBA library, with or without
alg, provided the signature also covers the extra components FORA requires on that route, its key is Ed25519, and the signer’s directory meets the rules where FORA departs from WG-00. FORA advertises those components through WBA’sAccept-Signaturemechanism, so a WBA library that can add covered components can call FORA without FORA-specific code.
The rules that make both true are in The Web Bot Auth Profile. FORA departs from WG-00 in three places, all in the key directory, each listed with its reason in Where FORA departs from WG-00. None of them changes how a WBA verifier checks a FORA request signature.
WBA establishes identity: this bot is who it claims to be. FORA adds the transaction layer on top of that identity: this bot is authorised to access this content at this price.
Where FORA departs from WG-00
Section titled “Where FORA departs from WG-00”FORA departs from WG-00 in three places, all in the key directory:
- A key’s JWK
algis the JOSE nameEdDSA(RFC 7517 §4.4), not the HTTP Message Signatures nameed25519that WG-00 §5.5.1 uses. See the note under Key Directory. - The directory response must be signed. WG-00 Appendix B.1 only RECOMMENDS it. A FORA verifier uses only the listed keys whose response signature verifies, and that signature covers exactly
"@authority";reqandcontent-digest. A response signature may carry noalg, as WG-00’s own Appendix E.2.3 example does. - Each key must carry
not_beforeandnot_after. A key that lacks either bound is never valid (Key Validity Windows). WG-00 defines no validity members, so a directory written to WG-00 alone must add them before a FORA verifier uses its keys.
Within what WG-00 permits, a FORA verifier also accepts only Ed25519 keys, and ignores a Signature-Agent member whose type is anything but directory.
None of this changes how a WBA verifier checks a FORA request signature: a FORA directory carries everything WG-00 asks for, and a WBA verifier ignores the members it does not know.
Signature Verification
Section titled “Signature Verification”The Requester message carries identity semantics only — it has no signature field. Request authentication is an RFC 9421 HTTP Message Signature over the HTTP request itself, carried in the Signature, Signature-Input and Signature-Agent HTTP headers rather than in the protobuf body. This separates identity semantics (the Requester message) from cryptographic proof (the HTTP signature).
Every signature MUST be a conformant WG-00 signature, as The Web Bot Auth Profile describes. On a FORA RPC, which carries a body, the signature MUST also cover @method, @target-uri, content-digest and authorization. A signature that omits one of them is refused, and the refusal names the missing component. authorization is covered even when empty, so a token cannot be slipped under an existing signature. The Signature-Agent member is covered so the directory the key is looked up in cannot be repointed.
Covering a header is not the same as sending it, and both are required. A verifier rebuilds the signature base from the request it received, so a covered name the request does not carry cannot be reconstructed at all:
- A sender MUST transmit every covered header, including an empty
Authorization.Authorization:with an empty value is the ordinary shape of a request from an agent holding no bearer token, and the field line MUST be present.Signature-Agentis never empty: it always names the signer’s https origin. - A verifier MUST reject a request that omits a covered header, and MUST accept a covered
Authorizationthat is empty. Treating a missing header as empty invents a value the sender may never have bound, and accepts a request whose base cannot be reconstructed. - Header field names are case-insensitive, and repeated field lines under one name are joined with
", "before the base is rebuilt (RFC 9421 §2.1). A second field line placed beside a covered one therefore changes that covered value and invalidates the signature — it does not replace or override it. This is the mechanism that stops a token being slipped under an existing signature, and it means a verifier MUST NOT resolve a covered header by picking one field line among several.
The Exchange verifies it by:
- Parsing
Signature,Signature-InputandSignature-Agent, and selecting theSignature-Agentmember the signature covers - Checking the signature against the profile:
tag="web-bot-auth",created,expiresandkeyidpresent,algeither absent or"ed25519",@target-urior@authoritycovered, and on a FORA RPC the four FORA components covered - Fetching the key directory at
{origin}/.well-known/http-message-signatures-directoryfor the origin that member names, and selecting theJsonWebKeywhose RFC 7638 thumbprint matches thekeyid(and whose[not_before, not_after)window covers now; a key without both bounds is never valid) - Reconstructing the signature base from the declared components and verifying the Ed25519 signature in the
Signatureheader against that key - Confirming the
Content-Digestmatches the request body, and that the directory that published the key is the identity the caller is authorised as
Each signature is checked against its own signer’s directory and that directory’s revocation list, never against another party’s; see Whose list revokes whose key.
If verification fails, the Exchange rejects the request. No fallback, no degraded mode. See The Web Bot Auth Profile below for the header details and a complete example.
Delegation
Section titled “Delegation”The delegation token is a JWT (the only token format) that is holder-of-key bound: the grant is tied to a specific key via the RFC 7800 cnf confirmation claim, and the holder proves possession by signing the request with that key (the same RFC 9421 signature it already sends). The token is not a bearer credential — copying it is useless without the matching private key.
Why not a plain bearer JWT? A bearer JWT is usable by anyone who holds it, and it can’t be narrowed for a sub-agent without a round trip to the issuer. FORA fixes both with one mechanism:
cnfholder-of-key binding (theft-resistance) plus chainedcnfJWTs (offline delegation). A principal narrows a grant for an agent by issuing a child JWT — no call back to the original issuer.
This section is the normative profile of that token. Every value a verifier needs is stated here as a number or an exact string; the Go SDK verifier and the shared test vectors are derived from this text.
Holder-of-key, not bearer
Section titled “Holder-of-key, not bearer”Each delegation JWT carries cnf.jkt — the RFC 7638 JWK thumbprint of the key the grant is bound to. At request time the verifier computes the thumbprint of the key that signed the RFC 9421 request and checks it equals cnf.jkt. A thief who steals the token but not the private signing key cannot produce a valid request, so the token grants nothing. This is the same proof-of-possession mechanism as DPoP.
How delegation works
Section titled “How delegation works”Delegation is a chain of cnf-linked JWTs, content owner → principal → agent:
- Root token — signed by the issuer the verifier trusts (the resource owner, or a subscription provider acting for it). Carries the granted
scope,exp, andcnf.jkt= the principal’s key. The issuer’s configured public key is the verifier’s only trust anchor. - Child token — signed by the principal, narrowing the grant for a specific agent:
scopecovered by the parent’s,expno later than the parent’s,cnf.jkt= the agent’s key. It carries the principal’s public key in its protected header (jwk) and the complete signed parent in itsfora_parentclaim, so the verifier can link it to the parent’scnfand verify it with no other input. - Request — signed by the agent (RFC 9421); the verifier checks
thumbprint(request key) == cnf.jktof the last child.
Chain-linkage invariant: each JWT’s signer MUST be the key the parent named in cnf. The configured key verifies the root; root.cnf pins the principal; the child’s header key must match that pin (and verifies the child); child.cnf pins the agent; the request key must match that pin. A token signed by a key not named upstream is rejected.
The chain narrows only — a child’s scope must be covered by its parent’s (see Scope Matching). The principal can delegate to a sub-agent (and that sub-agent further) by issuing another child JWT, entirely offline, up to the chain limit below. Verifiers need only the issuer’s public key; the principal and agent keys ride inside the chain.
The Delegation Message
Section titled “The Delegation Message”The token is carried in the Delegation message, nested inside Requester:
| Field | Type | Description |
|---|---|---|
principal_domain | string | Mirror of the root token’s iss. Informational: trust comes from the configured key, not from this field |
principal_id | string | Principal’s identifier (e.g., "user@acme.com", "marketdata.example.com") |
scopes | repeated string | Mirror of the terminal token’s effective scopes |
expires_at | Timestamp | When this delegation expires. Exchange MUST reject expired tokens |
max_spend_cents | int64 (optional) | Maximum spend in currency minor units (e.g. cents for USD) |
token | bytes | The complete chain as one compact JWS, at most 32768 bytes (a wire rule, bytes.max_len) |
token_format | string | Token format: "jwt" (the sole/default format) |
revocation_uri | string (optional) | Informational. Revocation lookup is not part of this profile; a chain is invalidated early by withdrawing the configured key (see Verification) |
max_accesses | int32 (optional) | Maximum number of accesses allowed under this delegation. Exchange tracks count and denies with DENIAL_REASON_QUOTA_EXCEEDED when count >= limit. |
quota_period | Duration (optional) | How often access/spend counters reset. E.g., "2592000s" (30 days) for monthly subscriptions — proto-JSON encodes Duration as seconds. When absent, quota is lifetime (bounded only by expires_at). |
issuer | string (optional) | Mirror of the root token’s iss, the configured trust anchor’s identity. Informational: no key is ever looked up from it |
ext | Struct | Implementer-specific extensions |
ext_critical | repeated string | Critical extension keys (COSE crit pattern) |
Every field except token and token_format is a mirror of the signed chain (see Mirror fields). The Exchange MAY use scopes for fast catalog filtering before it verifies the token; the token’s claims are authoritative.
Chain encoding and limits
Section titled “Chain encoding and limits”Delegation.token is one compact JWS: three non-empty segments of unpadded base64url separated by dots, with no other character anywhere, whitespace included. The header segment decodes to one JSON object and the payload segment to exactly one JSON object with nothing after it. The chain is nested, not listed:
- Every child token carries its complete parent, as the parent’s compact serialization, in the string claim
fora_parent. The parent’s signature is therefore inside the child’s signed payload, so a child cannot be re-attached to a different parent. - The root token has no
fora_parentclaim: the innermost token of the chain is the root, and it is verified with a configured key, never with a key it carries. - A chain holds at most 4 tokens: the root and up to three children. The verifier never decodes a fifth token.
- The complete
Delegation.tokenis at most 32768 bytes. The limit is a wire rule,bytes.max_lenon the field, so an oversized token is refused as an invalid request before any JWT is parsed, whether or not the bytes are a valid JWT. - Before each nested decode the verifier checks the remaining depth (fewer than 4 tokens read so far) and that the embedded parent string is not longer than 32768 bytes; only then does it parse the parent. The verifier never fetches an ancestor from anywhere: every token it needs is inside the one it was given.
Signing algorithm and protected header
Section titled “Signing algorithm and protected header”Every token in the chain is signed with an Ed25519 key, and the verifier accepts exactly two names for it in the protected header:
| Header | Required? | Allowed values |
|---|---|---|
alg | Yes | EdDSA (RFC 8037) or Ed25519 (RFC 9864). In both cases the signing key MUST be an Ed25519 OKP key; any other algorithm or curve is rejected. A chain may mix the two names. |
typ | Yes | The exact string fora-delegation+jwt, on every token. A token with another typ, or none, is rejected. |
jwk | Yes on every child; ignored on the root | The signing key as a public JWK: {"kty":"OKP","crv":"Ed25519","x":"<base64url>"}. Its RFC 7638 thumbprint MUST equal the parent’s cnf.jkt. A jwk on the root is not read; the root is verified only with the configured key. |
kid | No | Informational; the verifier does not use it to choose a key. |
crit | MUST NOT be present | The profile defines no extension header parameters, so a token that marks any as critical is rejected. |
b64 | MUST NOT be present | Unencoded payloads (RFC 7797) are not part of the profile. A token that carries b64 is malformed, with or without crit. |
The verifier checks the signature over the original signed bytes (the base64url header and payload exactly as received). It never re-serializes or rewrites a header, so a token stays valid or invalid regardless of the library that produced it.
Delegation-Claims Profile
Section titled “Delegation-Claims Profile”The token’s claims are the authoritative source of the grant; the plaintext Delegation fields are only a mirror (see Mirror fields).
| Claim | Required? | Type and rule |
|---|---|---|
iss | Yes | String. On the root: the identity configured with the trusted key, matched exactly (see Issuers and trust). On a child: the RFC 7638 thumbprint of the child’s own signing key, which is also the parent’s cnf.jkt. |
aud | Yes | A string or a non-empty array of strings. Each value names a recipient by the proto’s Request recipient rule (see Audience). A child’s audiences MUST be a subset of its parent’s. |
cnf with jkt | Yes | Object with the string member jkt: the base64url RFC 7638 SHA-256 thumbprint (43 characters, no padding) of the Ed25519 public JWK the grant is bound to. |
exp | Yes | A finite JSON number: NumericDate seconds since the epoch (RFC 7519). A child’s exp MUST NOT be later than its parent’s. |
nbf | No | A finite JSON number, NumericDate. Honored on every token that carries it. |
jti | Recommended | A non-empty string, unique per issuer. Present so a future revocation check can name the token; the verifier checks only its shape. |
iat, sub | No | Accepted and not interpreted. |
scope | No | Space-separated scopes. A missing or empty scope grants no scoped access; the token is still valid for holder binding alone. A child’s scopes MUST be covered by its parent’s (see Scope Matching): publisher:news:read under publisher:*:read is valid narrowing, publisher:news:* under publisher:*:read widens and is rejected. |
fora_parent | Yes on every child; MUST be absent on the root | The complete parent token as a compact JWS string. |
fora_max_spend_cents | No | Integer, currency minor units. Mirrors Delegation.max_spend_cents. |
fora_max_accesses | No | Integer. Mirrors Delegation.max_accesses. |
fora_quota_period | No | Duration string in seconds, the proto-JSON form ("2592000s"). Mirrors Delegation.quota_period. |
fora_advisory | No | A JSON object of informational members the verifier does not interpret (see Advisory information). |
Rules:
- Any other top-level claim is rejected. A claim this table does not list is a mandatory restriction the verifier cannot evaluate, and a dropped constraint would fail open. Vendors put their metadata inside
fora_advisory; they MUST NOT define top-levelfora_*or other claims. - Claim values keep their home encoding. RFC-registered claims use the encoding their RFC defines;
fora_*claims that mirror aDelegationfield use that field’s proto-JSON encoding, sofora_quota_periodis the same"2592000s"string thequota_periodfield carries on the wire. - A wrong type is a rejection. A non-numeric
exp, an emptyjti, acnfwithoutjktor anaudthat is not a string or array of strings makes the token invalid; the verifier never coerces a value.
Time and clock leeway
Section titled “Time and clock leeway”Times are NumericDate seconds, as JSON numbers between 0 and 253402300799 (9999-12-31T23:59:59Z, the last second a protocol Timestamp carries); a value outside that range is a bad claim, so the effective expiry the verifier returns always fits a Timestamp. The verifier evaluates every token in the chain against one clock reading now with a fixed leeway of 60 seconds:
- A token is expired when
now >= exp + 60. - A token is not yet valid when it carries
nbfandnow < nbf - 60. - Leeway is for clock skew only. The effective expiry the verifier returns is the smallest
expin the chain, without leeway, and an Exchange never issues a gated offer that expires later than it.
Issuers and trust
Section titled “Issuers and trust”Trust is configured, never discovered. The verifier is given a list of trusted root keys, each with the issuer identity it belongs to; in an Exchange that list comes from the subscription record that owns the gated term. The verifier:
- verifies the root token with one of those keys and requires the root’s
issto equal that key’s configured identity, as an exact string. The identity is normally the issuer’s domain (marketdata.example.com), but it is an opaque label: it need not equal the publisher segment of a subscription scope, because a subscription provider may be a different party from the publisher; - requires every child’s
issto equal the RFC 7638 thumbprint of the key that signed it, which is the parent’scnf.jkt. This makes the intermediate issuer checkable with no extra configuration: a child whose signature andjwkare valid but whoseissnames something else is rejected; - never fetches a key through OIDC discovery, a JWKS endpoint, a
jku/x5uheader or any token-supplied URL, and never trusts ajwkon the root. A key that is not configured verifies nothing.
Removing a key from the configured list withdraws trust from every token it signed; a token that was valid yesterday is refused at the next request.
Audience
Section titled “Audience”Each aud value is a recipient identity by the proto’s Request recipient rule, the same rule the exchange field of a request follows:
- Validate the value as sent: a bare host with an optional port
1-65535, ASCII only, no scheme, path, query, userinfo or trailing dot. - Only then lowercase it, and treat an absent port and
:443as the same port. - Compare it exactly with the verifier’s own identity. A subdomain is a different party.
Several audiences are listed explicitly; there is no wildcard audience. A child MAY drop audiences its parent listed and MUST NOT add one. A chain whose terminal token does not name the verifier is rejected.
Mirror fields
Section titled “Mirror fields”The plaintext Delegation fields repeat values from the signed chain so an Exchange can filter cheaply before it verifies. They establish nothing: a verifier reads the chain, and a mirror that disagrees with it cannot grant a scope, extend validity or raise a cap.
| Field | Mirrors |
|---|---|
issuer, principal_domain | The root token’s iss — the trust anchor’s identity, never an intermediate or terminal issuer. |
principal_id | Informational identifier of the principal; not read by the verifier. |
scopes | The terminal token’s effective scopes. |
expires_at | The chain’s earliest exp. |
max_spend_cents, max_accesses, quota_period | The terminal token’s effective caps. |
Inherited restrictions
Section titled “Inherited restrictions”Every restriction travels down the chain. The verifier checks every ancestor, not only the terminal token:
- Scopes narrow only. The effective scopes are the terminal token’s
scope, and each token’s scopes are covered by its parent’s. - Expiry narrows only. A child’s
expis no later than its parent’s, and every token is checked against the clock. The effective expiry is the earliestexpin the chain. - Omitting a restriction does not remove it. A child without
fora_max_spend_centsis still bound by its parent’s cap. A child that states a cap MUST NOT state one higher than its parent’s. - Caps are shared, not multiplied. A parent’s spend or access limit applies to all descendants together; issuing three children does not create three allowances. The effective cap is the smallest in the chain.
- Unsupported mandatory restrictions reject. A verifier that cannot enforce a cap the chain carries, or meets a claim it does not know, rejects the delegation rather than ignoring the restriction. The Go SDK verifier of this release meters nothing, so it refuses any chain that carries
fora_max_spend_cents,fora_max_accessesorfora_quota_period; an Exchange that enforces caps verifies them in its own step.
Advisory information
Section titled “Advisory information”Optional vendor metadata is carried inside the signed fora_advisory object, and only there. The verifier does not interpret its members, so an unknown name there is never a rejection. Two limits keep it from weakening the chain:
- A
fora_advisorymember that uses the name of a profile claim (scope,exp,aud,fora_max_spend_centsand the rest) is rejected: an authorization claim cannot be downgraded to advisory information. fora_advisorycannot loosen an ancestor. A child’s advisory object says nothing about its parent’s mandatory claims, which are checked as written.
Delegation chain example
Section titled “Delegation chain example”MarketData (the trusted issuer) issues a root token to the Acme principal, bound to Acme’s key. The verifier’s configured entry for this issuer is the identity marketdata.example.com with MarketData’s Ed25519 public key.
// root token — signed by marketdata.example.com with the configured key// protected header{ "alg": "Ed25519", "typ": "fora-delegation+jwt" }// claims{ "iss": "marketdata.example.com", "aud": ["exchange.example.com"], "jti": "md-2026-000481", "scope": "quote:* earnings:*", "exp": 1781787345, "cnf": { "jkt": "<thumbprint of Acme's principal key>" }}Acme delegates to a research agent that only needs earnings data, narrowing scope and shortening the TTL — no call back to MarketData. The child names its own signing key as iss, carries that key in jwk, and carries the complete root token in fora_parent:
// child token — signed by Acme's principal key, the key root.cnf.jkt names// protected header{ "alg": "EdDSA", "typ": "fora-delegation+jwt", "jwk": { "kty": "OKP", "crv": "Ed25519", "x": "<Acme principal pubkey>" } }// claims{ "iss": "<thumbprint of Acme's principal key>", "aud": ["exchange.example.com"], "jti": "acme-research-0007", "scope": "earnings:*", "exp": 1781701545, "cnf": { "jkt": "<thumbprint of the agent's key>" }, "fora_parent": "<the complete root token, compact JWS>"}Delegation.token carries the child token only. The agent then signs its request (RFC 9421) with its own key. Zero round trips to MarketData — each delegation is a locally-issued child JWT. The chain’s effective expiry is 1781701545, the child’s exp. The fora_max_spend_cents claim on the root is a cap this release’s Go verifier refuses, as Inherited restrictions explains; an Exchange that meters spend verifies it in its own step.
A chain of exactly this shape, signed with the fixed test keys of the shared chain cases, is the case two_tokens: the root (marketdata.example, quote:* earnings:*, alg: EdDSA) inside a child (earnings:*, alg: Ed25519), addressed to exchange.example. The Go SDK re-verifies it on every test run:
eyJhbGciOiJFZDI1NTE5IiwiandrIjp7ImNydiI6IkVkMjU1MTkiLCJrdHkiOiJPS1AiLCJ4IjoieUYwU2RxTHZHZnlIVjRMV2hMTS1nTXg3clBnX0xNeFpLcHBKZFk3blJ2WSJ9LCJ0eXAiOiJmb3JhLWRlbGVnYXRpb24rand0In0.eyJhdWQiOlsiZXhjaGFuZ2UuZXhhbXBsZSJdLCJjbmYiOnsiamt0IjoiV2ktdk5Udk84NHBvZEpwSXZYS1VsR05EVXVoS0pLUzR6VnpoblJ2VDNZNCJ9LCJleHAiOjE3ODAzMTg4MDAsImZvcmFfcGFyZW50IjoiZXlKaGJHY2lPaUpGWkVSVFFTSXNJblI1Y0NJNkltWnZjbUV0WkdWc1pXZGhkR2x2Yml0cWQzUWlmUS5leUpoZFdRaU9sc2laWGhqYUdGdVoyVXVaWGhoYlhCc1pTSmRMQ0pqYm1ZaU9uc2lhbXQwSWpvaVdHUjNZWEpFTkV3d01WQlpObEZhTUhSaE16QnBia0pNUjNaVU5UZHBkRTFVV1Vsa01raDZRM0ZTUlNKOUxDSmxlSEFpT2pFM09EQXpNVGc0TURBc0ltbHpjeUk2SW0xaGNtdGxkR1JoZEdFdVpYaGhiWEJzWlNJc0luTmpiM0JsSWpvaWNYVnZkR1U2S2lCbFlYSnVhVzVuY3pvcUluMC5pa0tTX2tzVkZTSzk2eE9ja2ZjMW8zc2prd1VpUUhMSUFpNFpCM3B1eHdmMWJQOTgzcGl6RkhLNy16Q2pid2VLVmNWaFczeTlkOEEyTTJnVGxtTk5BUSIsImlzcyI6Ilhkd2FyRDRMMDFQWTZRWjB0YTMwaW5CTEd2VDU3aXRNVFlJZDJIekNxUkUiLCJzY29wZSI6ImVhcm5pbmdzOioifQ.BYPx8_MNws93olALVra3qZDnx-Xw9wIhtansjrBcAJzEid1psR764LvcyE0pdJ1GhXfuMd9SYWRHQHRy6fOoCAVerification
Section titled “Verification”The Exchange or edge verifies a delegation offline, with the configured issuer keys as its only input besides the request. Every step fails closed. What a failure produces depends on where it happens:
- A
tokenover 32768 bytes fails wire validation: the whole request is refused as invalid, before any item is read, like any other field that breaks a wire rule. - At discovery, a chain that fails any later step is treated as if no delegation were present: the terms gated behind it are hidden (see Existence hiding), and no denial reason reports why. The Exchange MAY journal the failure.
- At purchase, a chain that fails any later step denies the item with
DENIAL_REASON_DELEGATION_INVALID.
The Go SDK implements exactly this order in VerifyDelegation, and the shared chain cases in sdk/go/helpers/testdata/delegation-chains.json pin which check refuses which input:
- Size and shape. The
Delegation.tokenis at most 32768 bytes (checked at wire validation and again here, before parsing) and is one compact JWS with the segment rules above.token_formatis"jwt"or empty. - Walk the chain inward. Decode the outer token’s protected header and claims and check them against the profile:
algisEdDSAorEd25519,typisfora-delegation+jwt, nocrit, the required claims are present with the right types, no unknown top-level claim, no cap claim the verifier cannot enforce, no profile name insidefora_advisory. If the token carriesfora_parent, check the remaining depth and the parent’s size, then decode the parent the same way, and repeat until a token withoutfora_parentis reached: that token is the root. A fifth token or an oversized parent is a rejection. - Verify the root with the configured key whose identity equals the root’s
iss. No configured key for that identity, or a signature that does not verify, is a rejection. The root’sjwk, if any, is ignored. - Verify each child outward from the root: its protected
jwkis an Ed25519 OKP public key,thumbprint(jwk) == parent.cnf.jkt(the chain-linkage invariant), itsissequals that thumbprint, and its signature verifies with that key over the original signed bytes. - Check time on every token with the 60-second leeway: not expired, not before
nbf, and each child’sexpno later than its parent’s. - Check attenuation on every child: audiences a subset of the parent’s, scopes covered by the parent’s.
- Check the audience on every token:
audnames the verifier’s own identity by the Request recipient rule. - Verify holder binding — the agent’s key that the request proves possession of MUST hash to the terminal token’s
cnf.jkt: on a direct call the key that signed the HTTP request (RFC 9421), on a purchase a Broker relayed the key that signed the agent’sAgentAcceptance(Which signature binds in a brokered request). On a query a Broker originated, which proves possession of no agent key, the delegation is not verified at all: it is not honored, and the query is answered as if none were present. This is what makes a leaked token useless to a thief: possessing the bytes is not enough — you must control the bound private key. It is not optional and not skippable; an Exchange that omits it reduces the token to a bearer credential and breaks the anti-theft guarantee. - Return the verified result: the root issuer identity, the terminal holder thumbprint, the effective scopes and the effective expiry. A gated offer the Exchange then issues MUST NOT expire later than that effective expiry.
Revocation lookup through revocation_uri is not part of this profile; withdrawing a configured key is the way to invalidate a chain early.
Binding across hops. Each child re-binds to the next holder’s key (cnf.jkt of the child = the next agent), so at every hop the bound key is whoever now signs the request. The chain of JWT signatures carries the binding forward — there is no point at which the token reverts to bearer form.
Which signature binds in a brokered request. The holder-binding check always matches the terminal holder — the key named in the last child’s cnf.jkt — and that key is always a key the request proves possession of. On a direct call the proof is the agent’s RFC 9421 request signature, so cnf.jkt is checked against the key that signed the request. On a purchase relayed by a Broker (BrokerService.ExecuteTransaction), the Broker signs the transport request alone, so cnf.jkt is checked against the key that signed the agent’s AgentAcceptance in the body, and an item without an AgentAcceptance fails the binding. The delegation therefore stays bound to the agent’s private key through the Broker, exactly as on a direct call. A Broker is never delegated to and never becomes the holder. On a query a Broker originated (Broker-led discovery), the Broker signs alone, so a delegation on it cannot pass holder binding: the Exchange does not honor it and answers as if none were present. How an agent proves a holder-of-key entitlement at discovery time through a Broker is an open protocol question. An Exchange that requires a verified delegation before it presents a scoped term (Scope-Based Access) therefore presents no such term on that query; the agent can query that Exchange directly, where its own request signature passes the holder binding.
Delegation Denial Reasons
Section titled “Delegation Denial Reasons”When delegation verification fails, the Exchange returns a specific denial reason in the response:
| Denial Reason | Meaning |
|---|---|
DENIAL_REASON_QUOTA_EXCEEDED | Subscription access count exhausted for this period |
DENIAL_REASON_DELEGATION_INVALID | On a purchase item: delegation token missing, malformed, unverifiable (broken chain linkage, wrong issuer or holder-binding mismatch), expired, not valid yet, addressed to another recipient, or it widens scope, audience or a cap beyond its parent. A token over 32768 bytes never reaches an item: wire validation refuses the request. At discovery none of these is reported; the gated terms are hidden instead |
Libraries
Section titled “Libraries”| Language | Library | Notes |
|---|---|---|
| Go | sdk/go/helpers (VerifyDelegation) | The reference verifier of this profile. It checks the Ed25519 signature with the standard library under either alg name and uses go-jose for the jwk header and the RFC 7638 thumbprint, because go-jose alone does not accept alg: Ed25519. Issuers may sign with go-jose (EdDSA) or any JOSE library. |
| TypeScript | jose (5.10 or later) | Signs and verifies under Ed25519 and EdDSA; runs in Node and the browser. A TypeScript chain verifier is deferred (see the parity matrix). |
| Python | joserfc or PyJWT (+ cryptography) | Signs under Ed25519 or EdDSA. A Python chain verifier is deferred (see the parity matrix). |
All use Ed25519 natively — the same key type as FORA identity signatures — so the delegation token and the RFC 9421 request signature share one key type.
Scope-Based Access
Section titled “Scope-Based Access”Scopes are how the Exchange decides which terms to present to a requester. They are the requester’s own statement of what it is entitled to, part of its signed request, attributable to the signer, and not verified by the Exchange; on a query a Broker originated, they are the Broker’s statement for the agent. The flow is:
- Agent sends
ResourceQuerywithRequester.scopes(and optionally aDelegationthat also carries scopes) - Exchange presents an offer for a term only when the stated scopes cover the term’s
LicenseTerm.scopes - Terms the scopes do not cover are not returned. The requester never learns they exist (see Existence hiding).
Scope coverage is not access control: it hides a term from a requester that does not state the scope, and it does not stop one that states it falsely. A false statement is traceable to the signing key, its directory and its account, and the agent’s acceptance signs the scoped offer it buys, so it is settled through reconciliation and the account relationship. An Exchange MAY also require a verified delegation that grants a term’s scopes before it presents the term; to a requester that presents none, it answers as if the term did not exist.
Scope Format
Section titled “Scope Format”Scopes follow the pattern {domain}:{permission} or {profile}:{permission}:
| Scope | Meaning |
|---|---|
"credit:read" | Can access credit reports (read-only) |
"subscription:marketdata-2026" | Has active MarketData subscription |
"revshare:publisher-x" | Party to an off-protocol revenue-share agreement (gates the FREE revshare term — see Licensing Terms) |
"academic:*" | Full access to academic resources |
"internal:reports" | Can access internal reports |
"dist:US:CA" | Multi-segment scope (colon-separated; matched segment-wise, see below) |
"*" | Unrestricted (public Exchange default) |
Scope Matching
Section titled “Scope Matching”Matching is segment-wise (:-separated). A granted scope G covers a required scope R iff, comparing segment by segment, each G segment equals the corresponding R segment or is *; a terminal * matches all remaining segments, of which there must be at least one. There is no implicit prefix match. A grant that is more specific than what is required does not cover it — coverage requires G to be equal-to-or-broader-than R, never narrower (e.g. granted dist:US:CA does not cover required dist:US, and granted dist:US:* does not cover required dist:US). A * in the required scope is a literal segment, so a narrow grant never covers a wildcard requirement; this is what stops a delegation child from widening its parent’s grant. Matching is case-sensitive and never trims. One algorithm applies protocol-wide: to Requester.scopes against LicenseTerm.scopes, and to a child delegation’s scopes against its parent’s.
| Granted | Covers | Does not cover |
|---|---|---|
dist:* | dist:US, dist:US:CA, dist:US:* | dist (a terminal * needs a segment to match) |
dist:US:* | dist:US:CA | dist:EU, dist:US |
publisher:*:read | publisher:news:read, publisher:*:read | publisher:news:write, publisher:news:* (the required * is literal) |
dist | dist | dist:US |
dist:US:CA | dist:US:CA | dist:US (grant narrower than required) |
credit:read | credit:read | *, Credit:read |
* | everything | — |
A set of granted scopes covers a set of required scopes when every required scope is covered by at least one granted scope. An empty required set is always covered; an empty granted set covers nothing else. The SDKs expose this as ScopeCovers / ScopesCover (scope_covers / scopes_cover, scopeCovers / scopesCover), and ScopesSubset is the same check read from the child’s side.
Access Tiers
Section titled “Access Tiers”No scopes (empty): Exchange applies its default access policy — typically all publicly available resources. This is the common case for public agents.
Subscription scopes: A scope like subscription:nyt-2026 states that the requester holds a subscription entitlement. The Exchange presents the subscription terms that scope covers (zero marginal cost) instead of per-request pricing. An Exchange that requires proof of the subscription presents them only with a verified delegation that grants the scope.
Enterprise scopes: Specific {domain}:{permission} scopes state roles, and the Exchange presents the restricted terms they cover. The enterprise’s own policy point, typically its Broker, decides which scopes a user’s request carries; the Exchange does not verify them, and may require a verified delegation for the terms it must not present on a statement alone.
Existence hiding
Section titled “Existence hiding”A requester never learns about a resource outside its scopes. This is a rule of the protocol, not an Exchange’s policy choice, and it is stated on Requester.scopes:
- At discovery: when the requester’s scopes leave no presentable offer for a resource, the Exchange answers with no offers and no absence reason (an
OfferGroupwith emptyoffersandabsence_reasonunset), exactly as for any resource with nothing to offer. A Broker relaying that answer adds no reason of its own. - At purchase: an offer the Exchange presented is honoured until it expires, so a purchase is never refused for scope.
- With a delegation required: an Exchange that requires a verified delegation before it presents a scoped term answers a requester that presents none as if the term did not exist.
No OfferAbsenceReason or DenialReason value reports a scope shortfall. The two values that once did are deprecated and never sent, and a receiver treats them as unknown.
Multi-hop forwarding
Section titled “Multi-hop forwarding”Nothing about a request’s route is carried in the message body. Each party signs the request it sends with its own labeled RFC 9421 signature in the HTTP headers. FORA’s own parties sign alone: an agent signs the requests it sends, and a Broker signs the requests it authors. This section is the one place the rules are specified, including the general rule for a third party that forwards a request unchanged; other pages refer to it.
How It Works
Section titled “How It Works”Every signature is a conformant WG-00 signature in its own right, with its own label and its own Signature-Agent member naming its signer’s directory (The Web Bot Auth Profile). WG-00 §5.2.2 sets the general rule for a party that passes on a request someone else signed. It MAY cover the earlier signature, and if it covers "signature";key=X it MUST also cover "signature-input";key=X and every component listed there. A party that cannot, because it changed a value the earlier signature was computed over, MUST NOT cover the earlier signature. It signs the request on its own terms.
FORA’s Broker signs alone
Section titled “FORA’s Broker signs alone”FORA’s Broker never forwards an agent’s request unchanged, so it never covers an agent’s signature. Each request it sends to an Exchange is its own:
- Discovery is Broker-led. The agent asks the Broker, not an Exchange: the point of the Broker is that the agent does not know the Exchange at discovery time. The Broker chooses the Exchanges and originates its own
DiscoverResourcesqueries, signed by the Broker alone, so a delegation on such a query is not honoured (Which signature binds in a brokered request). How an agent proves a holder-of-key entitlement at discovery time through a Broker is an open protocol question. - Execute is re-packaged. The Broker builds a fresh
TransactionRequestfor each Exchange and signs it alone (Projected execute requests).
In both cases the Broker’s signature covers its own request components, @method, @target-uri, content-digest and authorization, and exactly one Signature-Agent member, its own. The request carries no agent signature headers. Single-member WBA verifiers, such as Cloudflare’s web-bot-auth library, therefore accept every Broker signature.
The Exchange authenticates the caller as the Broker. On a relayed purchase, the agent’s identity and consent come from the body, not from the transport: each item carries the agent’s AgentAcceptance, a detached Ed25519 signature over {offer_sig, requester_id, requester_domain, idempotency_key}. The Exchange verifies it against the agent’s registered key, which it resolves from Requester.domain when a Broker re-packaged the request. That key also gives the delivery-URL binding: agent_id is its RFC 7638 thumbprint. AgentAcceptance is application-level consent, not a WBA transport signature, and this profile does not change it.
The general rule for unchanged forwarding
Section titled “The general rule for unchanged forwarding”A party that forwards a request unchanged in every component an earlier signature covers MAY cover that signature, as WG-00 §5.2.2 describes. Its signature then covers its own request components, its own Signature-Agent member, and the earlier signature together with that signature’s Signature-Input member and every component it lists. When every party covers the signature before it this way, the ordered set of signatures is a forwarding chain: tamper-evident and order-bound. FORA’s Broker does not forward this way.
Verification at the Exchange
Section titled “Verification at the Exchange”The Exchange:
- Counts the signatures on the request before it verifies any, every signature counted whether or not it covers another, and refuses a request carrying more than its
WellKnownManifest.max_intermediary_hopswith the Connect coderesource_exhausted(HTTP 429) and no typed reason.RequestConstraints.max_hopsnever reaches an Exchange: it rides only on the agent’sDiscoveryRequestto the Broker, and no party enforces it in this version - Reads each labeled signature and its
keyidfromSignature/Signature-Input - Resolves each signature’s
keyid(an RFC 7638 thumbprint) in the key directory that its own coveredSignature-Agentmember names, at{origin}/.well-known/http-message-signatures-directory. A verifier MUST NOT attribute a signature to a member that signature does not cover (WG-00 §5.2.2). A signature that covers an earlier one also covers that signature’s member, so it covers several; it is attributed to the member keyed to its own label (WG-00 §5.2.1), and a signature covering several members none of which is keyed to its label is refused - Verifies each signature independently against its own covered components and its own key, and authenticates the caller as the party that signed the request: the agent on a direct call, the Broker on a request the Broker sent. Where a party forwarded unchanged and covered an earlier signature, it also confirms that coverage
- On an execute request, verifies each item’s
AgentAcceptanceagainst the agent’s registered key, as above
Verifying a signature proves which party sent the request. Whether that party may act for another requester is the Exchange’s own admission policy, for example the Brokers registered with it: a request whose requester.domain names a directory other than the signer’s is processed only from a Broker the Exchange admits, and is otherwise refused as unauthenticated with request_auth_failure SIGNATURE_INVALID (see Direct and relayed requests).
Neither Broker RPC forwards a request. BrokerService.Resolve and BrokerService.ExecuteTransaction terminate at the Broker, which originates its own discovery queries and re-packages a purchase into one new request per Exchange, each signed by the Broker alone. See Projected execute requests.
See Multi-Hop Example (Broker) below for the concrete header layout.
Projected execute requests
Section titled “Projected execute requests”A Broker re-packages every execute request it relays on BrokerService.ExecuteTransaction. When it splits a mixed-Exchange TransactionRequest into per-Exchange subrequests, each subrequest has a new body, so the agent’s original signature — which covered the original body through Content-Digest — cannot travel with it. There is no way to forward a re-packaged body under the agent’s HTTP signature, and the protocol does not ask for one.
Instead, each projected subrequest is a new HTTP request its sender authors. The projecting party — a Broker, or the agent itself when it splits its own set — computes the subrequest’s Content-Digest and signs it with its own RFC 9421 signature, exactly as any sender does. The Exchange authenticates the wire caller as that sender.
The agent’s cryptographic authorization does not ride the transport layer at all. It rides in the body, as two detached Ed25519 signatures that stay valid however the request travels:
agent_acceptanceon each item — a detached Ed25519 signature over{offer_sig, requester_id, requester_domain, idempotency_key}, binding that offer to the requester and the idempotency key. The Exchange verifies it against the agent’s registered key, resolved fromRequester.domain, and binds the delivery URL to that key’s thumbprint.agent_request_acceptanceon the request — binds the complete ordered set of(offer signature, exchange)references, so each Exchange can require its subrequest to be the exact in-order projection of the set the agent signed. A subrequest that is not that projection (an item dropped, added or reordered) is refused per item, in the body: every item is denied withDENIAL_REASON_SIGNATURE_INVALID, because the agent’s signature does not cover what arrived. It is not aninvalid_argumenterror. See Broker fan-out and request projection for the flow.
Both acceptances name the requester: their canonical bytes carry a non-empty requester_id and requester_domain, copied from the request’s Requester. A signer refuses to sign bytes that name an empty requester, and a verifier refuses an acceptance whose canonical bytes name one, even when the signature over those bytes verifies. Such bytes bind the agent’s consent to nobody, and on a relayed purchase the acceptances are the only agent signatures the Exchange sees. The SDKs enforce this in all three languages: their acceptance signers refuse an empty requester locally as a malformed request, and their verifiers refuse such an acceptance.
The Exchange verifies each agent_acceptance against an Ed25519 key currently valid in the WBA directory requester.domain names. It resolves the agent_request_acceptance verification key from the requester the signed payload names: the payload’s requester_domain must equal the request’s requester.domain, and the signature is accepted only if it verifies against an Ed25519 key currently valid in that domain’s WBA directory ({requester_domain}/.well-known/http-message-signatures-directory). The envelope carries no keyid, so the verifier tries the currently-valid Ed25519 keys of that directory — rotation overlap keeps that set small. When the requester itself signed the arriving request, the request-signing key the Exchange already resolved is that key, and no second fetch is needed. A signature that verifies against a key the requester’s domain publishes is what makes the claimed requester identity an authenticated one, the same anchoring every other directory lookup uses.
Delegation stays bound to the agent through projection. On a re-packaged request the delegation’s cnf.jkt is checked against the key that signed the agent’s agent_acceptance, so the agent’s private key is still what the delegation is sealed to, exactly as on a direct call (see Which signature binds in a brokered request). The Broker authenticates as itself, while the body’s detached signatures carry the agent’s identity and authorization.
Before re-packaging, the Broker checks that requester.domain equals the agent’s verified signing directory, the origin the agent’s covered Signature-Agent member names. Every Exchange resolves the acceptance keys from requester.domain, so a mismatch could only be refused downstream. The Broker refuses it first, as unauthenticated with request_auth_failure SIGNATURE_INVALID: the signature verifies, but not for the requester the body claims.
Request Signatures (RFC 9421)
Section titled “Request Signatures (RFC 9421)”Why headers, not message fields
Section titled “Why headers, not message fields”Request signatures are RFC 9421 HTTP Message Signatures, carried in HTTP headers — not fields on any protobuf message. This separates identity semantics (the Requester message: who is making the request, scopes, delegation) from cryptographic proof (the HTTP signature attesting the request is authentic). No FORA message has ever carried a request-signature field; the protobuf body carries identity only, and forwarding is likewise carried in labeled RFC 9421 signatures rather than an in-message hop list (see Multi-hop forwarding).
How RFC 9421 Signatures Work
Section titled “How RFC 9421 Signatures Work”RFC 9421 defines a standard way to sign HTTP messages. WG-00 profiles it for automated clients. Four headers work together:
| Header | Purpose |
|---|---|
Content-Digest | Hash of the request body (binds the signature to the payload) |
Signature-Agent | Where each signer’s key directory is, one dictionary member per signature label |
Signature-Input | Declares which components are signed, the key ID, the algorithm, the validity window and the tag |
Signature | The actual cryptographic signature |
The Exchange verifies the signature by reconstructing the signed content from the declared components, fetching the signer’s public key from the key directory the covered Signature-Agent member names (selected by the keyid thumbprint), and verifying the Ed25519 signature.
The Web Bot Auth Profile
Section titled “The Web Bot Auth Profile”This section is the normative statement of how FORA request signatures are made and checked. It applies to every FORA request signature: FORA RPCs, forwarded requests, agent requests at the publisher edge, and the retrieval proof of possession a fetcher presents with a signed delivery URL (Retrieval proof of possession).
Every signature: the WG-00 base
Section titled “Every signature: the WG-00 base”Every FORA signature is a conformant WG-00 signature:
| Element | Requirement |
|---|---|
Signature-Agent header | A structured-field Dictionary (RFC 9651 §3.2). The member for this signature is <label>="https://<origin>": its key is the signature’s label, and its value is the ASCII serialization of an https origin, with no path, query or fragment. Signers use a member key equal to the signature label, and send no type parameter, because the default type, directory, is the one FORA uses |
"signature-agent";key="<label>" | Covered. This binds the signature to the directory it names (WG-00 §5.2.1) |
@target-uri or @authority | At least one covered (WG-00 §5.2). FORA RPCs always cover @target-uri |
created, expires | Both present, as integer seconds. The lifetime is a few minutes and always well under the 24 hours WG-00 recommends as the maximum. A signature is never reused as a long-lived credential (WG-00 §6.9) |
keyid | The key’s RFC 7638 JWK thumbprint (RFC 8037 Appendix A.3 for Ed25519), base64url without padding |
alg | "ed25519". WG-00 does not require alg, but widely deployed WBA verifiers do, so FORA always sends it. A FORA verifier also accepts a signature without one, because the key decides the algorithm; see What a FORA verifier accepts |
tag | "web-bot-auth" |
nonce | A fresh 64-byte random value for every signature, base64url without padding. See Replay protection |
No other signature parameters are sent.
FORA RPCs: the additional components
Section titled “FORA RPCs: the additional components”A FORA RPC carries a body and can move money, so FORA requires more than the WBA base. A signature on a FORA RPC MUST also cover:
| Component | Why |
|---|---|
@method | Narrows the signature to one method |
@target-uri | Narrows the signature to one RPC on one host |
content-digest | WBA covers no body by default (WG-00 §5.2). The RFC 9530 Content-Digest header binds the signature to the request body |
authorization | Bound even when empty, so a bearer token cannot be slipped under an existing signature |
This is FORA policy layered on WBA, which WG-00 §5.2.4 permits. It is not a separate signature format. A FORA verifier that refuses a signature for a missing component names the component in the refusal, and answers with an Accept-Signature field listing the components it requires (RFC 9421 §5.1, WG-00 §5.3). The covered "signature-agent";key="sig1" in it also asks for the dictionary form of Signature-Agent:
Accept-Signature: sig1=("@method" "@target-uri" "content-digest" "authorization" "signature-agent";key="sig1");created;expires;tag="web-bot-auth"Any WBA library that can add covered components can therefore call FORA. When the request carries an X-Entitlement-Token header, the signature MUST cover that header too.
Bodiless requests at the publisher edge
Section titled “Bodiless requests at the publisher edge”A request at the publisher edge that has no body only identifies an agent and moves no money. The edge accepts the bare WBA base for it: @authority or @target-uri, the covered Signature-Agent member, and the base parameters. It does not require the four FORA components. A request whose signature is missing or does not verify is treated as an unsigned request, not as an identified agent.
Retrieval proof of possession
Section titled “Retrieval proof of possession”When a delivery URL is bound to the purchasing agent (Retrieval-URL Identity Binding), the fetch carries a full WBA signature: the WG-00 base above (tag, dictionary Signature-Agent, the covered "signature-agent";key="<label>" member, a 64-byte nonce), plus @method and @target-uri. The request has no body, so it carries no content-digest.
That covered set is a minimum. An edge accepts a proof whose signature covers at least the WG-00 base, @method and @target-uri, and any further component a WBA library adds, @authority or a header for example, enters the signature base like the rest. A proof that omits one of the required components, or whose tag or Signature-Agent the profile refuses, is answered with Accept-Signature naming the minimum:
Accept-Signature: sig1=("@method" "@target-uri" "signature-agent";key="sig1");created;expires;tag="web-bot-auth"The edge still verifies offline. The fetcher also presents its raw public key in the X-FORA-Agent-Key header, and the edge uses that key only when its RFC 7638 thumbprint equals both the signature’s keyid and the delivery URL’s agent_id. The binding stays double: the Exchange signed the URL with the thumbprint inside it, and only the holder of the matching private key can produce the request signature. A generic WBA verifier ignores X-FORA-Agent-Key, resolves the same key from the agent’s directory named in Signature-Agent, and accepts the same signature. The edge’s full check is on Signed URL Verification.
What a FORA verifier accepts
Section titled “What a FORA verifier accepts”Agents in the field run many WBA libraries and some send earlier forms of the protocol. A FORA verifier accepts the following, which WG-00 permits:
- The legacy
Signature-Agentform, on a direct single-hop call only. A bare structured-field String (Signature-Agent: "https://research.acme.com"), covered as plain"signature-agent". The verifier treats it as a dictionary with one member whose key is the label of the signature covering it. A party that forwards a request unchanged refuses this form, because it has to append its own member to the agent’sSignature-Agentheader and a String cannot take a second member; it answers withAccept-Signatureasking for the dictionary form, as in theAccept-Signatureexample above. FORA signers never send this form. - A member key different from the signature label. Signers use a member key equal to the label, but when a signature covers one
Signature-Agentmember the verifier follows that member,"signature-agent";key="<member>", whatever the label is called. - No
nonce. WG-00 sets no nonce requirement. - No
alg. WG-00 does not require it. The key decides the algorithm (RFC 9421 §3.2, step 6.3): every key a FORA verifier resolves is Ed25519, and nothing a signer sends at run time selects the algorithm (RFC 9421 §7.3.6). Analgthat is present must nameed25519, in any letter case. - A
typeparameter equal todirectoryon the member. A member with any othertypevalue is ignored, as WG-00 §5.2.1 requires.
A FORA verifier refuses a signature whose alg is present but is not ed25519, an empty alg included, a signature with no tag or a tag other than web-bot-auth, a signature whose covered Signature-Agent member is not an https origin, and a bare unquoted Signature-Agent value such as https://research.acme.com, which is neither a dictionary nor a String. There is no transition window for that last form. A verifier that refuses a signature for a missing component, or for a form it does not accept, answers with Accept-Signature naming what it requires (WG-00 §5.3); a refusal for alg carries none, because no covered component would fix it.
Single-Hop Example
Section titled “Single-Hop Example”A complete WG-00-conformant FORA request. An agent whose directory is https://research.acme.com calls DiscoverResources on an Exchange directly. Every value is real: the request is signed with the Ed25519 key from RFC 8037 Appendix A, so a reader can recompute the digest and verify the signature. Production verifiers refuse published test keys (WG-00 §6.8).
POST /fora.v1.ExchangeService/DiscoverResources HTTP/1.1Host: exchange.exampleContent-Type: application/jsonContent-Length: 203Content-Digest: sha-256=:9D9PY/hvL0Rukm8JVOveoHYVBWp5iAhJcGnCSuyJlPo=:Authorization:Signature-Agent: sig1="https://research.acme.com"Signature-Input: sig1=("@method" "@target-uri" "content-digest" "authorization" "signature-agent";key="sig1");created=1790812800;expires=1790813100;keyid="kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k";alg="ed25519";nonce="TsrHAzO3DzKC9fj7sOvG6npFB86tAdaQ1XH4pBDIAPmZ93MbjGmCgXXhEyx-lC4CG6yY3ZwTqAVPC6LEeNMXIg";tag="web-bot-auth"Signature: sig1=:pDSMjqSLlVFpCP9YsWvGE0AcqsU1JEWhq1fRryL9tC/SF42A4KIxItxZG5evIcQjraFVROW/zOdW2UhSXdjFBQ==:The body is these 203 bytes, with no trailing newline:
{"ver":"1.0","exchange":"exchange.example","requester":{"id":"research-bot-42","domain":"research.acme.com","type":"REQUESTER_TYPE_AGENT"},"uris":["https://publisher.example/premium/ai-funding-roundup"]}The Signature-Input header declares:
- Label:
sig1. It identifies this signature among several, and it is also the key of the agent’sSignature-Agentmember - Covered components:
@method(POST),@target-uri(the RPC URL),content-digest(body hash),authorization(bound even when empty, as above), and thesignature-agentmember keyedsig1(the key directory) - created / expires: a 300-second validity window, 2026-10-01T00:00:00Z to 00:05:00Z. Both are required, and a signature outside its window is refused
- keyid: the RFC 7638 thumbprint of the agent’s key, resolved in the directory at
https://research.acme.com/.well-known/http-message-signatures-directory - alg:
ed25519 - nonce: 64 random bytes, base64url without padding
- tag:
web-bot-auth
The signature base the agent signed, and the verifier rebuilds, is:
"@method": POST"@target-uri": https://exchange.example/fora.v1.ExchangeService/DiscoverResources"content-digest": sha-256=:9D9PY/hvL0Rukm8JVOveoHYVBWp5iAhJcGnCSuyJlPo=:"authorization":"signature-agent";key="sig1": "https://research.acme.com""@signature-params": ("@method" "@target-uri" "content-digest" "authorization" "signature-agent";key="sig1");created=1790812800;expires=1790813100;keyid="kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k";alg="ed25519";nonce="TsrHAzO3DzKC9fj7sOvG6npFB86tAdaQ1XH4pBDIAPmZ93MbjGmCgXXhEyx-lC4CG6yY3ZwTqAVPC6LEeNMXIg";tag="web-bot-auth"The authorization line ends with a space and no value: the header is sent empty, and its empty value is what the signature binds. The signature-agent line carries the member’s value as a quoted string, because RFC 9421 §2.1.2 serializes a selected dictionary member as its structured-field value.
The bytes are signed exactly as sent, so a relay forwarding the request must not rewrite any covered header.
Multi-Hop Example (Broker)
Section titled “Multi-Hop Example (Broker)”A request a Broker sent to an Exchange: here a Broker-led DiscoverResources query that the Broker originated. The Broker’s directory is https://broker.example, and its signature is made with the RFC 9421 Appendix B.1.4 test key. The Broker signs alone, so the request carries one signature and one Signature-Agent member:
POST /fora.v1.ExchangeService/DiscoverResources HTTP/1.1Host: exchange.exampleContent-Type: application/jsonContent-Length: 221Content-Digest: sha-256=:FKKQPDds8rayE2fEUjhwSCgsyuVv4pmrHLM6S0MiZQM=:Authorization:Signature-Agent: sig1="https://broker.example"Signature-Input: sig1=("@method" "@target-uri" "content-digest" "authorization" "signature-agent";key="sig1");created=1790812801;expires=1790813101;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";nonce="7jwqQ5Qo8ytp-rXrRXvOS6tktBBVNJ1Rds5Ypdvh2L3QwG_335-eo89DX7NoxoLuwpT-4uz2aEaIZEzqlKbNIg";tag="web-bot-auth"Signature: sig1=:TVAmOQwm7x09EoMAUGPifHsGEGOxV7hqN9CF12Wb7SsWp2Kx2BxAKu8N+8fjprpL3ipAxkGk2rhonqMxW8XIBg==:The body is these 221 bytes, with no trailing newline:
{"ver":"1.0","exchange":"exchange.example","requester":{"id":"research-bot-42","domain":"research.acme.com","type":"REQUESTER_TYPE_AGENT"},"uris":["https://publisher.example/premium/ai-funding-roundup"],"deadline":"0.4s"}The Exchange verifies sig1 against the key its keyid selects in the directory the sig1 member names, and authenticates the caller as the Broker. A re-packaged execute request looks the same in its headers; the agent’s identity and consent travel in each item’s agent_acceptance. The signature base the Broker signed is:
"@method": POST"@target-uri": https://exchange.example/fora.v1.ExchangeService/DiscoverResources"content-digest": sha-256=:FKKQPDds8rayE2fEUjhwSCgsyuVv4pmrHLM6S0MiZQM=:"authorization":"signature-agent";key="sig1": "https://broker.example""@signature-params": ("@method" "@target-uri" "content-digest" "authorization" "signature-agent";key="sig1");created=1790812801;expires=1790813101;keyid="poqkLGiymh_W0uP6PZFw-dvez3QJT5SolqXBCW38r0U";alg="ed25519";nonce="7jwqQ5Qo8ytp-rXrRXvOS6tktBBVNJ1Rds5Ypdvh2L3QwG_335-eo89DX7NoxoLuwpT-4uz2aEaIZEzqlKbNIg";tag="web-bot-auth"Replay protection
Section titled “Replay protection”A verifier that keeps a replay store refuses a signature it has already accepted. The store key is the key ID plus the signature bytes.
Ed25519 signatures are deterministic, and created and expires are whole seconds. So two identical requests signed in the same second, with no nonce, get the same signature, and the verifier refuses the second one as a replay, although the client sent it on purpose.
To prevent this, every FORA signer adds an RFC 9421 nonce parameter to every signature:
- The nonce is 64 random bytes from the platform’s cryptographic RNG, encoded as base64url without padding (86 characters). 64 bytes is the length the WBA test vectors use, and widely deployed WBA verifiers refuse a nonce of any other length.
- Each new signing attempt gets a fresh nonce, so a client retry that signs the request again is not refused as a replay. Idempotency keys protect the business operation from running twice.
- Sending the same signed bytes again is still a replay, and the verifier refuses it.
- Verifiers do not require a nonce. A signature without one is still accepted, because WG-00 sets no nonce requirement and other WBA signers omit it.
- The low-level signing helpers (
SignRequest/AppendSignatureand their Python and TypeScript equivalents) do not generate a nonce. A caller that uses them directly and needs unique signatures must pass its own fresh nonce.
No fallback
Section titled “No fallback”On FORA RPCs, an RFC 9421 signature that conforms to this profile is the only request-authentication mechanism. A request without one (Signature, Signature-Input and Signature-Agent, with a Content-Digest over the body) is rejected — there is no proto-field fallback and no degraded mode. There were never any in-message signature fields to fall back to. The publisher edge is different only for bodiless requests, as Bodiless requests at the publisher edge describes: there a missing or invalid signature means the request is unsigned, and it is never treated as an identified agent.
Retrieval-URL Identity Binding
Section titled “Retrieval-URL Identity Binding”A signed retrieval_endpoint returned by a transaction is, by default, a bearer token: anyone holding it before expires_at can fetch. FORA optionally binds the URL to the purchasing agent, DPoP-style (RFC 9449):
- The Exchange embeds
agent_identity_hash— the RFC 7638 JWK Thumbprint (SHA-256) of the agent’s Ed25519 key, the key that signed the request on a direct call or the agent’sagent_acceptanceon a purchase a Broker relayed — as the URL’sagent_idquery parameter, which the URL signature covers, and echoes it on the execute-path response (TransactionResponse.agent_identity_hash, produced directly by the Exchange — the Broker is not in the execute response path for this field). - A capable delivery endpoint (edge function) verifies the binding without contacting the fetcher’s directory: verify the URL’s Ed25519 signature against the Exchange’s published public key (proves the hash is Exchange-issued and untampered), then require the fetcher to present its public key and a WBA signature over the retrieval request (Retrieval proof of possession), and check that
thumbprint(presented key)equals both the signature’skeyidandagent_identity_hash. The fetcher’s key arrives in the request, so no directory fetch is needed for it, while a generic WBA verifier can still resolve the same key from the agent’s directory. The edge does resolvekidto the Exchange’s public key, from a cached directory or from keys pinned in its configuration. - Bound to the request-signing key of whoever fetches, never to the principal/delegation, and exactly one key per transaction. Who that is depends on where the key lives: an agent embedding the SDK holds its own key and fetches for itself, while a custodial agent never fetches at all — its identity service holds the key and fetches on its behalf. Either way the fetcher is the key holder, which is what the edge checks.
- Enforcement is NOT mandatory: a CDN that cannot run code uses its native signed-URL scheme (a CloudFront RSA canned policy, for example) + short TTL + TLS. FORA reference implementations run on edge functions and do enforce it.
See Signed URL Verification for the full edge-function verification flow and the stolen-URL threat analysis.
Examples
Section titled “Examples”Public Agent (No Delegation)
Section titled “Public Agent (No Delegation)”A research bot queries a public Exchange. No delegation token, no special scopes — it gets whatever is publicly available.
{ "ver": "1.0", "exchange": "exchange.example", "requester": { "id": "research-bot-42", "domain": "research.acme.com", "type": "REQUESTER_TYPE_AGENT", "name": "Acme Research Assistant", "scopes": [] }, "uris": [ "https://cdn.fora-protocol.org/premium/ai-funding-roundup" ], "acceptable_restrictions": [ { "axis": "RESTRICTION_KIND_FUNCTION", "values": ["SUMMARIZE"] } ]}The Exchange verifies the signature by fetching the key directory at https://research.acme.com/.well-known/http-message-signatures-directory (the origin in the Signature-Agent member the signature covers) and selecting the key whose RFC 7638 thumbprint matches the keyid, then returns all publicly available offers for the requested URI.
Subscription Agent (MarketData JWT)
Section titled “Subscription Agent (MarketData JWT)”A financial analysis agent carries a MarketData-issued root token that grants access to subscription-tier data. delegation.token is a bytes field, so proto-JSON carries the compact JWS base64-encoded. The token below is a real one from the shared chain cases (docs_subscription_agent): it verifies with MarketData’s configured key, is addressed to exchange.example, and is bound to FinBot’s request key.
{ "ver": "1.0", "exchange": "exchange.example", "requester": { "id": "finbot-alpha", "domain": "fintech.example.com", "type": "REQUESTER_TYPE_AGENT", "name": "FinBot Alpha", "scopes": [ "subscription:marketdata-2026", "quote:*", "earnings:*" ], "delegation": { "principal_domain": "marketdata.example.com", "principal_id": "marketdata.example.com", "scopes": ["subscription:marketdata-2026", "quote:*", "earnings:*"], "expires_at": "2026-06-01T13:00:00Z", "token": "ZXlKaGJHY2lPaUpGWkVSVFFTSXNJblI1Y0NJNkltWnZjbUV0WkdWc1pXZGhkR2x2Yml0cWQzUWlmUS5leUpoZFdRaU9sc2laWGhqYUdGdVoyVXVaWGhoYlhCc1pTSmRMQ0pqYm1ZaU9uc2lhbXQwSWpvaWJUQk1PRGh0Y1V4M01uWnpSbnBNWTBGSFZHbE5kV2xPYWpZM1JqRjBRWGxCYjJSb09XMTNTM3A0T0NKOUxDSmxlSEFpT2pFM09EQXpNVGc0TURBc0ltbHpjeUk2SW0xaGNtdGxkR1JoZEdFdVpYaGhiWEJzWlM1amIyMGlMQ0pxZEdraU9pSnRaQzF6ZFdJdE1qQXlOaTB3TkRneElpd2ljMk52Y0dVaU9pSnpkV0p6WTNKcGNIUnBiMjQ2YldGeWEyVjBaR0YwWVMweU1ESTJJSEYxYjNSbE9pb2daV0Z5Ym1sdVozTTZLaUo5LmQ3UjRnYVhjQ2d5OW5SMzA0bF9LeE85c1FFV3BLRGs4dVY0UEVuSXN6ZS1RbnJfSjBfOV9fbTNnVzhiR0ZlaWJXZXp1VEI4akYyRzdKbGVsaW1vaEFn", "token_format": "jwt" } }, "uris": [ "marketdata://quote/NVDA", "marketdata://earnings/NVDA/2025-Q4" ], "acceptable_restrictions": [ { "axis": "RESTRICTION_KIND_FUNCTION", "values": ["RETRIEVE"] } ]}The Exchange:
- Verifies the request’s RFC 9421 HTTP Message Signature against
fintech.example.com’s published key - Verifies the delegation chain as Verification describes: the root verifies with the key configured for the issuer
marketdata.example.comon the subscription record,audnames this Exchange, andcnf.jktequals the thumbprint of the request signer (holder binding) - Reads the effective scopes the verifier returns (
subscription:marketdata-2026,quote:*,earnings:*) and the effective expiry (2026-06-01T13:00:00Z) - Presents the subscription-tier terms (zero marginal cost) whose scopes, such as
subscription:marketdata-2026, the verified delegation covers. For such a gated term the Exchange reads scopes from the verified delegation only and ignoresrequester.scopes; the non-gated terms are still filtered by the stated scopes (Scope-Based Access). A gated offer expires no later than2026-06-01T13:00:00Z
Enterprise Agent (IAM-Issued JWT)
Section titled “Enterprise Agent (IAM-Issued JWT)”An internal agent at a law firm carries a root token issued by the firm’s IAM system, granting access to legal and credit resources. The token is the shared chain case docs_enterprise_agent.
{ "ver": "1.0", "exchange": "exchange.example", "requester": { "id": "legal-research-agent", "domain": "agents.kirkland.com", "type": "REQUESTER_TYPE_SERVICE", "name": "Kirkland Legal Research", "scopes": [ "legal:case-law", "credit:read" ], "delegation": { "principal_domain": "iam.kirkland.com", "principal_id": "user@kirkland.com", "scopes": ["legal:case-law", "legal:statutes", "credit:read"], "expires_at": "2026-06-01T13:00:00Z", "token": "ZXlKaGJHY2lPaUpGWkVSVFFTSXNJblI1Y0NJNkltWnZjbUV0WkdWc1pXZGhkR2x2Yml0cWQzUWlmUS5leUpoZFdRaU9sc2laWGhqYUdGdVoyVXVaWGhoYlhCc1pTSmRMQ0pqYm1ZaU9uc2lhbXQwSWpvaVJtVm1Ta3N5VDB0YVNtSkNVbGxqZW1kTU9IbFphelJyVXpWVVNGOVVVbW94ZDNJM1IxSnlhVE15TkNKOUxDSmxlSEFpT2pFM09EQXpNVGc0TURBc0ltbHpjeUk2SW1saGJTNXJhWEpyYkdGdVpDNWpiMjBpTENKcWRHa2lPaUpwWVcwdE4yTXhaU0lzSW5OamIzQmxJam9pYkdWbllXdzZZMkZ6WlMxc1lYY2diR1ZuWVd3NmMzUmhkSFYwWlhNZ1kzSmxaR2wwT25KbFlXUWlMQ0p6ZFdJaU9pSjFjMlZ5UUd0cGNtdHNZVzVrTG1OdmJTSjkucjZuYXhKa08tT2FDU0czbENnYXNGRXVFUDktbHg3UFNDVU1ydG1pa1A5YS1rbkVndTlyR1FwYk9kVTdPbWlpc2Jkb1RYT1g3SEh4dmxsN1pzdFRNQ1E=", "token_format": "jwt" } }, "uris": [ "westlaw://case/2025-us-456", "duns://credit/123456789" ], "acceptable_restrictions": [ { "axis": "RESTRICTION_KIND_FUNCTION", "values": ["RETRIEVE", "SUMMARIZE"] } ]}The internal Exchange verifies the chain with the key it has configured for the issuer iam.kirkland.com; nothing is fetched from IAM. If the agent requests a resource outside its scopes, that URI’s group comes back with no offers and no absence reason, the same answer as for a resource the Exchange has nothing for (see Existence hiding).
Delegated Sub-Agent (cnf-chained delegation)
Section titled “Delegated Sub-Agent (cnf-chained delegation)”The Kirkland legal agent from the previous example delegates to a sub-agent that only needs credit data. It issues a child token, bound to the sub-agent’s key, that removes legal access and expires 30 minutes earlier. The token is the shared chain case docs_delegated_sub_agent: the child, with the complete IAM root token inside its fora_parent claim.
{ "ver": "1.0", "exchange": "exchange.example", "requester": { "id": "credit-checker-sub", "domain": "agents.kirkland.com", "type": "REQUESTER_TYPE_DELEGATED", "name": "Kirkland Credit Sub-Agent", "scopes": [ "credit:read" ], "delegation": { "principal_domain": "iam.kirkland.com", "principal_id": "user@kirkland.com", "scopes": ["credit:read"], "expires_at": "2026-06-01T12:30:00Z", "token": "ZXlKaGJHY2lPaUpGWkRJMU5URTVJaXdpYW5kcklqcDdJbU55ZGlJNklrVmtNalUxTVRraUxDSnJkSGtpT2lKUFMxQWlMQ0o0SWpvaVpHdFZlVkpXTm10dE4xcERWa3BaV1dac1VUTXdNWEZaY21KNk5ESnZaR2h5WWxWU1dIRmtiVlJ6T0NKOUxDSjBlWEFpT2lKbWIzSmhMV1JsYkdWbllYUnBiMjRyYW5kMEluMC5leUpoZFdRaU9sc2laWGhqYUdGdVoyVXVaWGhoYlhCc1pTSmRMQ0pqYm1ZaU9uc2lhbXQwSWpvaVZtbGlSak15UWtNMVQyVnlSSFl0U2pOdGNERllRM1YzYnprM1dGSkRTaTFGZFZOS2JHeEtTMEp4VlNKOUxDSmxlSEFpT2pFM09EQXpNVGN3TURBc0ltWnZjbUZmY0dGeVpXNTBJam9pWlhsS2FHSkhZMmxQYVVwR1drVlNWRkZUU1hOSmJsSTFZME5KTmtsdFduWmpiVVYwV2tkV2MxcFhaR2hrUjJ4MlltbDBjV1F6VVdsbVVTNWxlVXBvWkZkUmFVOXNjMmxhV0docVlVZEdkVm95VlhWYVdHaG9ZbGhDYzFwVFNtUk1RMHBxWW0xWmFVOXVjMmxoYlhRd1NXcHZhVkp0Vm0xVGEzTjVWREIwWVZOdFNrTlZiR3hxWlcxa1RVOUliRnBoZWxKeVZYcFdWVk5HT1ZWVmJXOTRaRE5KTTFJeFNubGhWRTE1VGtOS09VeERTbXhsU0VGcFQycEZNMDlFUVhwTlZHYzBUVVJCYzBsdGJIcGplVWsyU1cxc2FHSlROWEpoV0VweVlrZEdkVnBETldwaU1qQnBURU5LY1dSSGEybFBhVXB3V1Zjd2RFNHlUWGhhVTBselNXNU9hbUl6UW14SmFtOXBZa2RXYmxsWGR6WlpNa1o2V2xNeGMxbFlZMmRpUjFadVdWZDNObU16VW1oa1NGWXdXbGhOWjFrelNteGFSMnd3VDI1S2JGbFhVV2xNUTBwNlpGZEphVTlwU2pGak1sWjVVVWQwY0dOdGRITlpWelZyVEcxT2RtSlRTamt1Y2padVlYaEthMDh0VDJGRFUwY3piRU5uWVhOR1JYVkZVRGt0YkhnM1VGTkRWVTF5ZEcxcGExQTVZUzFyYmtWbmRUbHlSMUZ3WWs5a1ZUZFBiV2xwYzJKa2IxUllUMWczU0VoNGRteHNOMXB6ZEZSTlExRWlMQ0pwYzNNaU9pSkdaV1pLU3pKUFMxcEtZa0pTV1dONlowdzRlVmxyTkd0VE5WUklYMVJTYWpGM2NqZEhVbkpwTXpJMElpd2lhblJwSWpvaWJHVm5ZV3d0YzNWaUxUQXdNRGNpTENKelkyOXdaU0k2SW1OeVpXUnBkRHB5WldGa0luMC5jYkFYNlo2bi03aVVHNEFhQTQ5a1NRVzR4bi1MaWdVUWN3R3RRaVRFT2VDQjBhRlVsUHZOdmloUDZYTnBwVVo4OThuMXNOMmhJQ29mY3NtMkJ3dFhCUQ==", "token_format": "jwt" } }, "uris": [ "duns://credit/123456789" ], "acceptable_restrictions": [ { "axis": "RESTRICTION_KIND_FUNCTION", "values": ["RETRIEVE"] } ]}Key differences from the parent agent’s request:
typeisREQUESTER_TYPE_DELEGATED— signals this agent acts on behalf of anotherdelegation.scopesis narrowed to["credit:read"](legal scopes removed); the mirrorsprincipal_domainandprincipal_idstill describe the root, because they mirror the root token’sissdelegation.expires_atis shortened (30 minutes earlier), the child’s ownexp- The sub-agent sends and signs the request alone, with its own labeled RFC 9421 signature and its own
Signature-Agentmember (see Multi-hop forwarding). The parent’s authority does not travel as a request signature: it travels inside the delegation token - The delegation is a nested chain: the child token, signed by the parent agent with
alg: Ed25519, carries the complete IAM root token infora_parent, the parent agent’s public key in its protectedjwkheader, that key’s thumbprint as itsiss, and the sub-agent’s key thumbprint as itscnf.jkt
The Exchange verifies the full chain: IAM signed the root token, checked with the configured key; the parent agent signed the child (its jwk hashes to the root’s cnf.jkt — chain linkage); the sub-agent’s request key matches the child’s cnf.jkt. No call to IAM was required for the delegation.
Key Management
Section titled “Key Management”Where Keys Are Published
Section titled “Where Keys Are Published”Every party publishes its keys in one place — its WBA directory at {domain}/.well-known/http-message-signatures-directory, a pure JWK Set. The role manifest at {domain}/.well-known/fora.json (role-tagged) announces which party the {domain} is; the WBA directory holds its keys. There are no per-role key files; keys are inline JWKs identified by their RFC 7638 thumbprint.
| Party | Key Endpoint (WBA directory) | Role (in fora.json) |
|---|---|---|
| Agent / Requester | {domain}/.well-known/http-message-signatures-directory | ROLE_AGENT |
| Broker | {domain}/.well-known/http-message-signatures-directory | ROLE_BROKER (describes the document; an Exchange admits a Broker by its own policy, never by this role) |
| Exchange | {domain}/.well-known/http-message-signatures-directory | ROLE_EXCHANGE |
| Delegation Issuer (principal / resource owner) | {domain}/.well-known/http-message-signatures-directory | ROLE_AGENT (key signing delegation JWTs) |
Key Directory
Section titled “Key Directory”Every participant serves its keys as a WG-00 key directory (WG-00 §5.5 and §5.5.1):
| Property | Requirement |
|---|---|
| Location | https://<origin>/.well-known/http-message-signatures-directory, where <origin> is exactly the value of the participant’s Signature-Agent member |
| Transport | https only |
| Status | 200, served directly. A verifier does not follow a redirect and treats any other status as a discovery failure |
| Media type | Content-Type: application/http-message-signatures-directory+json |
| Body | A JWK Set (RFC 7517 §5): a keys array of Ed25519 keys (kty OKP, crv Ed25519, alg EdDSA), plus FORA’s optional revocation_url. Keys carry no kid; a verifier computes each key’s RFC 7638 thumbprint and matches it against the signature’s keyid |
| Signed response | Signed once per listed key, as WG-00 Appendix B.1 describes: the signature covers "@authority";req and content-digest, carries created, expires, keyid (the thumbprint) and alg, and has tag="http-message-signatures-directory". The response carries the matching Content-Digest header |
| Access | Reachable without a signature and without bot challenges, and cacheable with standard HTTP caching |
WG-00 makes the signed response RECOMMENDED. FORA requires it, because a directory that is not signed cannot be registered with some widely deployed WBA verifiers. A verifier checks the response signature with the key the directory lists, checks the Content-Digest against the body, and refuses a response signature whose created is in the future. It uses only the listed keys whose response signature verifies, and a response signature counts only when it covers exactly "@authority";req and content-digest. It accepts a response signature that carries no alg, as WG-00’s own Appendix E.2.3 example does; one that carries alg must name ed25519.
FORA adds two extension members to the JWK Set: not_before and not_after on each key (see Key Validity Windows), and revocation_url on the set (see Emergency Revocation). A generic WBA verifier ignores members it does not know, as RFC 7517 allows. A FORA verifier does not ignore the bounds: a key without both is never valid, so a directory written to WG-00 alone must add them before a FORA verifier uses its keys.
A complete directory response for the agent in the Single-Hop Example, signed with the same RFC 8037 test key:
GET /.well-known/http-message-signatures-directory HTTP/1.1Host: research.acme.comAccept: application/http-message-signatures-directory+jsonHTTP/1.1 200 OKContent-Type: application/http-message-signatures-directory+jsonCache-Control: max-age=3600Content-Digest: sha-256=:pwF1biF3JgznD+stgQ9rVYn9igU31Egz7PG2yK0+g1E=:Signature-Input: sig1=("@authority";req "content-digest");created=1790812200;expires=1790815800;keyid="kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k";alg="ed25519";tag="http-message-signatures-directory"Signature: sig1=:5UyPR4rbxeGrlooo3uc0y43hNHzRX9O0EsegyHPf8XYyqy6q7/6Kkub2rFN2Jzsv+4yTdGygDdRE0iaRucg6DQ==:
{ "keys": [ { "kty": "OKP", "crv": "Ed25519", "use": "sig", "alg": "EdDSA", "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo", "not_before": "2026-07-01T00:00:00Z", "not_after": "2027-01-01T00:00:00Z" } ], "revocation_url": "https://research.acme.com/.well-known/fora-invalidations.json"}The response signature base is:
"@authority";req: research.acme.com"content-digest": sha-256=:pwF1biF3JgznD+stgQ9rVYn9igU31Egz7PG2yK0+g1E=:"@signature-params": ("@authority";req "content-digest");created=1790812200;expires=1790815800;keyid="kPrK_qmxVWaYVA9wwBF6Iuo3vVzz7TxHCTwXBygrS4k";alg="ed25519";tag="http-message-signatures-directory""@authority";req takes its value from the request that fetched the directory, so the signature cannot be served again under a different host. The Content-Digest is computed over the body exactly as shown, with two-space indentation and no trailing newline.
Key Rotation
Section titled “Key Rotation”Key rotation follows a standard overlap pattern:
- Publish the new key alongside the old key (both valid)
- Start signing new requests with the new key
- After a grace period (Exchange-defined, typically 24-48 hours), remove the old key
- Exchanges SHOULD cache keys with a TTL and re-fetch periodically
For delegation tokens, an issuer’s keys are configured on the subscription record, not published in a directory, so rotation is an edit to that list: the new key is added, and the old key stays listed until every token it signed has expired, then it is removed. A root token verifies with any listed key whose identity equals its iss, so rotation does not invalidate existing tokens, and removing a key is the explicit withdrawal of trust from everything it signed.
Key Validity Windows
Section titled “Key Validity Windows”Each JsonWebKey carries RFC3339 not_before / not_after bounds. The window is half-open [not_before, not_after) — a key is valid at not_before and invalid at and after not_after. At least one key in the WBA directory’s keys MUST be valid at serve time, and verifiers MUST reject signatures whose key falls outside its window. A key that lacks either bound, or whose bound is not an RFC 3339 timestamp, is never valid. WG-00 defines no validity members, so this is one of the places FORA departs from it (Where FORA departs from WG-00).
Caching Contract
Section titled “Caching Contract”The WBA directory (/.well-known/http-message-signatures-directory) and /.well-known/fora.json MAY be cached by consumers and CDNs for routine periods (minutes to hours) — keys rotate slowly and carry explicit bounds. The revocation_url body, by contrast, SHOULD be served with a short or zero freshness lifetime (e.g. Cache-Control: max-age=60, or no-store) so the 300s revocation poll is not defeated by an intermediary cache.
Enforcement is operational, not protocol-guaranteed: a consumer that grants access against a stale revocation set bears that as its own business risk — it is not a protocol deficiency.
Emergency Revocation
Section titled “Emergency Revocation”When a WBA directory sets revocation_url, consumers poll it on a 300s cadence (±10% jitter) and replace their local revoked set with the response — a KeyRevocationList (thumbprint-only snapshot: as_of + revoked[]). A revoked thumbprint stays revoked; reject any signature whose key thumbprint is in the set. When revocation_url is unset, consumers rely on routine rotation and not_after expiry.
Whose list revokes whose key
Section titled “Whose list revokes whose key”Revocation is scoped to the signer. Each signature is verified against its own signer’s key directory and the revocation list that directory names, and against no other party’s:
- A Broker’s revocation list covers only the Broker’s keys, and an agent’s list covers only that agent’s keys. No party’s list can revoke another party’s key, even if it names that key’s thumbprint.
- A request that carries several request signatures, such as a request a party forwarded unchanged and covered under WG-00 §5.2.2, is refused if any one of them fails its own signer’s check.
- A request signed only by the agent is verified against the agent’s directory and list alone. It never depends on the Broker’s directory being reachable.
Fetching directories, revocation lists and manifests
Section titled “Fetching directories, revocation lists and manifests”A key directory is fetched with no redirect. It answers 200 at /.well-known/http-message-signatures-directory itself, and a verifier treats a redirect, like any other status, as a discovery failure (Key Directory). The directory’s address is the origin the signer committed to in its covered Signature-Agent member, so a redirect would let the server hand key lookup to another origin.
A revocation list and the role manifest at /.well-known/fora.json carry nothing to leak, so a party fetching one MAY follow up to five redirects, re-pinning the address and re-vetting the scheme at each hop. The cap is five hops; a longer chain fails the fetch.
Domainless Agents
Section titled “Domainless Agents”An individual agent with no public domain of its own is accommodated without a protocol change: a registry (operated by an Exchange/Broker or a third party) hosts the agent’s WellKnownManifest, and the agent sets Requester.domain to that registry host (e.g. agent-7.registry.example). The agent generates its own keypair; the registry publishes the public half in its WBA directory at {registry}/.well-known/http-message-signatures-directory. The domain + well-known paradigm is preserved end to end. Relaxing the discovery anchor (an explicit per-requester keys URL) is a deliberate deferral.
Next Steps
Section titled “Next Steps”- Transaction Flow — the RPCs that carry these signatures
- Scenario Walkthrough — see the full identity model in action
- Standards Layering — how authentication fits within the broader protocol stack
- Exchange Manifest — machine-readable Exchange self-description