Agent identity & auth
Engine v0.5.6+.
Login & sessions is about a human with a browser: a password, a
cookie, a 2FA code. This page is about the other subject your server talks to — an
agent. It doesn't have a browser, it shouldn't hold a long-lived secret, and it
often needs to hand a weaker slice of its own authority to a sub-agent it spawned
five seconds ago.
Synsema treats that as a first-class case: the runtime knows who is calling, and
meters spend and rate limits per identity rather than per IP or per process.
-- Doc example: agent identity — capability tokens that can only narrow, signed
-- requests (proof-of-possession) and per-identity metering.
-- (The server side rides on `serve`, which doesn't terminate — the page shows the
-- full flow; this doctest asserts the primitives that flow is built from.)
intent: "doc example: agent identity"
require random
-- Signing a request goes through the same deny-by-default gate as signing a
-- blockchain transaction, scoped to the key's name (here the label given to
-- as_secret) and audited. Without this line http_sign is refused.
require sign("SIG")
let root be "root-key-for-the-doctest"
let full be captoken_mint({"net": "*.example.com", "spend": "ETH"}, root,
{"ttl": 600, "spend": {"ETH": 0.5}, "id": "orchestrator"})
let sub be captoken_attenuate(full, {"net": "api.example.com"},
{"ttl": 60, "spend": {"ETH": 0.01}})
print("delegated token → " + slice(sub, 0, 12) + "…")
test "a capability token verifies and reports what it grants"
let v be captoken_verify(full, root)
assert_ne(v, nothing)
assert_eq(v.id, "orchestrator")
assert_eq(v.depth, 1)
assert(captoken_allows(v, "net", "api.example.com"))
test "attenuating needs no key, and the result is strictly weaker"
let v be captoken_verify(sub, root)
assert_eq(v.depth, 2)
-- the delegated host still works…
assert(captoken_allows(v, "net", "api.example.com"))
-- …but the rest of the parent's glob does not, and `spend` was not delegated
assert_eq(captoken_allows(v, "net", "other.example.com"), false)
assert_eq(captoken_allows(v, "spend", "ETH"), false)
-- the spend caveat came down from 0.5 to 0.01 (any unit: fiat, crypto, commodities)
assert_eq(v.caveats.spend.ETH, "0.01")
test "attenuation can never widen — the error names the problem"
assert_error(() => captoken_attenuate(sub, {"net": "*.example.com"}, nothing))
assert_error(() => captoken_attenuate(sub, {"exec": nothing}, nothing))
assert_error(() => captoken_attenuate(sub, {"net": "api.example.com"}, {"ttl": 99999}))
test "a forged or tampered token is nothing, never a partial success"
assert_eq(captoken_verify(full, "another-root"), nothing)
assert_eq(captoken_verify("not-a-token", root), nothing)
assert_eq(captoken_verify(slice(full, 0, 20), root), nothing)
test "expiry and revocation"
let short be captoken_mint({"net": "x.com"}, root, {"ttl": 60, "id": "temp-1"})
assert_ne(captoken_verify(short, root), nothing)
-- `at` moves the clock forward for a deterministic test
assert_eq(captoken_verify(short, root, {"at": 4102444800}), nothing)
-- revocation is a denylist of ids (typically read from redis)
assert_eq(captoken_verify(short, root, {"revoked": ["temp-1"]}), nothing)
test "contextual caveats are fail-closed"
let scoped be captoken_mint({"net": "x.com"}, root, {"ttl": 60, "aud": "orders-api"})
assert_ne(captoken_verify(scoped, root, {"aud": "orders-api"}), nothing)
assert_eq(captoken_verify(scoped, root, {"aud": "other-api"}), nothing)
-- not supplying the context at all is a rejection: you cannot claim a
-- condition holds if you never checked it
assert_eq(captoken_verify(scoped, root), nothing)
test "signed requests: the signature covers method, URL and body"
let key be as_secret("shared-signing-key", "SIG")
let req be {"method": "POST", "url": "https://api.example.com/orders", "body": "{\"n\":1}"}
let headers be http_sign(req, key, {"alg": "hmac-sha256", "keyid": "agent-7"})
let incoming be {"method": "POST", "url": "https://api.example.com/orders",
"headers": headers, "body": "{\"n\":1}"}
let v be http_signature_verify(incoming, key, {"alg": "hmac-sha256"})
assert_eq(v.keyid, "agent-7")
-- same signature, different body → the content-digest no longer matches
let tampered be {"method": "POST", "url": "https://api.example.com/orders",
"headers": headers, "body": "{\"n\":999}"}
assert_eq(http_signature_verify(tampered, key, {"alg": "hmac-sha256"}), nothing)
test "the verifier pins the algorithm — never reads it from the message"
let key be as_secret("shared-signing-key", "SIG")
let req be {"method": "GET", "url": "https://api.example.com/health"}
let headers be http_sign(req, key, {"alg": "hmac-sha256"})
let incoming be {"method": "GET", "url": "https://api.example.com/health", "headers": headers}
-- a verifier expecting ed25519 rejects an hmac message outright
assert_eq(http_signature_verify(incoming, key, {"alg": "ed25519"}), nothing)
-- and omitting alg is a programmer error, not a permissive default
assert_error(() => http_signature_verify(incoming, key))
test "oidc_verify demands iss and aud"
assert_error(() => oidc_verify("a.b.c", {"aud": "my-client", "jwks": "{}"}))
assert_error(() => oidc_verify("a.b.c", {"iss": "https://idp", "jwks": "{}"}))
-- a bad token with complete options is nothing, not an error
assert_eq(oidc_verify("not-a-jwt", {"iss": "https://idp", "aud": "my-client", "jwks": "{}"}), nothing)
test "spend_total reads per-identity totals"
assert_eq(spend_total("ETH"), 0)
assert_eq(spend_total("ETH", "orchestrator"), 0)
Why not just give the agent an API key
An API key in an agent's context is a bearer credential inside the most leak-prone
place in the system — a prompt. Three properties change that:
- It shouldn't be long-lived. Cloud workloads already have an identity
(oidc_verify below); terminals can use a device flow. Nothing to steal.
- It shouldn't be usable if copied. A signed request (
http_sign) proves
possession of a key that never leaves the sealed secret.
- It shouldn't be delegated whole. When an orchestrator spawns a sub-agent, the
universal practice today is passing along the same key — total impersonation.
Capability tokens let you hand over strictly less.
Capability tokens — delegation that can only narrow
A captoken says what the holder may do, and the holder can weaken it offline,
without the root key:
-- the spend unit is whatever the host works in: fiat, crypto, commodities, credits
let full be captoken_mint({"net": "*.example.com", "db": "postgres://localhost/app", "spend": "ETH"},
secret("ROOT_KEY"),
{"ttl": 600, "spend": {"ETH": 0.5}, "id": "orchestrator"})
-- handing work to a sub-agent: one host, no database, a fiftieth of the budget
let sub be captoken_attenuate(full, {"net": "api.example.com", "spend": "ETH"},
{"ttl": 60, "spend": {"ETH": 0.01}})
captoken_attenuate takes no key — that's the whole point, and why delegation
doesn't need a round trip to whoever minted the token. It works because each block
of the chain is signed with the previous signature as its key (the macaroon
construction): the holder can append a block, never remove or edit one.
The permissions are the same shapes as require — net("api.example.com"),
db("postgres://host/db"), file.read("./data/*") — and narrowing is checked with
the same covers() relation the capability system uses, so a scope in a token means
exactly what it means in a require. Widening is refused twice: when you
attenuate (with an error that says what didn't fit) and again when the token is
verified, so a hand-forged block can't widen either.
Verifying, and what "fail-closed" means here
let caps be captoken_verify(token, secret("ROOT_KEY"),
{"aud": "orders-api", "ip": request.ip, "method": request.method})
when caps == nothing
give unauthorized("bad token")
when not captoken_allows(caps, "net", target_host)
give fail(403, "that host is not in your token")
Every failure — bad signature, expired, forged, revoked, wrong audience — is
nothing, with no hint about which one: an endpoint must not be distinguishable by
why it rejected you. And a caveat you don't supply context for is a rejection,
not a pass: if the token says aud: "orders-api" and you never pass aud, you
cannot claim the condition holds.
Revocation, said out loud
Attenuation is offline, so there is no central check to hang revocation on. The
design answer is two things, and you should decide both before an incident, not
during one:
- Short TTLs. The default is 15 minutes on purpose.
- A denylist of ids.
captoken_verify(t, k, {"revoked": [...]}), with the list
typically read from redis. id is what you revoke; set it explicitly for tokens
you'll want to name later.
Signed requests (proof-of-possession)
Instead of sending a token that anyone who copies it can replay, sign the request:
let key be secret("AGENT_KEY") -- inside the handler, see the pitfall below
let req be {"method": "POST", "url": "https://api.partner.com/orders", "body": payload}
let headers be http_sign(req, key, {"alg": "ed25519", "keyid": "agent-7"})
let res be http_post("https://api.partner.com/orders", payload, headers)
Synsema implements a pinned profile of RFC 9421: the signature always covers
@method, @target-uri and content-digest (the digest goes even for an empty
body — if it were optional, someone could attach a body to a signed request without
breaking the signature), plus created, keyid and alg. No arbitrary component
lists, no general structured-field canonicalisation: that surface is where
implementations get quietly wrong.
Signing goes through the same door as signing a blockchain transaction:
require sign("AGENT_KEY"), deny-by-default, and every signature lands in the audit
log. The key is a sealed secret and never becomes a language value.
On the receiving end, http_signature_verify is the symmetric half — and it
requires you to pin the algorithm:
let v be http_signature_verify(incoming, pubkey, {"alg": "ed25519"})
That's not ceremony. If the verifier took alg from the message, an attacker would
switch it to hmac-sha256 and sign using your public key as the HMAC secret —
the same confusion attack that broke JWT libraries for years. The verifier chooses
the algorithm; the message never does. The default anti-replay window is ±300s on
created (a future timestamp is rejected too — otherwise it would be a deferred
replay), and if the client sent a nonce, verification hands it back so you can
check it against your own replay store on mutating routes.
Third-party identity: OIDC and cloud workloads
jwt_verify is for tokens you signed with your secret. For a token from
Google, GitHub, Auth0 — or from the cloud your agent is running in — use
oidc_verify:
require net("www.googleapis.com")
let claims be oidc_verify(id_token, {
"iss": "https://accounts.google.com",
"aud": "your-client-id.apps.googleusercontent.com",
"jwks_url": "https://www.googleapis.com/oauth2/v3/certs"
})
iss and aud are mandatory, and that is a deliberate design decision rather
than an oversight to fix later: a token minted by the same provider for a *different
application* has a perfectly valid signature. Checking the signature without the
audience is the classic confused-deputy hole. RS256 and ES256 are supported (RSA
below 2048 bits is refused); the JWKS is fetched over net, cached for 10 minutes,
and re-fetched when a kid shows up that isn't in the cached set — which is exactly
what key rotation looks like.
Note which failures are nothing and which are errors: a bad token is
nothing; a JWKS that can't be fetched is an error. "I couldn't verify it" must
never be mistaken for "it isn't valid".
This is what makes workload identity work: an agent on AWS, GCP, Azure or GitHub
Actions already has an identity issued by the platform. Read it from the metadata
service, exchange it for short-lived credentials, and there is no long-lived secret
in your .env at all. (On AWS always use IMDSv2 — the token-first flow; v1 is the
classic SSRF vector.)
mTLS — identity by certificate
For service meshes and partners that require client certificates:
require file.read("./certs/*")
mtls_identity("./certs/agent.pem", "./certs/agent.key",
{"hosts": ["*.mesh.internal", "vault.example"]})
-- https:// requests to those hosts present the certificate; the rest don't
It's per process, not per request, because a certificate identifies the
workload — the same idea as SPIFFE.
opts.hosts takes a list of hosts (or a single host as text) and follows the same
wildcard rule as require net: "*.mesh.internal" covers the domain and its
subdomains. Omit it and the certificate goes to every host the program can reach
— already bounded by require net, so a narrow net scope contains it on its own.
Declare hosts when your net scope is broad: presenting a client certificate is
saying "I am this agent", and a third party that merely asks for one shouldn't be
able to harvest your workload identity.
Terminating mTLS on the serve side (verifying incoming client certs) isn't in the
engine yet; put a reverse proxy in front for that.
Serving agents: identity, quotas and discovery
The server side lives in Serve. The short version:
- One
auth withtask handles both kinds of subject — return the verified captoken
for an agent, the session for a human, nothing for neither.
- The runtime picks the identity out of what you returned (
id,sub,keyid,
or a plain text) and uses it to meter:
- rate limit per identity in addition to the per-IP shield that runs before
auth (that shield is what stops an anonymous flood from burning a worker on
every auth attempt);
- spend, booked per identity in the ledger and in the audit line — and a
captoken's spend caveat becomes a real ceiling the server enforces, which
is how an orchestrator caps a sub-agent's budget for real rather than by
convention. Three ceilings can apply at once and the strictest wins: per unit
(SYNSEMA_SPEND_CEILING="EUR:500,ETH:0.1"), per identity
(SYNSEMA_SPEND_CEILING_PER_IDENTITY="agent-1=EUR:50" — its own variable, with
= before the unit, so a unit containing : can never collide with an identity
key) and the delegated one. The **unit is free text and no currency is
privileged**: fiat, crypto, commodities, credits, kWh — amounts keep up to 28
decimal places, so an 18-decimal crypto unit fits whole.
/.well-known/synsema-authpublishes, in JSON, which mechanisms the server
understands and which endpoints are protected — the machine-readable companion to
/llms.txt, for an agent that never read this page.
Choosing between the mechanisms
| You need | Use |
|---|---|
| A sub-agent to do less than you can | captoken_mint + captoken_attenuate |
| A stolen credential to be worthless | http_sign / http_signature_verify |
| "Log in with Google/GitHub" | oidc_verify |
| No secret in the environment at all (cloud) | workload identity → oidc_verify |
| A service mesh that demands client certs | mtls_identity |
| A human with a browser | Login & sessions |