GitHub Actions Supply Chain Hardening: Pinning, Permissions, and OIDC Token Security

GitHub Actions Supply Chain Hardening: Pinning, Permissions, and OIDC Token Security

The Threat Surface

GitHub Actions is the dominant CI/CD platform for open source and a large fraction of enterprise software. Its convenience — a marketplace of thousands of reusable actions, tight GitHub API integration, and zero infrastructure to manage — is also its attack surface. Every uses: directive is a dependency. Every secret mapped into a job is a target. Every broad GITHUB_TOKEN permission is a lateral movement vector.

Two incidents illustrate the concrete risk. In March 2023, the tj-actions/changed-files action was compromised after an attacker obtained the maintainer’s personal access token. The attacker force-pushed malicious code to every version tag of the action — over 300 tags — and inserted a payload that printed CI secrets (including GITHUB_TOKEN, AWS keys, npm tokens, and Docker Hub credentials) to the workflow log in an encoded form that bypassed GitHub’s secret masking. Any public repository using the action by tag immediately exposed its secrets in public workflow logs. The attack was detected through an alert on a downstream repository, not by GitHub’s infrastructure. The same campaign extended to reviewdog/action-setup, reviewdog/action-golangci-lint, and several other reviewdog ecosystem actions — all compromised via the same stolen credential, all pushing the same exfiltration payload.

The mechanism is identical in each case: maintainer credential compromise → force-push to mutable tag → next workflow run executes attacker code. No workflow file changes. No PR. No diff to review. The attack is silent from the consumer’s perspective until secrets appear somewhere they shouldn’t.

Pinning to Commit SHAs

Tags in Git are mutable references. A tag named v4 points to a commit object, and that pointer can be moved with a force-push. When your workflow declares uses: actions/checkout@v4, GitHub resolves v4 to whatever commit that tag currently points to — at execution time, every time. If the tag moves between two workflow runs, the two runs execute different code.

Commit SHAs are immutable. A SHA is the SHA-1 (and for newer Git objects, SHA-256) hash of the content of the commit object itself. No one can change what a SHA points to without producing a different SHA. Pinning to a full 40-character commit SHA makes the reference immune to force-push attacks.

- uses: actions/checkout@v4

- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

The first line is vulnerable. The second line is not. The comment preserves human readability without sacrificing the security property.

To resolve the current SHA for a tag without cloning:

gh api repos/actions/checkout/git/refs/tags/v4.1.1 \
  --jq '.object.sha'

Annotated tags have their own SHA distinct from the commit they point to. For annotated tags, peel through to the commit:

gh api repos/actions/checkout/git/refs/tags/v4.1.1 \
  --jq '.object.url' \
  | xargs gh api --jq '.object.sha // .sha'

Audit all workflow files in a repository for non-SHA references:

grep -rn 'uses:' .github/workflows/ | grep -v '@[0-9a-f]\{40\}'

A hardened repository produces no output. Any line in the output is a mutable reference that needs remediation.

Automating SHA Updates with Dependabot and Renovate

SHA pinning creates a maintenance obligation: when a new action version releases, someone must update the pin. Without automation, teams pin once and then fall behind on security patches because updating SHA pins manually is tedious. Dependabot handles this.

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    commit-message:
      prefix: "chore(actions)"
    groups:
      actions-minor:
        update-types:
          - "minor"
          - "patch"

Dependabot opens PRs each week with updated SHAs and version comments for any actions that released new versions. The PR diff shows exactly which SHA changed and to what. Review the PR, verify the new SHA corresponds to the published release on the action’s repository, and merge. This is a 30-second check per action.

Renovate provides finer control, particularly useful for organisations managing many repositories or wanting automerge policies differentiated by action risk level:

{
  "extends": ["config:base"],
  "github-actions": {
    "enabled": true,
    "pinDigests": true
  },
  "packageRules": [
    {
      "matchManagers": ["github-actions"],
      "matchUpdateTypes": ["patch"],
      "automerge": true,
      "automergeType": "pr",
      "platformAutomerge": true
    },
    {
      "matchManagers": ["github-actions"],
      "matchUpdateTypes": ["major", "minor"],
      "automerge": false,
      "reviewers": ["team:platform-security"]
    }
  ]
}

pinDigests: true causes Renovate to convert existing tag references to SHA pins on first run. Patch-level action updates automerge after CI passes. Major and minor updates require a team member to review — appropriate because major version bumps can change the action’s behaviour or introduce new permissions requirements.

One operational failure mode: Dependabot PRs accumulate in the queue because no one is assigned to merge them. After a few months, the team starts bypassing SHA pinning because “Dependabot isn’t keeping up.” Prevent this by assigning ownership: a designated person or rotation reviews and merges Dependabot action PRs weekly, with a policy that any PR open for more than seven days with passing CI is auto-merged unless someone objects.

GITHUB_TOKEN Minimum Permissions

The GITHUB_TOKEN is an automatically generated credential scoped to the repository and job. Its default permissions vary by repository setting, and in many organisations the default is read-write for all repository resources — contents, pull-requests, packages, deployments, security-events, and more. A compromised action running with this broad token can push commits, create releases, modify workflow files, approve pull requests, and interact with the GitHub API as the repository.

The correct model: declare permissions: {} at the workflow level to strip all defaults, then grant each job only the permissions it actually uses.

name: CI
on:
  push:
    branches: [main]
  pull_request:

permissions: {}

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - run: make test

  security-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - uses: github/codeql-action/analyze@c0d1daa7f7e14667747d73a7dbb never mind, pinned SHA below
      - uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571  # v3.24.6
        with:
          category: "/language:javascript"

  release:
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    permissions:
      contents: write
      packages: write
      id-token: write
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - run: make release

The test job can check out code and run tests. It cannot push commits. The security-scan job can upload SARIF results to the Security tab. It cannot write to repository contents. The release job has elevated permissions but only runs on pushes to main. A compromised action in the test job has a token scoped to contents: read — it can exfiltrate the token, but the token has no write surface to exploit.

Change the repository default in Settings > Actions > General > Workflow permissions to “Read repository contents and packages permissions.” This ensures that any workflow file that omits a permissions block gets the read-only default rather than write-all.

OIDC Token Scope Reduction

OIDC federation lets GitHub Actions exchange a short-lived JWT for cloud credentials without storing long-lived secrets. The workflow requests an id-token, sends it to the cloud provider’s token endpoint, and receives credentials scoped according to the provider’s trust policy. No AWS access keys or GCP service account JSON files in repository secrets.

The attack surface is the trust policy on the cloud side. A broad trust policy — one that trusts any workflow in any repository in your organisation — means a compromised action in any workflow can request cloud credentials. Narrow the trust policy to the specific repository, branch, and environment that legitimately needs cloud access.

AWS example using IAM role trust policy:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
          "token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
        }
      }
    }
  ]
}

The sub claim format repo:org/repo:environment:name restricts the role to workflows running in the production environment of the specific repository. A workflow in a different repository, or in the same repository without the environment: declaration, receives a sub claim that does not match the condition and cannot assume the role.

For branch-specific access (staging deployments from main only):

"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"

For tag-based access (releases from version tags only):

"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/tags/v*"

The corresponding workflow:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1

      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/prod-deploy
          aws-region: us-east-1

      - run: aws s3 sync ./dist s3://my-production-bucket/

GCP trust policy equivalent using Workload Identity Federation:

- uses: google-github-actions/auth@55bd3a7c6e2ae7cf1877fd1ccb9d54c0503c457c  # v2.1.2
  with:
    workload_identity_provider: projects/123/locations/global/workloadIdentityPools/github/providers/github
    service_account: prod-deploy@my-project.iam.gserviceaccount.com

The Workload Identity Pool attribute condition on the GCP side should include attribute.repository and attribute.ref claims to restrict which workflows can impersonate the service account.

Environment Protection Rules

GitHub Environments add an approval gate between the workflow trigger and the deployment step. A workflow job that declares environment: production pauses at that job until the required reviewers approve, any configured wait timer expires, and the branch matches the allowed deployment branches pattern.

Configure environments in Settings > Environments:

  • Required reviewers: two people from the security or release team
  • Wait timer: five minutes (gives reviewers time to notice and cancel automated deployments they didn’t expect)
  • Deployment branches: selected branches, pattern main
  • Environment secrets: production credentials scoped to this environment only

A compromised action running in a job without the environment: declaration cannot access environment-scoped secrets. An action that runs in the environment-gated job cannot skip the approval requirement — the approval is enforced by GitHub’s infrastructure, not by workflow logic that could be bypassed by code injection.

jobs:
  deploy-staging:
    runs-on: ubuntu-latest
    environment: staging
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - run: ./deploy.sh staging

  deploy-production:
    runs-on: ubuntu-latest
    needs: deploy-staging
    environment: production
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - run: ./deploy.sh production

The deploy-production job requires both deploy-staging to succeed and the two designated reviewers to approve before any production-scoped credentials are requested. This is a hard gate for SLSA Build Provenance requirements and audit trail purposes.

StepSecurity Harden-Runner

SHA pinning and permission restriction prevent the most common attack vectors, but they don’t stop a compromised action that operates within its declared permissions from exfiltrating secrets over the network. Harden-Runner from StepSecurity adds runtime monitoring to the GitHub Actions runner, intercepting system calls to detect unexpected outbound network connections and file access patterns.

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f  # v2.10.1
        with:
          egress-policy: audit
          allowed-endpoints: >
            github.com:443
            api.github.com:443
            objects.githubusercontent.com:443
            registry.npmjs.org:443

      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - run: npm ci
      - run: npm test

egress-policy: audit logs all outbound connections without blocking them — use this to establish a baseline of expected endpoints. Once you know what your workflow legitimately contacts, switch to egress-policy: block with an explicit allowed-endpoints list:

- uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f  # v2.10.1
  with:
    egress-policy: block
    allowed-endpoints: >
      github.com:443
      api.github.com:443
      objects.githubusercontent.com:443
      registry.npmjs.org:443
      nodejs.org:443

In block mode, any outbound connection to an endpoint not on the allowlist causes the step to fail immediately. A compromised action that attempts to POST credentials to an attacker-controlled server receives a connection refused. The exfiltration channel used in the tj-actions/changed-files incident — an HTTPS POST to a domain the attacker registered — would have been blocked by this control.

Harden-Runner also detects when workflow steps write to unexpected file paths and when processes run that were not present in a baseline scan. These signals surface in the StepSecurity dashboard, and can be exported to a SIEM via the API.

Secret Detection in Action Logs

GitHub automatically masks the values of secrets registered in the repository’s secret store when they appear in workflow logs — any exact string match is replaced with ***. This masking fails in predictable ways: base64-encoded secrets, secrets split across multiple log lines, and secrets derived from registered values (JWT claims computed from a secret key) are not masked.

The tj-actions/changed-files payload exploited exactly this: it base64-encoded each captured secret before printing it to the log, producing output that GitHub’s masking regex did not match. Anyone with read access to the repository — which for public repositories means anyone — could retrieve the workflow run log and decode the secrets.

Supplement GitHub’s built-in masking with explicit detection steps:

- name: Check for unmasked secrets in log output
  if: always()
  run: |
    LOG_URL="https://api.github.com/repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/logs"
    curl -sL -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
      -H "Accept: application/vnd.github+json" \
      "${LOG_URL}" -o /tmp/workflow-logs.zip
    unzip -p /tmp/workflow-logs.zip > /tmp/all-logs.txt
    if grep -qP '[A-Za-z0-9+/]{40,}={0,2}' /tmp/all-logs.txt; then
      echo "::warning::Base64-encoded content found in logs — review for secret exfiltration"
    fi

For comprehensive secret scanning in the repository itself, push protection via GitHub Advanced Security blocks commits containing known secret patterns at push time. Custom patterns extend coverage to organisation-specific token formats:

- name: Scan repository for secrets
  uses: trufflesecurity/trufflehog@main
  with:
    path: ./
    base: ${{ github.event.repository.default_branch }}
    head: HEAD
    extra_args: --only-verified

The --only-verified flag reduces false positives by attempting to validate detected credentials against their respective APIs before reporting. Unverified findings are still captured in the output for manual review.

Enable secret scanning push protection at the organisation level in Settings > Code security and analysis > Secret scanning > Push protection. This blocks pushes containing known secret formats before they reach the repository history — prevention rather than detection.

Reusable Workflows as a Supply Chain Control

Every third-party action you add to a workflow is a dependency you don’t control. One architectural response is to replace third-party actions with internal reusable workflows that perform the same function using tools you control directly. Instead of using a third-party action to upload SARIF results, run the gh CLI directly. Instead of using an action to configure cloud credentials, call the cloud provider’s CLI directly with OIDC token exchange.

Where third-party actions are genuinely necessary, encapsulate them in internal reusable workflows and reference those instead:

name: Reusable Security Scan
on:
  workflow_call:
    inputs:
      image-ref:
        required: true
        type: string
    secrets:
      github-token:
        required: true

jobs:
  scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f  # v2.10.1
        with:
          egress-policy: block
          allowed-endpoints: >
            github.com:443
            api.github.com:443
            ghcr.io:443

      - uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0  # v0.20.0
        with:
          image-ref: ${{ inputs.image-ref }}
          format: sarif
          output: trivy-results.sarif

      - uses: github/codeql-action/upload-sarif@8a470fddafa5cbb6266ee11b37ef4d8aae19c571  # v3.24.6
        with:
          sarif_file: trivy-results.sarif

Consumer workflows call the reusable workflow by reference:

jobs:
  security-scan:
    uses: your-org/.github/.github/workflows/security-scan.yml@main
    with:
      image-ref: ghcr.io/your-org/your-image:${{ github.sha }}
    secrets:
      github-token: ${{ secrets.GITHUB_TOKEN }}

The third-party action versions, Harden-Runner configuration, and egress allowlist are defined once in the internal reusable workflow. All consuming repositories get the hardened configuration automatically. Updating the third-party action SHA in the reusable workflow propagates to every consumer — no repository-by-repository Dependabot PR required.

Protect the reusable workflow file with CODEOWNERS:

.github/workflows/ @your-org/platform-security

Platform security team review is required for any change to the workflow definitions. Consumers cannot modify the internal workflow they’re calling — they can only call it. This is a meaningful supply chain control: the organisation’s security team owns and hardens the action wrappers, and all projects consume those wrappers rather than sourcing their own.

This pattern complements the dependency confusion defences described in Dependency Confusion Defence and the provenance tracking discussed in SLSA Build Provenance. For SBOM generation from the pipeline, see SBOM Generation and Consumption.

Consolidated Hardened Workflow

A workflow that applies all of the above controls:

name: Build, Scan, and Deploy
on:
  push:
    branches: [main]
  pull_request:

permissions: {}

jobs:
  build-and-test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f  # v2.10.1
        with:
          egress-policy: block
          allowed-endpoints: >
            github.com:443
            api.github.com:443
            objects.githubusercontent.com:443
            registry.npmjs.org:443

      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8  # v4.0.2
        with:
          node-version: "20"
          cache: "npm"
      - run: npm ci
      - run: npm test

  security-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
    steps:
      - uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f  # v2.10.1
        with:
          egress-policy: block
          allowed-endpoints: >
            github.com:443
            api.github.com:443
            ghcr.io:443

      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - uses: github/codeql-action/init@8a470fddafa5cbb6266ee11b37ef4d8aae19c571  # v3.24.6
        with:
          languages: javascript
      - uses: github/codeql-action/analyze@8a470fddafa5cbb6266ee11b37ef4d8aae19c571  # v3.24.6

  deploy-production:
    runs-on: ubuntu-latest
    needs: [build-and-test, security-scan]
    if: github.ref == 'refs/heads/main'
    environment: production
    permissions:
      contents: read
      id-token: write
    steps:
      - uses: step-security/harden-runner@91182cccc01eb5e233317f50d14e38ce555d5e7f  # v2.10.1
        with:
          egress-policy: block
          allowed-endpoints: >
            github.com:443
            sts.amazonaws.com:443
            s3.amazonaws.com:443

      - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11  # v4.1.1
      - uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502  # v4.0.2
        with:
          role-to-assume: arn:aws:iam::123456789012:role/prod-deploy
          aws-region: us-east-1
      - run: aws s3 sync ./dist s3://my-production-bucket/

Trade-offs

Control Operational Cost Risk Mitigated
SHA pinning Weekly Dependabot PR review Tag hijacking via force-push
permissions: {} + per-job grants Steps fail if permissions miscounted; iterative debugging Stolen token has no write surface
OIDC trust policy narrowing Trust policy must be updated when repo/branch structure changes Any workflow in org can assume production role
Environment approval gates Deployment latency; approver availability bottleneck Automated pipelines bypass without human review
Harden-Runner egress block Baseline audit phase required; allowlist maintenance Credential exfiltration over HTTPS
Reusable internal workflows Central workflow maintenance burden Per-team divergence on action versions and configs

The controls compound. SHA pinning makes tag hijacking ineffective. Minimum permissions mean a stolen token has limited scope. OIDC narrowing means a stolen token that does have cloud access can only assume the specific role tied to the specific environment. Harden-Runner egress blocking prevents the credential from leaving the runner even if all other controls fail. Environment gates add a human review point before the most sensitive credentials are accessed at all.