---
slug: 46-agent-identity
title: Agent identity & auth
description: Agents as first-class subjects — capability tokens that can only narrow, signed requests, third-party OIDC, mTLS, and quotas metered per agent identity instead of per IP.
example_ids: [agent-identity]
---

# Agent identity & auth

> **Engine v0.5.6+.**

[Login & sessions](40a-web-auth) 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.

```synsema
-- 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:

```synsema
-- 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

```synsema
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:

```synsema
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**:

```synsema
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`:

```synsema
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:

```synsema
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](40-serve#agent-identity). The short version:

- One `auth with` task 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-auth` publishes, 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](40a-web-auth) |
