The default trust model in most AI agent codebases looks like this:

import openai
from mycorp.db import Database

db = Database(connection_string=os.environ["DB_URL"])  # full creds

@tool
def query_users(filter: str) -> list:
    return db.execute(f"SELECT * FROM users WHERE {filter}")

The agent process holds DB_URL in env or memory. Every tool the agent can call has the keys to whatever it talks to. A prompt injection that gets the agent to call query_users with a malicious filter doesn't have to bypass auth โ€” the auth already happened, at process startup, with the full-power credential.

This is the same trust model we shipped web apps with in 2008. We learned then. We're re-learning now.

What we built instead

Every tool call through the Clampd gateway gets a fresh, short-lived Ed25519-signed token minted at the moment of approval. The token is signed by the gateway with an Ed25519 keypair. Tools verify the signature using a public key fetched from a JWKS endpoint exposed by the gateway. The agent never holds long-lived credentials for downstream tools.

The on-the-wire format is intentionally compact: base64url(json_payload).base64url(ed25519_signature). Two segments, not three. We don't ship the JWT-style protected header because every claim a tool needs is in the payload and the signing algorithm is fixed at deployment time. JWS-shaped, but stripped down. (If your tooling expects RFC 7519 JWTs with a header, the source is in services/crates/ag-gateway/src/scope_token.rs if you need to wrap it.)

Token claims look like this:

// Decoded scope-token claims (Ed25519-signed)
{
  "sub":     "agent-7b3a-...",           // agent_id
  "scope":   "db:query:read",             // taxonomy-driven scope string
  "tool":    "db.query",                  // tool name
  "binding": "sha256(tool || params)",    // SHA-256 over the call payload
  "exp":     1715000000,                  // expiry (unix seconds)
  "rid":     "req-9c2f1e..."              // request_id for audit correlation
}

The four fields that matter for security:

The flow per call

For every tool call the agent makes:

  1. Agent calls the gateway: "I want to call db.query with these params."
  2. Gateway runs the 9-stage pipeline (auth, classify, policy, etc).
  3. If allowed, gateway asks ag-token: "mint a scope token for this agent, this tool, these params, this scope."
  4. The gateway signs the claims above with the Ed25519 key. binding field = SHA-256 over (tool name + canonical-JSON params).
  5. Gateway forwards the call to the downstream tool with the token in Authorization: Bearer ....
  6. Tool verifies the token's signature against the gateway's JWKS endpoint (/.well-known/jwks.json). Caches the public key.
  7. Tool checks: scope matches my operation? tool name matches? binding matches what I just received? not expired?
  8. If yes, tool runs and returns. If no, 403.

The agent never holds the database password. ag-token holds it (or, more commonly, ag-token has an OIDC trust relationship with the database's IdP and mints DB-specific credentials downstream of the scope-token check). The agent's process memory contains nothing more dangerous than a request_id.

The four properties that follow from this

1. Stolen token has 5-minute lifetime

An attacker who pulls a token out of an audit log, a network capture, or a memory dump can use it for at most the remaining TTL. Default 300s. The blast radius of token leakage is bounded by clock seconds.

2. Stolen token can't be replayed against a different tool

The tool claim pins the token to a specific tool name. A token minted for db.query won't validate when presented to db.delete, even if the scope strings overlap. Forces the attacker to capture a token specifically for the operation they want, not just any token from the same agent.

3. Stolen token can't be replayed with different params

The binding field is a SHA-256 over the canonical-JSON form of the (tool, params) that the token was issued for. The receiving tool computes the same hash over the params it just received. If the attacker captured a token for db.query(SELECT 1) and tries to replay it for db.query(DROP TABLE users), the hashes don't match. 403.

4. Stolen agent identity can't help (much) without the gateway

Even if the attacker compromises the agent itself and steals its long-term identity (the ags_ secret used to authenticate to the gateway), they still have to go through the gateway to mint scope tokens. The gateway runs the policy pipeline on every call. A compromised agent that suddenly tries 50 different tools, 500 db queries, or anything that crosses session-pattern thresholds gets auto-suspended (see our 8-layer kill cascade) before doing serious damage.

Why Ed25519, not RSA

Three reasons:

Both Python and TypeScript SDKs ship a verify_scope_token / verifyScopeToken helper that does the JWKS fetch, signature check, and claims validation in one call. Any language with an Ed25519 verify primitive and JSON parsing can implement the same logic in ~30 lines.

What this looks like in tool-author code

Tool implementers call require_scope at the top of the tool function. The library handles JWKS caching, Ed25519 signature verification, expiry, and scope check. If any of those fail, require_scope raises ScopeVerificationError, which propagates out of your handler.

# Tool implementation (Python). The agent never sees the DB password.
from clampd import require_scope

def handle_db_query(sql: str):
    # Verify scope token: signature, scope, expiry.
    # Raises ScopeVerificationError on any failure.
    claims = require_scope("db:query:read")

    # The DB password lives in this process, not the agent's.
    return internal_db.execute(sql)

What require_scope verifies for you: the Ed25519 signature against the gateway's public key (fetched from JWKS and cached), the expiry timestamp, and that the requested scope appears in the token's scope claim. What it does not verify automatically is the binding field. That check is the tool author's responsibility โ€” and it's the most important piece for full anti-replay protection. The pattern:

import hashlib, json
from clampd import require_scope

def handle_db_query(sql: str):
    claims = require_scope("db:query:read")

    # Verify the binding: tool author re-computes the hash and
    # compares to the claim. This is what makes a captured token
    # unreplayable with different params.
    expected = hashlib.sha256(json.dumps(
        {"tool": "db.query", "params": {"sql": sql}},
        sort_keys=True, separators=(",", ":"),
    ).encode()).hexdigest()

    if claims.binding != expected:
        raise PermissionError("Token binding does not match call payload")

    return internal_db.execute(sql)

We're working on a helper that wraps the binding check too, so tool authors won't need to compute the hash themselves. Until then: the binding field is in claims.binding, the canonicalisation rule is the same as the gateway's (sort_keys=True, separators=(",", ":")), and a tool that skips this check still gets scope + signature + expiry, which is most of the value but not all of it.

Where this isn't a fit

Honest limits

Scope tokens require the tool to do verification. If you can't change the tool's code (a SaaS API you don't control, a closed-source MCP server), you can't get the scope-token guarantees end-to-end. In that case, the token check happens at the gateway boundary and the tool still gets called with whatever credential the gateway holds for it. You get gateway-level scoping but not tool-side enforcement. Honest gap. The pattern works best when you control both ends, which is the typical case for first-party tools but not always for third-party APIs.

Other limits worth naming:

What you can do without Clampd

Even if you build this yourself, three architectural patterns transfer:

Building this yourself means writing token-mint code, signature verification, JWKS handling, claims validation, key rotation, audit logging, and clock-skew handling. It's well-understood territory but it isn't free. We built it once, in Rust, ship it as part of Clampd, and let tool authors call require_scope("db:query:read") instead.

Why no AI security tool we benchmarked has this

Our comparison page matrix shows a row for "cryptographic per-call scope tokens (Ed25519-signed)." Across the verified products in the four other approach categories (prompt-only safety filters, cloud AI gateways, OSS LLM classifiers, DIY framework hooks), none ship this out of the box. Cloud AI gateways have IAM/OAuth at the gateway boundary but not per-call params binding. OSS classifiers are stateless models, not auth systems. Sophisticated teams sometimes roll per-call tokens themselves on top of framework hooks โ€” we've talked to a few who have โ€” but it's not a feature you can install, and the binding-against-canonical-params detail is the part most homegrown implementations skip.

Our take on why: per-call tokens require a stateful component that knows what the agent is allowed to do, who's calling, and how to mint signed tokens at sub-millisecond cost. That component looks like a gateway. If you don't already have a gateway in front of agent traffic, scope tokens are a heavy lift. If you do, they're an obvious feature.

Try Clampd in 60 seconds

One line of Python or TypeScript. Scope tokens are minted automatically on every approved call. Tools verify with one decorator. Self-hosted, source-available.

pip install clampd npm install @clampd/sdk
Get Started โ†’ Compare to alternatives