INTYGA Developer Reference
INTYGA is a general authorization + witness primitive: no high-risk action runs without a cryptographically-signed human approval and a tamper-evident record. AI agents are one consumer via the Model Context Protocol (MCP); humans and any backend service use the same primitive over a simple REST call (POST /authorize) or the @intyga/sdk. This reference covers all of them, plus the console and public verification APIs. The complete gateway endpoint list ships as docs/API.md in the source repository.
New here? The Quickstart below takes you from signup to your first human-gated action in about 5 minutes.
π Quickstart β gate your first action in 5 minutes
The smallest useful thing INTYGA does: no irreversible action runs until a human signs off on the exact operation β and your code can prove they did. You'll wrap one dangerous call. That's the whole integration.
1. Get credentials
Sign in to the console and mint an API key (API Keys β New). Pick a SERVICE key for pipelines/scripts (it requests, a human approves) or a human key for your own backend. The secret is shown once β INTYGA stores only a hash. Then have an administrator export the trust anchor for your approvers and ship it with your deployment. One file names each approver, the keys they sign with, and the consoleβs WebAuthn expectations, so it configures verification on its own β and quorum counts people rather than keys. Your runtime must know which approver keys it trusts before a receipt arrives; it must never learn them from that receipt.
export INTYGA_GATEWAY_URL=https://api.intyga.com # or your self-hosted gateway export INTYGA_CLIENT_ID=<client_id> export INTYGA_CLIENT_SECRET=<client_secret> export INTYGA_APPROVERS_FILE=./trust-anchor.json # exported from Approval Rules
2. Install the SDK
npm i @intyga/sdk @intyga/verify
3. Wrap the blast-radius call
import { readFileSync } from "node:fs";
import {
IntygaClient, parseTrustAnchorFile, trustAnchorApprovers, verifyApprovalReceipt,
} from "@intyga/sdk";
// Load once at startup β a trust anchor is security configuration, not per-request data.
// One file carries every approver's keys AND the console's WebAuthn expectations.
const anchor = parseTrustAnchorFile(readFileSync(process.env.INTYGA_APPROVERS_FILE!, "utf8"));
const intyga = new IntygaClient({
gatewayUrl: process.env.INTYGA_GATEWAY_URL!,
clientId: process.env.INTYGA_CLIENT_ID!,
clientSecret: process.env.INTYGA_CLIENT_SECRET!,
});
const action = {
target: "prod-db-cluster-01", // WHERE it runs β bound into the signature
actionType: "wipe_production",
params: { db: "prod-1" },
};
// Blocks until a human approves with their passkey / security key. Times out safely.
const r = await intyga.requireApproval("Delete production database prod-1", action);
if (r.status !== "APPROVED") throw new Error(`Not authorized: ${r.status}`);
// Prove β offline, no INTYGA secret β a human signed THIS exact instruction.
// nonce + approvers come from YOUR side; a receipt can't vouch for its own signer.
const check = verifyApprovalReceipt(r.receipt!, {
...action,
nonce: r.nonce!,
approvers: trustAnchorApprovers(anchor), // DID mode: quorum counts PEOPLE, not keys
}, {
expectedOrigin: anchor.webauthn?.origin,
expectedRpId: anchor.webauthn?.rpId,
});
if (!check.ok) throw new Error(`bad receipt: ${check.reason}`);
await reallyDropTheDatabase("prod-1"); // β
only nowUsing INTYGA with AI agents (MCP)? Expand this before you ship. The MCP result is not the gate β your own service is.
The MCP tools (verify_human_authorization, check_human_authorization) obtain an approval; they enforce nothing. APPROVED coming back from check_human_authorization is a fact about the gateway, reported to you by the agent. Everything the model says is on the untrusted side of the line, so a prompt-injected or simply buggy agent may:
- never ask for approval and call your service anyway β your service refuses any call that carries no receipt.
- invent an
APPROVEDresult or a receipt βverifyApprovalReceiptchecks against approver keys you pinned; a receipt cannot vouch for its own signer. - get approval for one thing, then do another β your service recomputes
target+actionType+paramsfrom the request it is about to execute; a one-byte difference fails. - replay a receipt a human really did sign last week β
expiresAtis enforced fail-closed, and your service records every redeemednonceuntil then. - present a receipt some other agent obtained β the requester is part of the signed bytes; assert the agent identity you expect.
So the pattern is untrusted agent β signed receipt β your enforcing service β side effect. The agent forwards the receipt and nonce on the call that does the irreversible thing; your service is the only place that can refuse, and it needs no INTYGA secret to do so.
The hosted MCP tools return the v1 receipt for your enforcing service. If you run the tool server, @intyga/mcp-sdk or @intyga/mcp-proxy can enforce it before the handler runs when configured with an RP-owned AgentV1Runtime. The gateway publishes instructions to cooperative models in InitializeResult.instructions. Your RP remains the security boundary.
// YOUR service β the one place that can actually refuse. No INTYGA secret, no gateway round-trip.
import { readFileSync } from "node:fs";
import { parseTrustAnchorFile, trustAnchorApprovers, verifyAgentForExecution } from "@intyga/sdk";
const anchor = parseTrustAnchorFile(readFileSync(process.env.INTYGA_APPROVERS_FILE!, "utf8"));
const approvers = trustAnchorApprovers(anchor);
const TARGET = "prod-db-cluster-01"; // THIS service, by the name approvals are bound to
app.post("/admin/drop-database", async (req, res) => {
const { receipt, nonce, database } = req.body; // treat all agent-supplied fields as untrusted
if (!receipt || !nonce) return res.status(403).end(); // no receipt, no action β never "check later"
// Recompute the action from what YOU are about to do β never from what the agent says was approved.
const action = { target: TARGET, actionType: "wipe_production", params: { database } };
// These are YOUR protected records, retained when the request was created. Never copy expected
// context, configuration or the session head from the receipt or this incoming request.
const pending = await db.pendingApprovals.get(nonce);
if (!pending) return res.status(403).end();
await db.transaction(async (tx) => {
const state = await tx.agentSessions.lockState(pending.sessionId); // head, seq, aggregate
const check = verifyAgentForExecution(
receipt,
{ ...action, nonce, approvers, requesterDid: pending.agentDid,
agentContext: pending.agentContext },
await runtimeConfigFor(pending.agentDid), // live model, tools and prompt from YOUR runtime
state,
{ expectedOrigin: anchor.webauthn?.origin, expectedRpId: anchor.webauthn?.rpId,
agentAuthorityChain: pending.authorityChain },
);
if (!check.ok) throw new Error("approval refused: " + check.reason);
await tx.redeemedNonces.insert({ nonce, expiresAt: pending.expiresAt });
await tx.agentSessions.advance(pending.sessionId, check.nextHead,
pending.agentContext.session.seq, pending.agentContext.session.aggregate);
await tx.agentBudgets.reserve(pending.agentDid, pending.agentContext.action.amount);
await tx.dropDatabase(database);
});
return res.status(204).end();
});Why your service and not the agent's SDK call. In step 3 the code that asks for approval and the code that acts are the same process, so requireApproval + verifyApprovalReceipt there is enough. With an agent they are different processes, and only one of them is yours. Put the verify-and-redeem step in the process that performs the side effect, and leave nothing it can reach that skips it. The Go, Rust, Java and Python verifiers make the same target / params / nonce / approver / expiry checks; asserting requesterDid exists in the TypeScript and Python verifiers today, so a Go, Rust or Java service pins the requesting agent by its own auth on the call.
4. Approve it (no app required)
While requireApproval waits, the approver signs with a passkey or hardware security key in the browser (Touch ID, Windows Hello, YubiKey) β nothing to install. Open the pending request from its notification or the console's Challenges page. To try a separate request from a terminal instead, the CLI below creates its own challenge and opens that approval page; it does not approve the SDK request above:
npx @intyga/sdk authorize "Delete production database prod-1" \
--web https://app.intyga.com --target prod-db-cluster-01 \
--type wipe_production --params '{"db":"prod-1"}' --consumeDone. A leaked credential, a prompt-injected agent, or a fat-fingered script can no longer wipe prod on its own β and every approval is recorded in an append-only, Merkle-anchored ledger your auditor can verify against the signer's public key. Next: pick a recipe for your stack, or connect an AI agent over MCP.
Trust Architecture
INTYGA operates on a Witness Pattern. A person enrolls a passkey or security key; INTYGA stores its public key, never the approval private key. Synced passkeys are supported, while a rule can require a device-bound credential. A protected request carries exact parameters and a challenge nonce. The approver's authenticator signs, and the gateway verifies the signature and records the witness. Hosted authorization receives the action parameters; the platform plane receives only a payload digest. Published policy blobs are zero-knowledgeciphertext, while authoritative Approval Rules remain gateway-readable.
Recipes β gate a specific action
The primitive is always the same β requireApproval then verifyApprovalReceipt. Each recipe below differs only in the actionType, params, and the policy rule that governs it. The full runnable code lives once in /examples.
Two values are shared by all of them and both come from your side β TARGET, the environment about to execute, and TRUSTED_APPROVERS, the approver keys you resolved from your own key management. Neither is ever read from the receipt: a receipt checked against the key carried inside it proves only that the receipt is self-consistent.
// Shared by every recipe below. Both come from YOUR side, never from the receipt. const TARGET = "prod-db-cluster-01"; // this execution environment (Target Isolation) const anchor = parseTrustAnchorFile(readFileSync(process.env.INTYGA_APPROVERS_FILE!, "utf8")); const TRUSTED_APPROVERS = trustAnchorApprovers(anchor); // DID mode: quorum counts PEOPLE, not keys
ποΈ Production database delete
Never drop or truncate a prod database without a signed human approval β bind the target so an approval can't be reused for a different DB.
const action = { target: TARGET, actionType: "wipe_production", params: { db: "prod-1" } };
const r = await intyga.requireApproval("Delete production database prod-1", action);
if (r.status !== "APPROVED") throw new Error("blocked");
const check = verifyApprovalReceipt(r.receipt!, {
...action, nonce: r.nonce!, approvers: TRUSTED_APPROVERS,
}, {
expectedOrigin: anchor.webauthn?.origin,
expectedRpId: anchor.webauthn?.rpId,
});
if (!check.ok) throw new Error(`blocked: ${check.reason}`);
await dropDatabase("prod-1");
// Policy rule (deny outright, or require a hardware key):
{ "action": "wipe_production", "effect": "require_approval", "requireHardwareKey": true }π¦ Wire transfer over a limit
Protect transfers with an exact approval request. A local allow rule still goes through the gateway; an amount ceiling can require stricter handling above the limit.
const action = {
target: TARGET,
actionType: "wire_transfer",
params: { to: "ACME-123", amount: 50000, currency: "USD" },
};
const r = await intyga.requireApproval("Wire $50,000 to ACME-123", action);
if (r.status !== "APPROVED") throw new Error("blocked");
const check = verifyApprovalReceipt(r.receipt!, {
...action, nonce: r.nonce!, approvers: TRUSTED_APPROVERS,
}, {
expectedOrigin: anchor.webauthn?.origin,
expectedRpId: anchor.webauthn?.rpId,
});
if (!check.ok) throw new Error(`blocked: ${check.reason}`);
await sendWire(action.params);
// Local allow still goes through gateway authorization; the ceiling changes the local decision:
{ "action": "wire_transfer", "effect": "allow", "maxAmount": 5000, "currency": "USD" }π Credential / key rotation
Require a second person to approve rotating production secrets β four-eyes so the requester can't approve their own rotation.
const action = { target: TARGET, actionType: "rotate_credentials", params: { service: "aws-prod" } };
const r = await intyga.requireApproval("Rotate aws-prod credentials", action);
if (r.status !== "APPROVED") throw new Error("blocked");
const check = verifyApprovalReceipt(r.receipt!, {
...action, nonce: r.nonce!, approvers: TRUSTED_APPROVERS,
}, {
expectedOrigin: anchor.webauthn?.origin,
expectedRpId: anchor.webauthn?.rpId,
});
if (!check.ok) throw new Error(`blocked: ${check.reason}`);
await rotate("aws-prod");
// Policy rule: four-eyes (a DIFFERENT admin must approve):
{ "action": "rotate_credentials", "effect": "require_approval", "requesterCannotApprove": true }π Terraform / deploy (CI/CD)
Gate a production apply in your pipeline with the GitHub Action β the pipeline (a SERVICE identity) requests, a human approves. No app code changes.
# .github/workflows/deploy.yml
# NOT YET PUBLISHED β intended shape. Use the `intyga` CLI in a workflow step today.
- uses: intyga-dev/require-approval@v1
with:
action-type: terraform_apply
action-description: "terraform apply β prod workspace"
params: '{"workspace":"prod"}'
slack-webhook: ${{ secrets.INTYGA_SLACK_WEBHOOK }} # DM an Approve button
# See examples/ci-cd-github-action for the full workflow.βΈοΈ Kubernetes / infra change
Gate a risky kubectl apply or scale from a script: request, notify the approver's chat, then block until signed.
# Credentials from the quickstart; configure the approver's notifications in the console.
# Replace the hash placeholder with the SHA-256 of the exact manifest you will apply.
intyga authorize "kubectl apply -f prod-ingress.yaml" \
--target prod-k8s --type k8s_apply \
--params '{"cluster":"prod","kind":"Ingress","manifestSha256":"<sha256-of-exact-manifest>"}' \
--approvers-file ./trust-anchor.json --no-open --consume
# Apply only on exit 0, and only the unchanged manifest whose hash you requested.Model Context Protocol (MCP) API
AI agents connect over Streamable HTTP at /mcp with a Bearer agent token (from POST /oauth/token). Two tools verify the controller and request biometric sign-off. The agent's identity (agentDid) is taken from the token β never passed by the model β so it can't be spoofed or hallucinated.
Connect your agent
In the console, open My Agents β create an agent (this mints its DID and links you as its owner) β mint its agent API key. Exchange it for a token, then point a Streamable HTTP MCP client at the gateway:
TOKEN=$(curl -s -u "$CLIENT_ID:$CLIENT_SECRET" -X POST https://api.intyga.com/oauth/token | jq -r .access_token) # Manual test in Codex; the client must inherit this environment variable: export INTYGA_MCP_TOKEN="$TOKEN" codex mcp add intyga --url https://api.intyga.com/mcp \ --bearer-token-env-var INTYGA_MCP_TOKEN
Or in an mcp.json-style client configuration:
{
"mcpServers": {
"intyga": {
"type": "streamable-http",
"url": "https://api.intyga.com/mcp",
"headers": { "Authorization": "Bearer <token>" }
}
}
}The exchanged token expires after 15 minutes. A persistent client must refresh it; a static token in either example is for a short manual test. See the public agent setup instructions for the complete v1 connection and receipt requirements. The executing service must prepare and retain agentContext from its own state before an AI-agent request.
Usage pattern: the agent calls verify_human_authorization (keeps the nonce), then polls check_human_authorization every ~2s until the status leaves PENDING, and finally verifies the returned receipt before executing the tool. Challenges expire after 120s by default, so an ignored request fails safe. You approve with your passkey on the console Challenges page.
verify_human_authorization
MCP TOOLCreates a signing challenge bound to the action and notifies the human owner to approve it with their registered passkey. Returns immediately with the challenge nonce and a PENDING status.
Arguments Schema:
{
"action": "string, required (e.g. 'wire_transfer')",
"target": "string, required β the execution environment that will perform it
(e.g. 'prod-db-cluster-01'). Bound into the signature so an approval
cannot be replayed against a different target (DIV Target Isolation).",
"actionDescription": "string, required (e.g. 'Wire $50,000 to ACME Corp')",
"params": "object, required (e.g. {\"to\": \"ACME-123\", \"amount\": 50000})
β displayed key-by-key to the human AND bound to their signature.
This is what prevents blind-signing.",
"amount": "number (optional, also folded into params)",
"currency": "string (optional, 3-letter ISO)",
"agentContext": {
"action": { "reversibility": "irreversible", "amount": null },
"configDigest": "sha256:<64 lowercase hex from live model, tools and prompt>",
"delegatedBy": null,
"session": {
"id": "sha256:<64 lowercase hex from random opaque session ID>",
"seq": "1", "prev": null, "aggregate": null
}
}
}Note that action here is the wire name for what the SDK calls actionType β same field, and it is the string your server-side Approval Rules match on. agentContext is required for an AI_AGENT key. The values shown above are placeholders, not valid digests: your executing service must compute them from its own runtime and durable session state. Monetary actions use decimal strings for both action.amount and session.aggregate. Keep raw prompts and personal data out of these new receipt fields.
Response Schema:
{
"nonce": "string (UUID challenge token)",
"status": "PENDING" | "APPROVED",
"agentContext": "issuer-completed v1 context for AI_AGENT | undefined"
}check_human_authorization
MCP TOOLPolls the gateway for the signature status of a previously dispatched authorization challenge nonce.
Arguments Schema:
{
"nonce": "string (UUID challenge token)"
}Response Schema:
{
"status": "APPROVED" | "PENDING" | "DENIED" | "EXPIRED" | "CONSUMED",
"signatureHash": "string (hash of the approval signature) | undefined",
"receipt": "object | undefined β present once APPROVED or CONSUMED"
}CONSUMED means the approval was real but has already been redeemed. It is reported separately from APPROVED on purpose: a spent approval is still the forensic record of what was authorized, but it is no longer permission to act. Treat only APPROVED as a go.
Then verify it yourself β do not stop at APPROVED
A status field is the gateway's word for it. The receipt in that response is the part you can check without us, and the code that executes the tool call should be the thing that checks it. For an AI agent, verifyAgentForExecution from @intyga/sdk compares the signed receipt with independently retained context, the live model, tools and prompt, and a locked session head. The digest is an RP assertion; it protects against drift only when your service recomputes and enforces it.
// YOUR service β the one place that can actually refuse. No INTYGA secret, no gateway round-trip.
import { readFileSync } from "node:fs";
import { parseTrustAnchorFile, trustAnchorApprovers, verifyAgentForExecution } from "@intyga/sdk";
const anchor = parseTrustAnchorFile(readFileSync(process.env.INTYGA_APPROVERS_FILE!, "utf8"));
const approvers = trustAnchorApprovers(anchor);
const TARGET = "prod-db-cluster-01"; // THIS service, by the name approvals are bound to
app.post("/admin/drop-database", async (req, res) => {
const { receipt, nonce, database } = req.body; // treat all agent-supplied fields as untrusted
if (!receipt || !nonce) return res.status(403).end(); // no receipt, no action β never "check later"
// Recompute the action from what YOU are about to do β never from what the agent says was approved.
const action = { target: TARGET, actionType: "wipe_production", params: { database } };
// These are YOUR protected records, retained when the request was created. Never copy expected
// context, configuration or the session head from the receipt or this incoming request.
const pending = await db.pendingApprovals.get(nonce);
if (!pending) return res.status(403).end();
await db.transaction(async (tx) => {
const state = await tx.agentSessions.lockState(pending.sessionId); // head, seq, aggregate
const check = verifyAgentForExecution(
receipt,
{ ...action, nonce, approvers, requesterDid: pending.agentDid,
agentContext: pending.agentContext },
await runtimeConfigFor(pending.agentDid), // live model, tools and prompt from YOUR runtime
state,
{ expectedOrigin: anchor.webauthn?.origin, expectedRpId: anchor.webauthn?.rpId,
agentAuthorityChain: pending.authorityChain },
);
if (!check.ok) throw new Error("approval refused: " + check.reason);
await tx.redeemedNonces.insert({ nonce, expiresAt: pending.expiresAt });
await tx.agentSessions.advance(pending.sessionId, check.nextHead,
pending.agentContext.session.seq, pending.agentContext.session.aggregate);
await tx.agentBudgets.reserve(pending.agentDid, pending.agentContext.action.amount);
await tx.dropDatabase(database);
});
return res.status(204).end();
});Your service must derive the expected action from the operation it will actually perform, resolve approver keys from its own trust anchor, check any parent authority receipt chain, and atomically reserve the nonce, session head and budget before the side effect. A gap, fork, changed aggregate, expired receipt or changed agent configuration refuses execution. The wrapper and proxy perform these checks at their tool boundary when configured with a trusted v1 runtime. They refuse an AI-agent request if the runtime is absent.
Integrating Platforms β embed INTYGA for YOUR customers
A service with its own end users can embed the approval primitive white-label: your customers never hold an INTYGA account, never see an INTYGA page, and every passkey ceremony runs on your own domain, under your own brand. INTYGA receives a digest of your payload β never the payload. Your requests may carry financial, personal or payroll data; sending us plaintext would make us a data processor for your entire customer base while adding nothing to verification, since you already hold the payload. So the receipt binds a SHA-256, and the human-readable rendering stays yours. The spec for this plane is DIV Β§5c.
You provision subjects β your users, named by an opaque externalId you choose. Binding a subject to a legal person is your claim: you can attach identity-assurance metadata at creation (how you verified the person), and it is recorded and witnessed verbatim, never validated or asserted by INTYGA.
1 Β· Register your Relying Party and mint a platform key
In the console under Platform RPs, register the domain your ceremonies run on. The RP ID must be the origin host or a parent of it β WebAuthn itself enforces this in the browser, so the console refuses early what the ceremony would refuse late. Rule of thumb: pick the common parent domain and list every origin under it.
RP ID: acme.com # NOT www.acme.com β pick the parent
Origins: https://www.acme.com
https://app.acme.com # every page that runs a ceremony, exactlyA passkey is cryptographically scoped to the RP ID it was created under. Changing the RP ID later means re-enrolling every subject. Settle it before anything real enrolls. Revoking an RP config refuses new and in-flight ceremonies; credentials stay bound to their RP ID and already-issued receipts stay valid.
For local development, register the RP in test mode: everything stays fully real and fully witnessed, but its receipts are never billed and stay out of Protected-Op usage. localhost is a valid test RP (WebAuthn treats it as a secure context), so the whole loop runs on a laptop before any real domain exists. Because a test RP is unbilled it is loopback-only (localhost, 127.0.0.0/8, or::1). Reserved and private-DNS names can serve real users, so shared staging RPs are registered live. Nothing else about the integration changes.
Then mint a company API key carrying the platform scopes (subjects:write, credentials:write, receipts:create, receipts:read). A platform key has no working static secret: it authenticates only with a signed assertion (private_key_jwt, RFC 7523), so a leaked config string is not a credential. Generate a P-256 keypair, paste the public half at key creation, keep the private key where your integration signs:
openssl ecparam -name prime256v1 -genkey -noout -out platform-key.pem openssl ec -in platform-key.pem -pubout -outform DER | base64 -w0 # paste this into the console
2 Β· Enroll subjects β the ceremony runs on YOUR page
Your backend asks for registration options, your page runs the WebAuthn ceremony, your backend relays the result. The subject being bound is fixed server-side when the ceremony begins β the relay carries an attestation, never an identity to trust.
import { IntygaPlatformClient } from "@intyga/sdk"
const intyga = new IntygaPlatformClient({
gatewayUrl, clientId, privateKey: fs.readFileSync("platform-key.pem", "utf8"),
})
// Your user id, your identity-assurance claim (recorded verbatim, witnessed, never validated):
await intyga.createSubject("user-4711", { method: "bankid", level: "substantial" })
const options = await intyga.beginEnrollment("user-4711", "https://www.acme.com")
// β hand options to your page: navigator.credentials.create({ publicKey: options })
const { subject } = await intyga.completeEnrollment("user-4711", browserResponse)
// subject.did is now self-certifying β derived from the enrolled key itself3 Β· Request a signature, verify the receipt yourself
Canonicalize your payload once, hash it, and bind the ceremony to the digest. What the user sees is rendered by you, from that same serialization.
The receipt proves this key signed this digest at this time on this RP. It cannot prove what your UI showed β that guarantee is now yours to keep. Render your approval screen from the exact canonical serialization you hash, and execute from it too. If you rebuild the payload between approval and execution, the receipt proves nothing about what ran.
const payload = { invoice: "INV-1042", amount: 1234.5, currency: "EUR" }
const challenge = await intyga.requestSignature({
externalId: "user-4711",
payload,
origin: "https://www.acme.com",
})
// β your page: navigator.credentials.get({ publicKey: challenge.options })
const { receipt, ledger } = await intyga.completeSignature(challenge.nonce, browserAssertion)
// Verify OFFLINE β your digest, your RP, your trust anchor. No call to INTYGA:
const verdict = intyga.verifyReceipt(receipt, {
approvers: { publicKeys: [subjectPublicKey] }, // from your own enrollment record
payloadHash: hashPayload(payload), // recomputed from YOUR copy
rpId: "acme.com",
nonce: challenge.nonce,
}, { expectedOrigin: "https://www.acme.com" })
if (!verdict.ok) throw new Error(verdict.reason)
// Later: the receipt's tamper-evident inclusion proof, by its ledger position
const proof = await intyga.getReceiptProof(challenge.nonce) // 409 until the ledger sealsEvery completed signature is one Protected Op and one PLATFORM_RECEIPT_ISSUED entry in your audit trail β filterable, carrying the subject, the signed bytes, the signature and the signing key, so each one is independently verifiable with no INTYGA secret. INTYGA never refuses an approval because you crossed a plan-volume allowance, and Protected-Op volume is not billed. Offline verification of platform receipts ships in @intyga/verify (TypeScript) today; the Go, Rust, Python and Java verifiers refuse this payload type until they implement DIV Β§5c β stated in their READMEs.
INTYGA Integration Stack
The @intyga/sdk library and its intyga CLI wrap the gateway for every use case β agents, humans, and backend services. The standalone @intyga/verify package confirms approvals offline (no INTYGA secret), and off-platform zero-knowledge policy encryption means INTYGA never sees your policy plaintext. A GitHub Action that gates CI/CD with no app code changes is designed but not yet published β gate a workflow today with an intyga CLI step, which is what the Action will wrap.
@intyga/sdk (Library)
One SDK for authorization and offline verification. The example below uses a human or SERVICE (pipeline) key. An AI_AGENT key additionally requires the v1 agentContext on the request and verifyAgentForExecution at the execution boundary; see the MCP section above. Verification is re-exported from the standalone @intyga/verify package: recompute the canonical payload from YOUR params, confirm it byte-matches what was signed, and check the human's signature β offline, no INTYGA secret.
For a tool server, @intyga/mcp-sdk accepts an agentV1 runtime. For a third-party stdio server, launch @intyga/mcp-proxy --agent-v1-module ./rp-agent-v1.mjs. Both send the v1 agent context, verify the receipt, and require your RP's atomic nonce/session/budget reservation before execution. Supply live model, tool and prompt data plus trusted approver keys from your own environment; the agent cannot attest these for itself. See each package README for the runtime interface.
import { IntygaClient, verifyApprovalReceipt } from "@intyga/sdk";
const intyga = new IntygaClient({
gatewayUrl: "https://api.intyga.com",
clientId: process.env.INTYGA_CLIENT_ID, // human or SERVICE key in this example
clientSecret: process.env.INTYGA_CLIENT_SECRET,
});
const action = {
target: "prod-db-cluster-01", // WHERE it runs β bound into the signature
actionType: "wipe_production",
params: { db: "prod-1" },
};
// Blocks until the human approves with their passkey / security key (or times out):
const r = await intyga.requireApproval("Delete production database", action);
if (r.status !== "APPROVED") throw new Error("not authorized");
// Prove β offline, no network, no INTYGA secret β that a human signed THIS exact instruction.
// nonce and approvers come from YOUR side: a receipt cannot vouch for its own signer.
const check = verifyApprovalReceipt(r.receipt!, {
...action,
nonce: r.nonce!,
approvers: { publicKeys: TRUSTED_APPROVER_KEYS },
}, {
expectedOrigin: anchor.webauthn?.origin,
expectedRpId: anchor.webauthn?.rpId,
});
if (!check.ok) throw new Error(`receipt failed to verify: ${check.reason}`);
// ...safe to proceed.@intyga/verify (open-source, offline)
NO NPM DEPENDENCIESA standalone library with no npm runtime dependencies (re-exported by @intyga/sdk) that lets a relying party confirm an approval with no INTYGA secret: recompute the canonical payload from YOUR params, check it byte-matches what was signed, and verify the human's ES256 or WebAuthn signature against a key you already trusted. Inspect the verifier source. A policy AUTO_APPROVEDreceipt carries no human signature, so it is refused by default (opt in with allowAutoApproved: true) β ok: true otherwise always means a real human signed. AI-agent receipts also require independently retained agent context; use the v1 execution pattern above for their live configuration and session checks.
RFC 3161 timestamp verification additionally requires OpenSSL 3 and caller-configured TSA trust, signer certificate pins and revocation policy. All five verifier languages support this optional path. The CLI accepts audit-verify --tsa-trust tsa-trust.json, alongside the trusted issuer list, required quorum and Rekor log key. A verified timestamp establishes that the checkpoint commitment existed by the TSA time, not when its underlying action occurred.
import { verifyApprovalReceipt } from "@intyga/verify";
const check = verifyApprovalReceipt(receipt, {
target: "prod-db-cluster-01", // YOUR execution environment (Target Isolation)
actionType: "wipe_production",
params: { db: "prod-1" }, // what you're ACTUALLY about to do
nonce, // the challenge YOU issued
approvers: { publicKeys: TRUSTED_APPROVER_KEYS }, // YOUR trust anchor β never the receipt's
}, {
expectedOrigin: anchor.webauthn?.origin,
expectedRpId: anchor.webauthn?.rpId,
});
if (!check.ok) throw new Error(`refusing to proceed: ${check.reason}`);
// β
a human you already trust signed off on THIS exact instructionintyga (CLI)
The @intyga/sdk ships a CLI for scripts, CI, and off-platform zero-knowledge policy encryption. When triggering authorization from a developer CLI, it opens the configured console's approval page in your browser. The passkey ceremony runs there, not on a separate CLI loopback server. Set INTYGA_APP_URL or --webto your console URL; use --no-open on a headless runner.
The binary is intyga, but it ships inside @intyga/sdk: run it as npx @intyga/sdk <command>, or npm i -g @intyga/sdk once and then call intyga directly as shown below. (npx intyga would resolve to an unrelated package.)
# Require approval and verify against your exported approver trust anchor:
intyga authorize "Rotate production credentials" \
--gateway https://api.intyga.com \
--client-id "<id>" --client-secret "<secret>" \
--target prod-aws-account --type "rotate-keys" --params '{"service":"aws"}' \
--approvers-file ./trust-anchor.json --consume
# Zero-knowledge policy tooling (keys never leave your machine):
intyga keygen --out org
intyga policy-encrypt policy.json --pubkey org.public.key --out blob.jsonCI/CD (headless): split the flow so the approver is notified where they already are. intyga authorize --no-wait creates the challenge and returns the approval deep-link immediately; intyga notify --slack <webhook> posts an interactive Approvebutton; intyga await <nonce> --consume blocks until it's signed and verifies the receipt. Pass the same --target, --type and--params to both authorization commands, and configure trusted approvers as above. The repository's examples/ci-cd-github-action shows the composition; the reusable GitHub Action is not published yet.
GitHub Action (require-approval)
CI/CDGate a deploy on a signed human approval with no application code changes. The step authenticates as a SERVICE identity (it requests; a human approves β separation of duties), optionally DMs the approver a Slack/Teams Approve button, then blocks until the receipt verifies. A non-zero exit fails the job, so any needs:-dependent deploy can't run.
# NOT YET PUBLISHED β intended shape. Use the intyga CLI step today.
- uses: intyga-dev/require-approval@v1
with:
gateway-url: ${{ secrets.INTYGA_GATEWAY_URL }}
web-url: ${{ secrets.INTYGA_WEB_URL }}
client-id: ${{ secrets.INTYGA_CLIENT_ID }} # a SERVICE key
client-secret: ${{ secrets.INTYGA_CLIENT_SECRET }}
action-type: deploy_production
action-description: "Deploy ${{ github.sha }} to production"
params: '{"sha":"${{ github.sha }}"}'
slack-webhook: ${{ secrets.INTYGA_SLACK_WEBHOOK }} # optional: interactive Approve buttonApproval Rules (server-side governance)
Everything on this page that decides whether a human must sign β the quorum, four-eyes, hardware-key class, escalation β is an Approval Rule, evaluated by the gateway. A client can neither author a rule nor pass a threshold: there is no requiredApprovals argument. A ZK policy manifest travels with your agent and may only ever make governance stricter; an Approval Rule is authoritative. What a caller does supply is the action type, which selects an exact rule. See Matching below for what happens when no exact rule exists.
Matching
The * rule is the tenant baseline. Another rule matches only when its action ID exactly equals actionType; display text never selects it. The specific rule must preserve every baseline requirement, or the request is refused. Rules live per tenant and are configured in the console (each change is itself a step-up signed mutation, so the governance config has the same audit trail as the actions it governs).
When no exact rule matches, new workspaces deny the request. Administrators may explicitly choose baseline approval for unknown action IDs. If the baseline requires hardware, a specific rule also has to require hardware, whatever its quorum. Migrated workspaces retain recorded legacy behavior until an administrator activates exact matching. Preview the effective requirements on Approval Rules before activation. Offline consumers need a fresh v1 trust bundle and a compatible SDK.
Constraints
- requiredApprovals (default
2): distinct valid signatures needed before the challenge flips toAPPROVED. M-of-N multi-signature. - approverDids / approverGroupIds: who is eligible. Groups are expanded into a snapshot when the challenge is created β editing a group never affects an in-flight challenge (fail-closed).
- requesterCannotApprove: four-eyes. The approver must be a different identity than the requester, so nobody ratifies their own request.
- requireHardwareKey / allowedAaguids: the approving credential must be a device-bound (non-synced) authenticator, optionally narrowed by AAGUID to a specific model β e.g. a corporate YubiKey fleet. Synced passkeys are rejected.
- requireAttestedRequester / allowedIssuers: the machine-side mirror. The requesting workload must have proved its identity by third-party attestation (OIDC / SPIFFE). A plain client-credentials agent never satisfies it, and neither does DPoP β key possession is self-asserted, not provenance.
- escalateAfterSeconds + escalationApproverDids / escalationGroupIds: a still-pending challenge adds approvers and re-notifies. Escalation only ever widens who may sign β it never lowers the quorum, bypasses four-eyes, or extends expiry.
- autoApproveWindow*: pre-approval windows (start, end, day of week, and the specific requester DID it applies to). Actions approved this way produce a receipt with
sigAlg: "AUTO_APPROVED"and no human signature β which is exactly whyverifyApprovalReceiptrefuses those by default. This is a policy convenience, not a break-glass mechanism: for approving actions while INTYGA is unreachable, see Offline Approval.
Enforcement timing
Hardware-class and attestation constraints are enforced at signing time, not just at request time β so a legitimate approver cannot unknowingly ratify an action requested by an unattested agent holding a leaked key. Pre-approval windows never bypass them.
Public Gateway API
Anyone can check a INTYGA approval without an account. Note the ordering below: the offline path is the primary one. An endpoint you have to call is a weaker guarantee than math you can run yourself, so the API here is a convenience β never the thing the witness property depends on.
1. Offline β no network, no account (recommended)
verifyApprovalReceipt from @intyga/verify re-derives the canonical payload from your parameters and checks the signature locally. This is the guarantee: it keeps working if INTYGA is down, and it keeps working if INTYGA no longer exists.
2. Ledger inclusion β against an independently trusted root
A receipt proves an approval happened. An inclusion proof checks that an event was committed under a particular root; by itself it does not prove the whole log is complete. Export the proof from Audit and compare it against a root obtained through your independently trusted checkpoint channel. The file below is your retained root list, not a public repository assumed to exist. Gapless tenant-sequence checks concern committed events only; they cannot expose an event withheld before commitment.
npx @intyga/sdk audit-verify ./intyga-audit-proof-seq-1234.json \ --roots roots/roots.jsonl # exit 0 = verified, 1 = not verified