Skip to content

CI/CD and infrastructure automation

Put a human signature between your pipeline and production.

A protected branch answers who may merge. An environment approval answers who may click a button in a UI you do not control, and leaves a record only that platform can vouch for. INTYGA answers something narrower and more useful: whether a named person signed this exact apply, for this target, with these parameters, immediately before it ran — and leaves a receipt anyone can verify without asking us anything.

The integration point

Call INTYGA directly before the command that changes production. The pipeline has its own service identity and requests; the human is the approver. The two are different identities on purpose — separation of duties is a property of the key material, not of a job name.

  1. Construct the target, action type, and exact parameters your pipeline will execute.
  2. Request a human approval and wait for the signed result.
  3. Re-verify that result locally against approver keys your pipeline already trusts, then execute once.

Step three is the one that matters. The receipt is checked in your runner, against parameters your runner derived and public keys your runner already trusted — not against anything the response claims about itself. If the plan, the workspace or the commit changed between the request and the apply, the recomputed payload no longer matches the signed bytes and the job fails instead of deploying.

The INTYGA credential in your CI secrets is a requester credential. Harvesting it lets an attacker ask for an approval. It does not let them grant one, and it does not produce a receipt that verifies.

const action = {
  target: DEPLOY_TARGET,   // this pipeline — so the approval can't be replayed against another
  actionType: "terraform_apply",
  params: { workspace: "prod", change: process.env.GIT_SHA }
};

const approval = await intyga.requireApproval("Terraform apply — prod", action);
if (approval.status !== "APPROVED") throw new Error("deployment not approved");

// nonce and approvers come from YOUR side — a receipt cannot vouch for its own signer.
const check = verifyApprovalReceipt(approval.receipt!, {
  ...action, nonce: approval.nonce!, approvers: TRUSTED_APPROVERS,
}, {
  expectedOrigin: process.env.INTYGA_WEBAUTHN_ORIGIN!,
  expectedRpId: process.env.INTYGA_WEBAUTHN_RP_ID!,
});
if (!check.ok) throw new Error("receipt mismatch: " + check.reason);

await terraformApply("prod");

Or gate a workflow with no application code at all

The intyga CLI ships inside @intyga/sdk and splits the flow into three steps, so a headless runner never blocks on a browser it cannot open. Create the challenge, notify the approver where they already are, then block on the signature and verify the receipt.

  1. authorize --no-wait creates the challenge and returns the nonce and approval deep-link immediately — written to $GITHUB_OUTPUT for the next step.
  2. notify posts an interactive Approve button to Slack or Teams. The message carries a link, never the action parameters.
  3. await --consume blocks until it is signed, verifies the receipt, and redeems the approval single-use. A non-zero exit fails the job, so every needs:-dependent deploy step is skipped.

A prebuilt require-approval GitHub Action that wraps these three steps is designed but not yet published — the CLI step below is what it will wrap, and what to use today.

# .github/workflows/deploy.yml
jobs:
  gate:
    runs-on: ubuntu-latest
    outputs:
      nonce: ${{ steps.request.outputs.nonce }}
    steps:
      - id: request
        run: |
          npx @intyga/sdk authorize "Deploy ${GITHUB_SHA::7} to prod" \
            --gateway "$INTYGA_GATEWAY_URL" \
            --target prod-eu-1 \
            --type deploy_production \
            --params "{\"sha\":\"$GITHUB_SHA\"}" \
            --no-wait --no-open
        env:
          INTYGA_CLIENT_ID:     ${{ secrets.INTYGA_CLIENT_ID }}      # a SERVICE key
          INTYGA_CLIENT_SECRET: ${{ secrets.INTYGA_CLIENT_SECRET }}

      - run: |
          npx @intyga/sdk notify \
            --url "${{ steps.request.outputs.approval_url }}" \
            --context "Deploy ${GITHUB_SHA::7} to prod" \
            --slack "$SLACK_WEBHOOK"

      - run: |
          npx @intyga/sdk await "${{ steps.request.outputs.nonce }}" \
            --gateway "$INTYGA_GATEWAY_URL" \
            --target prod-eu-1 \
            --type deploy_production \
            --params "{\"sha\":\"$GITHUB_SHA\"}" \
            --timeout 900 --consume

  deploy:
    needs: gate          # unreachable unless the receipt verified
    runs-on: ubuntu-latest
    steps:
      - run: ./deploy.sh

Where the security boundary actually is

The security boundary is not the API call to INTYGA. It is the executor that refuses to obtain deployment authority unless a receipt over its own recomputed parameters verifies against keys it already trusted. A gate the same compromised change can edit or delete is not an authorization boundary — it is a comment.

This is the pattern we run on ourselves: INTYGA's own production deploys and migrations take a commit SHA, refuse anything that is not an ancestor of main, and block on a human approval bound to that SHA. How we run this ourselves has the detail, including what the break-glass path is when our own gateway is the thing that is down.

What to gate first

Terraform applies

Bind workspace, plan hash, commit, and target account before the apply.

Production deployments

Require sign-off for a particular release, region, and service—not a generic “deploy” permission.

Schema migrations

Hold destructive or irreversible migrations while a person sees the intended production impact.

Package publishes

Bind name, version, and tarball hash to a maintainer's signature — a harvested registry token alone cannot complete a release.

Environment promotions

A staging approval should not be spendable against production. The target is part of the signed payload, so it cannot be.

Runner and workflow changes

Self-hosted runner registration, OIDC trust policy edits, and org-level secret changes — the steps that quietly widen everything downstream.

Publishing has one extra subtlety the others share in milder form: the gate must live outside the repository the attacker can change, or the same poisoned commit can remove it. The package-publishing use case shows that architecture in full, and the ChainDrop analysis shows why it matters.

What the approver actually sees

Not text the pipeline wrote into a chat message. The notification carries an opaque reference and a kind — no commit message, no parameters, no PII. The approver's browser fetches the authoritative action from the gateway over TLS and displays the parameters key by key. Those canonical bytes are what the passkey signs.

This is what makes the signature mean something specific. A pipeline cannot describe a production apply as a staging one in the notification and have the human sign the description — the human signs the fetched payload, and your runner recomputes that same payload from what it is about to execute.

{
  "target": "prod-eu-1",
  "actionType": "terraform_apply",
  "display": "Terraform apply — prod",
  "params": {
    "workspace": "prod",
    "change": "a18ef92d4f...",
    "planHash": "sha256:6b1f…",
    "account": "acme-prod-eu"
  },
  "nonce": "single-use challenge",
  "expiresAt": "short approval window"
}

Governance the pipeline cannot request its way out of

Whether a human must sign, how many, and with what class of key are server-side approval rules — evaluated by the gateway and frozen onto the challenge when it is created. A caller can request an authorization. It cannot ask for less of one, and a compromised runner cannot lower its own threshold.

  • M-of-N quorum — distinct valid signatures before the challenge flips to approved.
  • Four-eyes — the approver must be a different identity than the requester.
  • Hardware-key class — device-bound authenticators only, optionally narrowed by AAGUID to a specific model. Synced passkeys are rejected.
  • Attested requester — the requesting workload must have proved its identity by third-party attestation (OIDC / SPIFFE), so a leaked client-credentials key is not enough to raise the request at all.
  • Escalation — a still-pending challenge adds approvers and re-notifies after a timeout. It only ever widens who may sign; it never lowers the quorum or extends expiry.

Hardware-class and attestation constraints are enforced at signing time, not only at request time — so an approver cannot unknowingly ratify an action raised by an unattested runner holding a leaked key.

What this breaks, and what it does not

Broken: credential-only deployment

Possession of a CI token, a cloud role, or the whole runner filesystem stops being sufficient authority to change production. An attacker with all of it can raise an approval request; the request then sits in front of a person, and expires or is denied unless someone signs it with an enrolled authenticator. Replay is broken too: the approval binds target, action type and parameters, so a signature for a staging apply cannot be spent on production, and a redeemed approval cannot be spent twice.

Not broken: a bad change that a human approves

The gate proves a person decided. It does not read the diff, evaluate the Terraform plan, or know whether the migration is safe. Keep code review, plan review and staged rollouts — this composes with them. And gate deliberately: an approver who is asked forty times a day stops reading, which is the failure mode this control exists to avoid.

When the gate cannot be reached

Approvals fail closed, which means INTYGA is a dependency in the path of every action you gate. That is a real operational cost and worth stating plainly rather than reassuring you out of it: if the gateway is unreachable, the deploy does not proceed.

The break-glass path is Offline Approval — your relying party generates the nonce and builds the challenge itself, approvers sign it on a disconnected device, and your runtime verifies the result with the same check it always runs. INTYGA is not in the loop. It is a procedure you operate and rehearse, not a switch we flip. The SDK returns it as a distinct OFFLINE_APPROVED status rather than APPROVED, so adding the fallback to an existing pipeline has to be a conscious change at the call site.

Read the availability and Offline Approval procedure →

The evidence a gated pipeline leaves

A receipt, not a log line

Each approval produces a signed receipt over the canonical payload. Verify it with @intyga/verify — open source, minimal dependencies, no INTYGA secret required.

Verifiable by someone who distrusts us

An auditor recomputes the payload from your parameters and checks the signature against the approver's public key. Nothing in that check calls INTYGA.

A tamper-evident witness log

Approvals, denials and rule changes are appended to a hash-linked ledger with periodic checkpoints, so committed entries cannot be quietly edited after the fact.

Scope that claim honestly: the log proves committed entries were not altered. It cannot prove an event was never withheld from commitment in the first place. See the security architecture for how the chain is anchored.

This pattern extends beyond CI/CD

The same architecture applies anywhere a machine performs an irreversible action on behalf of a human: registry publishes, privileged infrastructure and database operations, payments and treasury movements, and AI agent tool calls. The security property is the same in each: possession of a credential stops being sufficient authority, and a human decision becomes part of the execution path.

Start narrow, then add governance.

Prove the flow on one destructive deployment. When it works, add four-eyes, M-of-N approvers, and hardware-key requirements through approval rules. Approvals are not metered, so the Developer tier gates as many deployments as you ship, at no cost — what the paid tiers add is seats, a longer evidence-retention window, and quorum rules.

See the security architecture →