Verify an agent
Check who is calling, whether a human stands behind them, and what that human allowed — without storing anything.
Three questions, in increasing order of cost. Most services only need the first.
1. Who is calling?
Verify the token against the broker's key set. Nothing is stored on your side.
import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(
new URL("https://id.interagentic.dev/brave-fox-a3f2/jwks.json"),
);
const { payload } = await jwtVerify(token, jwks, {
audience: "api.example.com",
maxTokenAge: "5m",
});
// payload.sub === "interagentic://id.interagentic.dev/brave-fox-a3f2"In practice you derive the JWKS URL from the token's iss rather than hardcoding
it — see publishing a service.
Always check aud. Without it you accept tokens minted for someone else.
2. Is a human behind it?
Ask the broker, authenticated as your own domain:
const assertion = await new SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: DOMAIN_KEY_ID })
.setIssuer("api.example.com")
.setAudience("id.interagentic.dev")
.setIssuedAt()
.setExpirationTime("2m")
.sign(domainPrivateKey);
const res = await fetch(
"https://id.interagentic.dev/brave-fox-a3f2/metadata.json",
{ headers: { authorization: `Bearer ${assertion}` } },
);
const { linked, humanId, permissions } = await res.json();This requires your domain to publish its own key set at
/.well-known/interagentic/jwks.json. That is how the broker knows the request
really comes from you, and it is what scopes the answer to you.
humanId is stable for your domain and unrelated to the identifier the same
person has at any other service. Use it as your user key.
3. What did they allow?
permissions in the same response contains only the grants made to you:
{ "linked": true, "humanId": "h_9c3a…", "permissions": ["orders:read"] }If a permission you need is missing, refuse with a link:
if (!permissions.includes("orders:write")) {
const url = new URL(
"https://id.interagentic.dev/brave-fox-a3f2/link",
);
url.searchParams.set("service", "api.example.com");
url.searchParams.set("permissions", "orders:read,orders:write");
return Response.json(
{ error: "user_required", authorizationUrl: url.toString() },
{ status: 403, headers: { Link: `<${url}>; rel="approve"` } },
);
}The agent hands that link to its human and retries. Ask for the narrowest set you can work with — a person reading the approval screen is deciding whether to trust you, and a long list is a reason to say no.
Skipping a round trip
If the broker supports it, the agent can present a broker-signed token that
already carries human and permissions. Verify it against the broker's own
key set and skip step 2 entirely:
const brokerJwks = createRemoteJWKSet(
new URL("https://id.interagentic.dev/.well-known/jwks.json"),
);
const { payload } = await jwtVerify(brokerToken, brokerJwks, {
audience: "api.example.com",
});
// payload.human, payload.permissions — signed by the broker, not the agentTreat this as a fast path. Keep metadata.json as the fallback, because a
conforming broker is allowed not to implement it.
Caching
| Thing | Cache for | Why |
|---|---|---|
| JWKS | minutes to hours | Keys rotate; jose refetches on unknown kid |
metadata.json | seconds | Permissions are revoked in real time |
| Token verification | never | Tokens already expire in five minutes |
Do not cache a permission decision past a few seconds. Revocation that takes an hour to apply is not revocation.
Demanding a fresher key
If your service handles something sensitive, state a maximum key age and let the agent rotate:
const rotatedAt = Date.parse(metadata.keyRotatedAt);
if (Date.now() - rotatedAt > 15 * 60 * 1000) {
return new Response(null, {
status: 401,
headers: {
"WWW-Authenticate":
'Interagentic realm="api.example.com", error="key_too_old", max_key_age=900',
},
});
}A conforming client rotates and retries once. Choose the number deliberately: frequent rotation shortens the window a stolen key is useful for, and lengthens the time your callers spend rotating instead of working.