Credentials are harvestable
ChainDrop collected npm tokens, GitHub credentials, and cloud keys from every CI environment it infected. Anything a machine can read, a worm running on that machine can read.
Package publishing and registries
The ChainDrop worm republished itself into 433 npm packages in roughly four hours using stolen maintainer credentials — every release built by a legitimate workflow, with valid provenance. Nothing in that loop required a person. A publish gated on a fresh passkey signature over the exact repository, commit, registry, package, version, and artifact digest is the step a worm cannot complete autonomously.
Split the process across two independently administered trust boundaries. The repository-controlled build job produces an artifact without publishing authority and requests approval for its exact release metadata. A separately managed release workflow, pinned to reviewed code, retrieves the artifact, recomputes its digest, verifies the human approval locally, and only then obtains short-lived authority to publish.
The INTYGA key in your CI secrets is a requester credential. Harvesting it gives an attacker the ability to request approval, not the authority to grant approval or enter the protected publish path.
# Repository-controlled workflow — .github/workflows/release.yml
jobs:
build: # untrusted — no publishing authority
runs-on: ubuntu-latest
outputs:
approval-id: ${{ steps.approval.outputs.approval-id }}
steps:
- uses: actions/checkout@<FULL_COMMIT_SHA>
- run: npm ci
- run: npm pack --pack-destination dist
- uses: actions/upload-artifact@<FULL_COMMIT_SHA>
with:
name: release-artifact
path: dist/*.tgz
if-no-files-found: error
- id: approval
run: node request-publish-approval.mjs dist/*.tgz
env:
INTYGA_API_KEY: ${{ secrets.INTYGA_REQUESTER_KEY }}
release:
needs: build
# Separately administered, pinned to immutable reviewed code.
uses: acme/security-workflows/.github/workflows/npm-release.yml@<FULL_COMMIT_SHA>
with:
artifact-name: release-artifact
approval-id: ${{ needs.build.outputs.approval-id }}
secrets: inheritThe release workflow lives where the package repository cannot modify it: a separate security or platform repository, or a centrally managed reusable workflow — referenced by an immutable commit SHA, never a mutable branch or tag. Its trusted maintainer identities come from protected configuration, and it prefers short-lived or federated publishing authority over a long-lived registry token.
# acme/security-workflows — npm-release.yml (not editable from the package repo)
jobs:
release:
runs-on: ubuntu-latest
environment: npm-release
permissions:
contents: read
id-token: write
steps:
- uses: actions/download-artifact@<FULL_COMMIT_SHA>
with:
name: ${{ inputs.artifact-name }}
path: dist
- run: node recompute-and-verify-release.mjs dist/*.tgz
env:
INTYGA_APPROVAL_ID: ${{ inputs.approval-id }}
INTYGA_WEBAUTHN_ORIGIN: ${{ vars.INTYGA_WEBAUTHN_ORIGIN }}
INTYGA_WEBAUTHN_RP_ID: ${{ vars.INTYGA_WEBAUTHN_RP_ID }}
- run: npm publish dist/*.tgz --provenance
// recompute-and-verify-release.mjs
const action = {
target: "npmjs:@acme/api-client",
actionType: "package_publish",
params: {
repository: "https://github.com/acme/api-client",
commit: "a18ef92d4f...",
registry: "https://registry.npmjs.org",
name: "@acme/api-client", version: "4.2.0",
artifactDigest: { algorithm: "sha512", value: TARBALL_SHA512 },
},
};
// never trust the build job's digest — hash the bytes actually received
const actualDigest = await sha512File(artifactPath);
if (actualDigest !== action.params.artifactDigest.value)
throw new Error("artifact digest does not match the approved release");
// nonce and approvers come from protected configuration —
// a receipt cannot vouch for its own signer.
const check = verifyApprovalReceipt(receipt, {
...action, nonce, approvers: TRUSTED_MAINTAINERS,
}, {
expectedOrigin: process.env.INTYGA_WEBAUTHN_ORIGIN!,
expectedRpId: process.env.INTYGA_WEBAUTHN_RP_ID!,
});
if (!check.ok) throw new Error("receipt mismatch: " + check.reason);
// npm publish runs as the next step — authority exists only hereThe security boundary is not the API call to INTYGA. The security boundary is the separately administered release environment that refuses to obtain publishing authority unless the exact approval verifies.
ChainDrop collected npm tokens, GitHub credentials, and cloud keys from every CI environment it infected. Anything a machine can read, a worm running on that machine can read.
Origin and intent are different claims. Provenance proves how an artifact was built — and ChainDrop's releases carried valid provenance, because the legitimate workflows built them. INTYGA proves that a human approved the exact artifact for release.
433 of the 444 compromised packages were republished automatically with harvested credentials. A signature ceremony per publish is the step that cannot run unattended.
Incident figures from StepSecurity's ChainDrop analysis (August 2026). For the full incident walkthrough and the reasoning behind this architecture, read the engineering-blog analysis of ChainDrop and wormable publishing.
A worm with complete access to every credential available inside the build environment can request approval. It cannot autonomously enter the separately protected publish path without obtaining a valid approval from an enrolled maintainer authenticator. Each unattended republish — the mechanism that turned 11 compromised packages into 444 — stops at an approval request instead of immediately becoming a release: the request remains incomplete, is denied, or expires unless an enrolled maintainer explicitly approves it, and the resulting outcome is recorded.
If a poisoned commit reaches your release branch and you approve the release, you have signed the poison. The gate proves a person decided; it does not review the diff. Keep code review on release branches and a minimum-release-age policy on your dependencies — this control composes with those, it does not replace them.
A release gate is only independent if the package repository cannot modify or bypass it as part of the same compromised change. A secure deployment should therefore ensure that:
A workflow that can both change the package and remove its own approval requirement does not create an independent authorization boundary.
The same architecture applies anywhere a machine performs an irreversible action on behalf of a human: cargo publish, docker push, helm releases, Terraform applies and production deployments, and privileged infrastructure changes. Package publishing is only one example. The security property is broader: possession of a credential is no longer sufficient authority. A human decision becomes part of the execution path.
A gated release process is worth advertising: it tells your users that a stolen credential cannot ship your next version. Once your publish workflow requires an approval, embed a badge.
[](https://intyga.com/use-cases/package-publishing)Releases are not metered: the Developer tier gates as many publishes as you ship, at no cost, and an approval is never refused for billing reasons. What the paid tiers add is seats, a longer evidence-retention window, and quorum rules.
See pricing →