GitHub Actions security for MCP servers: OIDC tokens, minimal GITHUB_TOKEN permissions, and safe secrets injection
GitHub Actions workflows for MCP server repositories sit at the intersection of code and deployment — they build your server, run your security gate, and publish your releases. A compromised workflow can exfiltrate every secret in your repository, publish a backdoored npm package under your name, or push malicious commits with a trusted identity. Four specific hardening steps reduce the attack surface: scoped GITHUB_TOKEN permissions, pinned third-party Action dependencies, OIDC tokens for cloud authentication, and a hard boundary between untrusted PR code and privileged workflow steps.
Scope GITHUB_TOKEN to minimum permissions
By default, GitHub grants workflows a GITHUB_TOKEN with write access to most repository resources. Most workflows only need a fraction of that. Set permissions: read-all at the workflow level as a baseline, then grant write access explicitly at the job level for only the jobs that need it:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
permissions:
contents: read # default — read repo contents
# all other permissions: none
jobs:
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # required for CodeQL / SkillAudit SARIF upload
steps:
- uses: actions/checkout@v4 # see pinning section below
- name: Run SkillAudit
uses: skillaudit/github-action@v1
with:
token: ${{ secrets.SKILLAUDIT_TOKEN }}
publish:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
permissions:
contents: write # only this job needs write access for releases
packages: write # for GitHub Package Registry
steps:
- uses: actions/checkout@v4
- run: npm publish
Pin third-party Actions to commit SHAs
Referencing a third-party Action by tag (actions/checkout@v4) is a trust decision: if the tag is moved to a different commit (intentionally or via a supply chain compromise), your workflow runs different code without any change on your side. Pinning to an immutable commit SHA guarantees you run exactly what you reviewed:
# Unsafe — tag can be moved to malicious commit
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
# Safe — pinned to immutable commit SHA
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
Use a tool like StepSecurity Harden Runner or Dependabot's GitHub Actions update feature to manage SHA pinning with automated updates when new versions are released.
OIDC tokens for cloud authentication
Long-lived AWS IAM credentials or GCP service account keys stored as GitHub secrets are a high-value target: anyone who can read your secrets (a compromised workflow, a malicious PR) gets persistent cloud access. OIDC tokens eliminate long-lived credentials: GitHub mints a short-lived token for each workflow run that your cloud provider exchanges for temporary credentials. The token is valid for the duration of the run only:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC token minting
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# Exchange GitHub OIDC token for AWS temporary credentials
# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY in secrets
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6542e698252b07175d0e09601 # v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-mcp-server-deploy
aws-region: us-east-1
- name: Deploy to ECS
run: aws ecs update-service --cluster mcp --service mcp-server --force-new-deployment
Configure the AWS IAM role's trust policy to only allow assumption from your specific repository and branch — not from any GitHub-hosted runner anywhere.
Isolate untrusted PR code from privileged secrets
The most critical GitHub Actions security rule for open-source MCP server repositories: never run code from a fork PR in a context that has access to repository secrets. The pull_request_target trigger runs in the base repository context (with secrets) but checks out the PR's code — a combination that allows a malicious PR to exfiltrate all your secrets:
# DANGEROUS — do not use pull_request_target with checkout of PR code
on:
pull_request_target: # has secrets
jobs:
test:
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }} # attacker-controlled code
- run: npm test # runs attacker code with your secrets
The safe pattern: use pull_request (no secrets) for test runs on PR code, and use pull_request_target only for trusted operations that don't check out PR code (labeling, commenting):
# SAFE — run PR tests without secrets
on:
pull_request: # no access to secrets from fork PRs
jobs:
test:
steps:
- uses: actions/checkout@v4 # checks out PR code — safe, no secrets
- run: npm test # no secrets available, attacker has nothing to steal
What SkillAudit checks in GitHub Actions workflows
SkillAudit's CI workflow analysis scans .github/workflows/*.yml for common security misconfigurations:
- HIGH:
pull_request_targetwith PR code checkout and secret access — the exact pattern that enables secret exfiltration from fork PRs. - HIGH: third-party Actions pinned by mutable tag, not commit SHA — any
uses:referencing a third-party action by@v1,@main, or@latestrather than a commit SHA. - MEDIUM:
GITHUB_TOKENwith write permissions at workflow scope — no job-level permission scoping; all jobs run with write access they may not need. - MEDIUM: long-lived cloud credentials stored as secrets —
AWS_ACCESS_KEY_ID,GCP_SA_KEY, or similar patterns in${{ secrets.* }}references where OIDC would eliminate the long-lived credential. - LOW:
ACTIONS_ALLOW_UNSECURE_COMMANDSset to true — enables deprecated workflow commands that allow environment variable injection from log output.
Scan your GitHub Actions workflows for security misconfigurations
Run a free audit →