Documentation
Everything you need to issue, delegate, verify, and revoke credentials for your AI agents.
Overview
Attest gives every AI agent in your pipeline a cryptographically signed, scoped, auditable credential -- so you always know who authorized what action and when.
The Missing Protocol for AI Agent Authorization
A short companion piece on the product thesis behind Attest. It explains why delegated authority, intent binding, and provenance need to move together as agents fan out across tools and sub-agents. Read the article.
Each credential is an RS256-signed JWT carrying:
- att_tid -- task-tree UUID linking all credentials to a user request
- att_uid -- the end-user who initiated the task
- att_scope -- an allow-list of actions this credential permits
- att_chain -- the ordered list of ancestor JTIs, enabling cascade revocation
- att_depth -- delegation depth (0 = root, 1 = first child, ...)
The credential lifecycle:
Quickstart
The fastest path to value is not raw credential issuance. It is protecting one real MCP tool call end to end.
Fastest activation path: run the demo, add withAttest() to one MCP server, issue a credential with the exact discovered scopes, then confirm an allowed call succeeds and an out-of-scope call is blocked.
Want the shortest real path? Use the dedicated MCP quickstart for the copy-paste integration path, then come back here for the broader API reference.
1. Install the SDK and protect one tool
npm install @attest-dev/sdk @modelcontextprotocol/sdk
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { withAttest } from "@attest-dev/sdk/mcp";
const server = new McpServer({ name: "my-tools", version: "1.0.0" });
const protectedServer = withAttest(server, {
issuerUri: "https://api.attestdev.com",
});
protectedServer.tool("send_email", schema, handler);
2. Expose scopes, then issue only what the tool needs
import { getAttestScopes } from "@attest-dev/sdk/mcp";
app.get("/.well-known/attest-scopes", (_req, res) => {
res.json({ tools: getAttestScopes(protectedServer) });
});
# Create an organisation and save the API key (shown once)
curl -X POST https://api.attestdev.com/v1/orgs \
-H "Content-Type: application/json" \
-d '{"name": "acme-corp"}'
# Issue only the discovered scope for send_email
curl -X POST https://api.attestdev.com/v1/credentials \
-H "Authorization: Bearer att_live_..." \
-H "Content-Type: application/json" \
-d '{
"agent_id": "orchestrator-v1",
"user_id": "usr_alice",
"scope": ["email:send"],
"instruction": "Send the weekly digest"
}'
3. Confirm the protected-tool moment
# Expected behavior after wiring Attest into the MCP server
# - a tool call with email:send succeeds
# - a tool call requiring crm:write is blocked
# - the task tree can be revoked with DELETE /v1/credentials/{jti}
# - the evidence trail is queryable with GET /v1/tasks/{tid}/audit
API Reference
Base URL: https://api.attestdev.com
Organisations
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/orgs |
public | Create an org. Returns api_key (shown once), key_id, org. |
| GET | /v1/org |
Bearer | Return the authenticated org's details. |
Credentials
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /v1/credentials |
Bearer | Issue a root credential. Body: agent_id, user_id, scope, instruction, ttl_seconds. |
| POST | /v1/credentials/delegate |
Bearer | Delegate to a child agent. Body: parent_token, child_agent, child_scope, ttl_seconds. |
| DELETE | /v1/credentials/{jti} |
Bearer | Revoke a credential and all its descendants. Body: revoked_by. |
| GET | /v1/revoked/{jti} |
public | Check if a JTI is revoked. Returns {"revoked": true|false}. |
Audit
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/tasks/{tid}/audit |
Bearer | Retrieve the append-only, cryptographically chained audit log for a task tree. |
JWKS
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /orgs/{orgID}/jwks.json |
public | Return the org's active RSA-2048 public key as a JSON Web Key Set. |
Key Management
Manage API keys and rotate your org's RSA signing key.
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /v1/org/keys |
Bearer | List all API keys (id, name, created_at, revoked_at). |
| POST | /v1/org/keys |
Bearer | Create a new API key. Body: {"name": "ci-key"}. Returns api_key (shown once) and key_id. |
| DELETE | /v1/org/keys/{keyID} |
Bearer | Revoke an API key. Cannot revoke the key used to authenticate the request. |
| POST | /v1/org/keys/rotate |
Bearer | Rotate the org's RSA signing key. Returns {"kid": "new-key-id"}. Fetch updated JWKS before old tokens expire. |
Key rotation: after calling POST /v1/org/keys/rotate, re-fetch
/orgs/{orgID}/jwks.json to get the new public key. Existing tokens signed with
the old key remain valid until they expire -- they will just fail to verify against the new JWKS
if you clear your cache.
TypeScript SDK
Install
npm install @attest-dev/sdk
Issue and verify
import { AttestClient, AttestVerifier } from "@attest-dev/sdk";
const client = new AttestClient({ apiKey: "att_live_..." });
// Issue a root credential
const { token, claims } = await client.issue({
agentId: "summary-agent",
userId: "user-123",
scope: ["files:read", "db:query"],
instruction: "Summarise the quarterly report",
ttlSeconds: 3600,
});
// Delegate to a sub-agent with narrower scope
const { token: childToken } = await client.delegate({
parentToken: token,
childAgent: "db-agent",
childScope: ["db:query"],
ttlSeconds: 900,
});
// Verify (offline -- no API call needed after JWKS is cached)
const verifier = new AttestVerifier({ orgId: claims.att_tid });
const result = await verifier.verify(childToken);
console.log(result.valid, result.claims?.att_scope);
Revoke and audit
// Revoke (cascades to all descendants)
await client.revoke(claims.jti, "user-requested");
// Fetch audit chain
const events = await client.audit(claims.att_tid);
events.forEach(e => console.log(e.event_type, e.agent_id));
Python SDK
Install
pip install attest-sdk
Issue and delegate
from attest import AttestClient, IssueParams, DelegateParams
client = AttestClient(api_key="att_live_...")
# Issue a root credential
token = client.issue(IssueParams(
agent_id="summary-agent",
user_id="user-123",
scope=["files:read", "db:query"],
instruction="Summarise the quarterly report",
ttl_seconds=3600,
))
print(token.claims.att_tid) # task-tree UUID
print(token.claims.att_scope) # ["files:read", "db:query"]
# Delegate to a sub-agent
child = client.delegate(DelegateParams(
parent_token=token.token,
child_agent="db-agent",
child_scope=["db:query"],
ttl_seconds=900,
))
Verify with AttestVerifier
from attest import AttestVerifier
# No API key needed -- fetches JWKS once and caches it
verifier = AttestVerifier(org_id="<orgID>")
result = verifier.verify(child.token)
print(result.valid) # True
print(result.claims.att_depth) # 1
Revoke and audit
# Revoke (cascades to descendants)
client.revoke(token.claims.jti, revoked_by="user-requested")
# Fetch audit chain
events = client.audit(token.claims.att_tid)
for e in events:
print(e.event_type, e.agent_id)
Scope format
Scopes are strings in resource:action format. Wildcards are supported:
files:* grants all actions on files.
| Scope | Grants |
|---|---|
files:read | Read files only |
files:* | All actions on files |
db:query | Run read-only database queries |
*:read | Read any resource |
A delegated credential's scope must be a subset of its parent's scope. Attempting to expand scope during delegation returns a 422 error.
Delegation depth
Each credential carries an att_depth field. Root credentials have depth 0.
Each delegation increments depth by 1. There is no hard cap on depth, but practical
pipelines rarely exceed 3-4 levels.
Chain integrity
The att_chain field is an ordered list of ancestor JTIs, starting from the
root and ending with the current credential's own JTI. Verifiers should assert:
len(att_chain) == att_depth + 1att_chain[-1] == jti
Revoking any credential in the chain cascades to all descendants -- any credential whose
att_chain contains the revoked JTI is also considered revoked.
Key rotation
Call POST /v1/org/keys/rotate to generate a new RSA-2048 signing key. The old
key is retired but not deleted -- existing tokens signed with it remain verifiable until they
expire (as long as you retain the old JWKS).
After rotation, re-fetch /orgs/{orgID}/jwks.json. The JWKS endpoint always
returns the current active key, so verifiers that cache JWKS should call
clear_jwks_cache() (Python) or equivalent after rotation.