---
slug: 46-agent-identity
title: Identidad de agentes y auth
description: Los agentes como sujetos de primera clase — tokens de capacidad que solo pueden achicarse, requests firmadas, OIDC de terceros, mTLS y cuotas medidas por identidad de agente en vez de por IP.
example_ids: [agent-identity]
---

# Identidad de agentes y auth

> **Engine v0.5.6+.**

[Login y sesiones](40a-web-auth) trata del **humano con un browser**: una contraseña,
una cookie, un código 2FA. Esta página trata del otro sujeto con el que habla tu
servidor: un **agente**. No tiene browser, no debería cargar un secreto de larga
vida, y muchas veces necesita entregarle una porción *más débil* de su propia
autoridad a un subagente que lanzó hace cinco segundos.

Synsema trata eso como caso de primera clase: el runtime sabe *quién* está llamando,
y mide el gasto y el rate limit por identidad en vez de por IP o por proceso.

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

## Por qué no alcanza con darle una API key al agente

Una API key en el contexto de un agente es una credencial al portador dentro del
lugar más propenso a filtrarse de todo el sistema: un prompt. Tres propiedades
cambian eso:

- **No debería ser de larga vida.** Los workloads en la nube ya tienen identidad
  (`oidc_verify`, más abajo); las terminales pueden usar un device flow. No hay nada
  que robar.
- **No debería servir si la copian.** Una request *firmada* (`http_sign`) demuestra
  posesión de una clave que jamás sale del `secret` sellado.
- **No debería delegarse entera.** Cuando un orquestador lanza un subagente, la
  práctica universal hoy es pasarle la misma key — suplantación total. Los tokens de
  capacidad permiten entregar estrictamente menos.

## Tokens de capacidad — delegación que solo puede achicar

Un captoken dice *qué puede hacer quien lo porta*, y quien lo porta puede debilitarlo
**offline**, sin la clave raíz:

```synsema
-- la unidad de spend es la que use el host: fiat, cripto, commodities, créditos
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"})

-- delegar trabajo a un subagente: un solo host, sin base de datos, una cincuentava parte del presupuesto
let sub be captoken_attenuate(full, {"net": "api.example.com", "spend": "ETH"},
                              {"ttl": 60, "spend": {"ETH": 0.01}})
```

`captoken_attenuate` **no recibe clave** — ese es justamente el punto, y por eso
delegar no necesita ida y vuelta con quien acuñó el token. Funciona porque cada
bloque de la cadena se firma usando la firma anterior como clave (la construcción de
macaroons): quien porta el token puede *agregar* un bloque, nunca quitar ni editar
uno.

Los permisos son las mismas formas de `require` — `net("api.example.com")`,
`db("postgres://host/db")`, `file.read("./data/*")` — y el achique se verifica con la
misma relación `covers()` que usa el sistema de capabilities, así que un scope en un
token significa exactamente lo que significa en un `require`. Ampliar se rechaza
**dos veces**: al atenuar (con un error que dice qué no entraba) y de nuevo al
verificar, así que un bloque forjado a mano tampoco puede ampliar.

### Verificar, y qué significa acá "fail-closed"

```synsema
let caps be captoken_verify(token, secret("ROOT_KEY"),
                            {"aud": "orders-api", "ip": request.ip, "method": request.method})
when caps == nothing
    give unauthorized("token inválido")
when not captoken_allows(caps, "net", target_host)
    give fail(403, "ese host no está en tu token")
```

Toda falla — firma mala, vencido, forjado, revocado, audiencia equivocada — es
`nothing`, sin pistas de cuál fue: un endpoint no debe poder distinguirse por *por
qué* te rechazó. Y un caveat para el que no aportás contexto es un **rechazo**, no un
permiso: si el token dice `aud: "orders-api"` y nunca pasás `aud`, no podés afirmar
que la condición se cumple.

### Revocación, dicha en voz alta

La atenuación es offline, así que no hay chequeo central donde colgar la revocación.
La respuesta de diseño son dos cosas, y conviene decidir ambas antes de un incidente,
no durante:

- **TTLs cortos.** El default son 15 minutos a propósito.
- **Una denylist de ids.** `captoken_verify(t, k, {"revoked": [...]})`, con la lista
  típicamente leída de redis. `id` es lo que se revoca: fijalo explícitamente en los
  tokens que después vas a querer nombrar.

## Requests firmadas (proof-of-possession)

En vez de mandar un token que cualquiera que lo copie puede repetir, firmá la
request:

```synsema
let key be secret("AGENT_KEY")             -- dentro del handler; ver el pitfall abajo
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 implementa un **perfil pineado** de RFC 9421: la firma siempre cubre
`@method`, `@target-uri` y `content-digest` (el digest va incluso con body vacío: si
fuera opcional, alguien podría agregarle body a una request firmada sin romper la
firma), más `created`, `keyid` y `alg`. Sin listas arbitrarias de componentes ni
canonicalización general de structured fields: esa superficie es donde las
implementaciones se equivocan en silencio.

Firmar pasa por **la misma puerta que firmar una transacción de blockchain**:
`require sign("AGENT_KEY")`, deny-by-default, y cada firma queda en el log de
auditoría. La clave es un `secret` sellado y nunca se convierte en un valor del
lenguaje.

Del lado receptor, `http_signature_verify` es la mitad simétrica — y **exige que
pinees el algoritmo**:

```synsema
let v be http_signature_verify(incoming, pubkey, {"alg": "ed25519"})
```

No es ceremonia. Si el verificador tomara `alg` del mensaje, un atacante lo cambiaría
a `hmac-sha256` y firmaría usando tu clave **pública** como secreto HMAC — el mismo
ataque de confusión que rompió librerías de JWT durante años. El algoritmo lo elige
el verificador; el mensaje nunca. La ventana anti-replay por defecto es ±300 s sobre
`created` (un timestamp *futuro* también se rechaza: sería un replay diferido), y si
el cliente mandó un `nonce`, la verificación te lo devuelve para que lo chequees
contra tu propio store de replay en las rutas de mutación.

## Identidad de terceros: OIDC y workloads en la nube

`jwt_verify` es para tokens que firmaste **vos** con **tu** secreto. Para un token de
Google, GitHub, Auth0 — o de la nube donde corre tu agente — va `oidc_verify`:

```synsema
require net("www.googleapis.com")

let claims be oidc_verify(id_token, {
    "iss": "https://accounts.google.com",
    "aud": "tu-client-id.apps.googleusercontent.com",
    "jwks_url": "https://www.googleapis.com/oauth2/v3/certs"
})
```

`iss` y `aud` son **obligatorios**, y eso es una decisión de diseño deliberada, no un
olvido a corregir después: un token acuñado por el mismo proveedor para *otra
aplicación* tiene una firma perfectamente válida. Chequear la firma sin la audiencia
es el agujero clásico del confused deputy. Se soportan RS256 y ES256 (una RSA de
menos de 2048 bits se rechaza); el JWKS se trae por `net`, se cachea 10 minutos y se
vuelve a traer cuando aparece un `kid` que no está en el set cacheado — que es
exactamente cómo se ve una rotación de claves.

Fijate cuáles fallas son `nothing` y cuáles son errores: un **token** malo es
`nothing`; un JWKS que no se pudo traer es un **error**. "No pude verificarlo" nunca
debe confundirse con "no vale".

Esto es lo que hace funcionar la **workload identity**: un agente en AWS, GCP, Azure
o GitHub Actions ya tiene una identidad emitida por la plataforma. La leés del
metadata service, la canjeás por credenciales cortas, y no queda ningún secreto de
larga vida en tu `.env`. (En AWS usá SIEMPRE IMDSv2 — el flujo con token primero; v1
es el vector clásico de SSRF.)

## mTLS — identidad por certificado

Para service meshes y partners que exigen certificado de cliente:

```synsema
require file.read("./certs/*")
mtls_identity("./certs/agent.pem", "./certs/agent.key",
              {"hosts": ["*.mesh.internal", "vault.example"]})
-- las requests https:// a esos hosts presentan el certificado; el resto no
```

Es por **proceso**, no por request, porque un certificado identifica al *workload* —
la misma idea de SPIFFE.

`opts.hosts` acepta una lista de hosts (o uno solo como texto) y sigue la misma regla
de comodín que `require net`: `"*.mesh.internal"` cubre el dominio y sus subdominios.
**Si lo omitís, el certificado va a todos los hosts que el programa pueda alcanzar** —
ya acotados por `require net`, así que un alcance de `net` angosto lo contiene solo.
Declará `hosts` cuando tu alcance de `net` sea amplio: presentar un certificado de
cliente es decir *"yo soy este agente"*, y un tercero que simplemente lo pida no
debería poder cosechar tu identidad de workload.

Terminar mTLS del lado serve (verificar los certs de cliente *entrantes*) todavía no
está en el engine; para eso, un reverse proxy adelante.

## Servir agentes: identidad, cuotas y descubrimiento

El lado servidor vive en [Serve](40-serve#agent-identity). La versión corta:

- Un solo task de `auth with` atiende a los dos tipos de sujeto: devolvé el captoken
  verificado para un agente, la sesión para un humano, `nothing` para ninguno.
- El runtime saca la **identidad** de lo que devolviste (`id`, `sub`, `keyid`, o un
  texto pelado) y la usa para medir:
  - **rate limit** por identidad *además* del escudo por IP que corre antes del auth
    (ese escudo es lo que evita que una avalancha anónima queme un worker en cada
    intento de autenticación);
  - **spend**, imputado por identidad en el ledger y en la línea de auditoría — y el
    caveat `spend` de un captoken se vuelve un **techo real que el servidor hace
    cumplir**, que es como un orquestador limita de verdad el presupuesto de un
    subagente, y no por convención. Pueden aplicar tres techos a la vez y gana el más
    restrictivo: por unidad (`SYNSEMA_SPEND_CEILING="EUR:500,ETH:0.1"`), por identidad
    (`SYNSEMA_SPEND_CEILING_PER_IDENTITY="agent-1=EUR:50"` — variable propia, con `=`
    antes de la unidad, así una unidad que contenga `:` jamás colisiona con la clave
    de una identidad) y el delegado. La **unidad es texto libre y ninguna moneda está
    privilegiada**: fiat, cripto, commodities, créditos, kWh — los montos guardan
    hasta 28 decimales, así que una unidad cripto de 18 decimales entra entera.
- `/.well-known/synsema-auth` publica, en JSON, qué mecanismos entiende el servidor y
  qué endpoints están protegidos — el compañero legible por máquina de `/llms.txt`,
  para un agente que nunca leyó esta página.

## Cómo elegir entre los mecanismos

| Lo que necesitás | Usá |
|---|---|
| Que un subagente pueda *menos* que vos | `captoken_mint` + `captoken_attenuate` |
| Que una credencial robada no sirva | `http_sign` / `http_signature_verify` |
| "Iniciar sesión con Google/GitHub" | `oidc_verify` |
| Ningún secreto en el entorno (nube) | workload identity → `oidc_verify` |
| Un service mesh que exige certs de cliente | `mtls_identity` |
| Un humano con un browser | [Login y sesiones](40a-web-auth) |
