Agent identity and delegated access
Status: draft 0.1 (agent-grant/0.1). Covers friction #3 (agents borrow the user's full session or password), #8 (sites can't tell which agent acts for whom), #9 (bot detection blocks legitimate agents) and #16 (OAuth scopes are coarse and long-lived). Reference implementation: agent_index/identity.py.
The problem
Today an agent either logs in as the person, with every permission the person has and no record that an agent did it, or it looks like an anonymous scraper and gets blocked. Sites can't tell a person's own agent from abuse, so they block both or trust both.
The model
Three keys, two signatures:
- The person has a key, registered with the site once (the way a passkey is).
- The agent has its own key. Its id is the public key, so anyone can verify it without a lookup.
- The person signs a grant: this agent, this site, these scopes, these limits, this purpose, until this time.
- The agent signs every request with its own key and attaches the grant.
The site checks both signatures and learns who is acting (the agent), for whom (the person), with what authority (scopes and limits), and why (purpose). It never sees the person's password or session.
Grant
A compact token: base64url JSON payload, a dot, base64url Ed25519 signature by the person's key.
{
"typ": "agent-grant/0.1",
"iss": "ed25519:<person public key>",
"iss_name": "Maxam",
"sub": "ed25519:<agent public key>",
"aud": "shop.example",
"scopes": ["orders:read"],
"constraints": {},
"purpose": "Check order status",
"iat": 1794200000,
"exp": 1794203600,
"jti": "9c1e0f4b2a7d3e11"
}
Rules:
- aud is one site. A grant for one site is useless on another.
- exp defaults to one hour. Task-length grants are the point; long-lived ones defeat it.
- scopes are the site's own names, published in its action manifest.
- constraints carries limits the site enforces, such as spending caps (see payments).
- The person can revoke a grant by jti at any time; sites keep a revocation list until exp.
Signed requests
Requests are signed with HTTP Message Signatures (RFC 9421), Ed25519, in the Web Bot Auth profile (agent_index/httpsig.py):
Agent-Grant: <grant token>
Content-Digest: sha-256=:<base64>:
Signature-Agent: "https://agents.example"
Signature-Input: sig1=("@method" "@authority" "@path" "@query" "content-digest" "agent-grant" "signature-agent");created=1794200123;expires=1794200183;keyid="<JWK thumbprint>";alg="ed25519";nonce="<random>";tag="web-bot-auth"
Signature: sig1=:<base64>:
keyidis the RFC 7638 JWK thumbprint of the agent's key. For a delegated request it must match the grant'ssub.- A delegated request must cover
@method,@authority,@path,agent-grant,@querywhen there is one, andcontent-digestwhen there is a body. Payment retries also coveragent-payment. - Sites reject signatures more than 5 minutes old, expired ones, and any nonce seen before.
- The implementation matches RFC 9421's Ed25519 test vector (appendix B.2.6) byte for byte; the tests check it.
Signed agents without a grant (#9)
An agent that only reads public pages doesn't need a grant, but it shouldn't look like an anonymous scraper either. With AGENT_INDEX_SIGNATURE_AGENT set to the URL where its operator hosts a key directory (agent-index key-directory prints it, to serve at /.well-known/http-message-signatures-directory), every request agent-index sends is signed with Web Bot Auth. Bot-management services that verify Web Bot Auth can then let it through. A site using the middleware passes bot_keys=httpsig.DirectoryResolver(fetch=...): it fetches and caches the directory, verifies the signature, and hands the app scope["signed_agent"]. The site's policy can require it with requires_signature.
Issuing, holding and revoking grants
- The person:
agent-index person-keycreates their key (it belongs on the person's own device),agent-index grant <site> --scope ... --purpose ... --ttl 3600signs a grant for the agent keywhoamishows, and--max-totalmakes it a spending mandate. - The agent keeps grants in its wallet (
wallet_add,agent-index wallet add). For each request it picks the narrowest live grant for that site and scope, and only uses one when the site asks (401withget_grant) or an action needs the scope. Pages fetched under a grant are private: they never enter the shared cache or index. agent-index revoke <site> <grant id>signs a revocation and posts it to the site's/.well-known/agent-revocations. The site accepts it only from the grant's issuer (or another trusted principal) and refuses the grant from then on, across restarts (sitestate.SiteState).
What a site does with it
- Let identified agents through bot defenses instead of serving CAPTCHAs (#9).
- Apply per-agent rate limits and per-person accountability.
- Show the person an activity log: which agent did what, under which grant, for what purpose.
- Fall back to normal login for anything without a grant.
Errors
not_delegated, bad_token, bad_signature, bad_key, unknown_principal, unknown_agent, wrong_audience, grant_expired, grant_revoked, wrong_agent, stale_request, replayed_request, scope_missing. Each is a stable code, never prose (see errors).
Relation to existing work
Signatures are RFC 9421 in the Web Bot Auth profile, which bot-management services already verify. The grant could also be carried as an OAuth token-exchange result, so existing OAuth servers can issue grants; that is the remaining step for sites that already run OAuth.