Skip to content

guides

Intended Documentation

Verify Decision Tokens

Verify Ed25519 Authority Tokens locally with @intended/verify — pin the kid, validate claims, enforce the 300s TTL, and reject on the exact failure reasons the verifier returns.

Verify Authority Tokens#

The authorityDecisionToken returned by POST /intent is an Ed25519 JWT bound to exactly one intent, tenant, and target. Intended decides and signs; it never runs the action. Before your agent's action takes effect, the service that receives it (or the gateway you run in front of it) verifies the token locally — it never needs to call back to the Intended runtime. Verification is fail-closed: any failed check rejects the action.

This page uses @intended/verify's verifyToken, which is built on jose. Single-use enforcement (nonce consumption) is the verifying service's job; the last step below covers it.

Authority Token lifecycle
A token is minted on APPROVED as an Ed25519 JWT, carried by your agent to the service it calls, verified locally — kid pinned, signature checked, claims and expiry validated — then its nonce is consumed for single use. Replay, expiry, and kid mismatch are rejection states.

Minted on APPROVED, verified locally, consumed once. Kid mismatch, bad signature, claim mismatch, and expiry are all rejected.

Token claims#

The validated claim set (from AuthorityDecisionTokenClaimsSchema):

ClaimMeaning
intentIdThe intent this token authorizes (UUID).
tenantIdTenant scope.
adapterIdThe consumer the token is bound to. Decisions from /v1/agent-intent carry decision-only.
adapterTargetThe specific target the action is bound to.
targetSystemThe system the action targets.
proposedActionThe action that was approved.
decisionAlways APPROVED — only approvals mint tokens.
issuedAt / expiresAtISO-8601 validity window.
nonce≥32-character value enforcing single use.

At signing time the runtime also sets the registered JWT claims iss: "intended-authority", aud: <adapterId>, and integer iat / exp. The header is { "alg": "EdDSA", "typ": "JWT", "kid": "<tenant-key-id>" } — kid is mandatory.

Local verification flow#

Decode the header and read kid

Read the kid from the JWT header and load the tenant's public key for that kid. Pinning kid is mandatory; an unpinned or unknown kid is rejected with KID_MISMATCH.

Verify the Ed25519 signature

Verify against the public key, restricted to the EdDSA algorithm, with the expected issuer and audience. A failed signature check returns SIGNATURE_VERIFICATION_FAILED.

Validate the claims

The verifier parses the claims against the schema, then asserts tenantId and adapterId match what you expect, the issuedAt/expiresAt window is well-formed, the token is not used before valid or past expiry (within clockSkewSeconds), and the TTL does not exceed maxTokenTtlSeconds.

Consume the nonce

verifyToken itself does not touch a datastore — it is pure verification. Single-use enforcement is the verifying service's responsibility: record each accepted (tenantId, nonce) atomically (a unique constraint works well) and reject a token whose nonce has already been consumed, before you act.

With the TypeScript SDK#

From @intended-inc/sdk 0.8.0, guard() does all of this for you before your callback runs: it fetches the tenant's public key for the token's kid, calls POST /verify/token with consume: true, and runs the callback only if that call used the token up. A token that is invalid, expired or already used returns status: "denied" and the callback does not run. Pass { verifyToken: "verify" } to check without consuming (when a downstream service consumes the token), or "off" to skip the check, which logs a warning because the decision is then advisory only. This is a breaking change in 0.8.0: if your callback passes the token to a service that consumes it, use { verifyToken: "verify" }.

Checking a token needs intent:create or token:read (the "Write" preset has both; "Read" has token:read), verify:token or audit:read. Consuming it (consume: true) needs intent:create (the "Write" preset); any other key, including one with verify:token, gets 403 MISSING_CONSUME_SCOPE. Re-consuming a token with the same API key that submitted it answers TOKEN_ALREADY_CONSUMED but is not treated as a token replay by Intended's abuse detection.

/verify/* is rate limited to 600 requests per API key per 5 minutes by default (429 VERIFY_RATE_LIMITED with Retry-After).

The console's Insights page shows the share of allowed actions in the last 7 days whose token was verified with consume: true. Tokens checked only offline are not counted there.

Each credential gets a share of the tenant's /verify/* rate window, so one busy or misbehaving verifier is refused on its own (VERIFY_RATE_LIMITED) without blocking your other verifiers. VERIFY_TENANT_RATE_LIMITED means several credentials together filled the tenant window; Intended is alerted when that happens.

Verifying in code#

verifyToken returns { valid, reason, claims, header }. On success reason is null and claims is the validated claim set; on failure valid is false and reason names the failure.

ts
import { verifyToken } from "@intended/verify";

const result = await verifyToken({
  token,                          // the authorityDecisionToken from POST /intent
  publicKeyPem,                   // tenant public key (SPKI PEM) for the token's kid
  expectedKid: kid,               // pin the key id
  expectedTenantId: "tenant_acme_prod",
  expectedAdapterId: "decision-only",
  expectedIssuer: "intended-authority",
  expectedAudience: "decision-only", // aud === adapterId
  maxTokenTtlSeconds: 300,        // 300s is the hard cap; a longer TTL is rejected
  clockSkewSeconds: 60,
});

if (!result.valid) {
  // result.reason is one of the documented failure reasons below
  throw new Error(result.reason ?? "TOKEN_SIGNATURE_INVALID");
}
// result.claims is the validated claim set; proceed with the action.

Failure reasons#

These are the exact reason values verifyToken returns. Treat every one as a hard reject.

ReasonCause
KID_MISMATCHToken kid does not match the pinned expectedKid.
SIGNATURE_VERIFICATION_FAILEDSignature, issuer, or audience check failed.
TOKEN_CLAIMS_INVALIDClaims fail schema validation.
TOKEN_TENANT_MISMATCHToken tenantId ≠ expectedTenantId.
TOKEN_ADAPTER_MISMATCHToken adapterId ≠ expectedAdapterId.
TOKEN_CLAIMS_INVALID_TIMEissuedAt / expiresAt not parseable.
TOKEN_TIME_WINDOW_INVALIDexpiresAt ≤ issuedAt.
TOKEN_USED_BEFORE_VALIDNow + skew is before issuedAt.
TOKEN_EXPIREDNow − skew is past expiresAt.
TOKEN_TTL_EXCEEDS_LIMITTTL exceeds maxTokenTtlSeconds (the 300s cap).

Warning

verifyToken is signature + claim verification only. It does not enforce single use — that requires consuming the nonce. Track and reject seen nonces in the service that acts on the token.

On-robot verification (Physical AI)#

For physical-AI runtimes, the Authority Token minted by POST /v1/physical/authority-tokens is verified at the robot or controller before actuation lands, so a compromised network path or stale cloud session cannot let an actuation through. The physical-AI signer signs with RS256, not the Ed25519 used by the general /intent token above, and publishes its public key at /.well-known/jwks.json. In production, when INTENDED_PHYSICAL_KMS_KEY_ID is set, the signer is backed by a persistent AWS KMS key; in dev/sandbox it falls back to an ephemeral per-process keypair that rotates on restart, so cache JWKS short and refresh on a kid miss.

Any standard JWT library can verify a physical token. Check:

CheckExpected value
AlgorithmRS256 only; resolve the key by the header kid from the JWKS
isshttps://api.intended.so
audintended-edge-verifier
expIn the future (allow a small clock-skew leeway)
intended.tenantIdThe tenant this robot is provisioned to
sub / intended.actorIdentityThis robot's registered actorIdentity
jtiNot seen before, if your controller enforces single use
python
import jwt  # PyJWT[crypto]

jwks = jwt.PyJWKClient("https://api.intended.so/.well-known/jwks.json", cache_keys=True)

def verify_physical_token(token: str, tenant_id: str, actor_identity: str) -> dict | None:
    try:
        key = jwks.get_signing_key_from_jwt(token)
        claims = jwt.decode(
            token,
            key.key,
            algorithms=["RS256"],
            audience="intended-edge-verifier",
            issuer="https://api.intended.so",
            leeway=1,
            options={"require": ["exp", "jti", "sub"]},
        )
    except jwt.PyJWTError:
        return None  # refuse the actuation
    physical = claims.get("intended", {})
    if physical.get("tenantId") != tenant_id or claims["sub"] != actor_identity:
        return None
    return claims  # proceed with motion; record claims["jti"] if enforcing single use

Next steps#

Verify Decision Tokens | Intended