Lab: Hardening GitHub Actions Workflows — Permissions, Pinning, and Secrets

Overview

GitHub Actions has become the most widely adopted CI/CD platform for open-source and commercial software alike. That popularity makes it the number-one attack surface in the CI/CD landscape. Misconfigured workflows routinely leak secrets, grant excessive permissions, and pull in third-party code that can be silently tampered with.

In this hands-on lab you will harden a deliberately insecure GitHub Actions workflow using the three most impactful techniques available today:

  1. Minimal permissions — restrict the GITHUB_TOKEN to only the scopes each job actually needs.
  2. SHA pinning — reference every third-party action by its immutable commit SHA instead of a mutable tag.
  3. Secrets protection — scope secrets to environments with approval gates and prevent leakage through fork-based pull requests.

By the end of the lab you will have a production-grade workflow template you can drop into any repository.

Prerequisites

  • A GitHub account with permission to create repositories.
  • Basic familiarity with GitHub Actions YAML syntax (triggers, jobs, steps).
  • The gh CLI installed (optional but helpful for querying action SHAs).

Environment Setup

Create a Test Repository

Create a new public repository on GitHub called gha-hardening-lab. You can do this through the UI or with the CLI:

gh repo create gha-hardening-lab --public --clone
cd gha-hardening-lab

Initialize a minimal Node.js project so the workflow has something to build:

npm init -y
cat <<'EOF' > index.js
console.log("Hello from the hardening lab");
EOF
git add -A && git commit -m "Initial commit" && git push

The Initial (Insecure) Workflow

Create the file .github/workflows/build.yml with the following content. This workflow is intentionally insecure — it has no permissions block, uses mutable tags, and exposes secrets too broadly:

# .github/workflows/build.yml  — INSECURE starting point
name: Build

on:
  push:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install
      - run: npm test
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .

Commit and push this file. It will run successfully, but it has at least five security issues that you will fix in the exercises below.

Exercise 1: Minimal Permissions

The Problem with Default Permissions

When a workflow does not declare a permissions block, the GITHUB_TOKEN receives the repository’s default permissions. For most repositories that means read and write access to every scope — contents, packages, issues, pull requests, deployments, and more. If an attacker compromises any step in that workflow, they inherit all of those permissions.

The principle of least privilege demands that you grant only the permissions each job actually requires, and nothing else.

Step 1 — Set a Restrictive Top-Level Default

Add a top-level permissions key immediately after the on: block. This sets the default for every job in the workflow:

permissions:
  contents: read

If you want to start with the most restrictive possible default and then grant permissions per job, you can use an empty map:

permissions: {}

Step 2 — Add Per-Job Permissions

Each job can override the workflow-level default. Grant only what the job needs:

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read       # check out code
      actions: read        # read workflow metadata
    steps:
      - uses: actions/checkout@v4
      # ...

If a second job needs to upload a release asset, you would grant it contents: write on that job alone — never at the workflow level.

Before and After

Before (insecure):

name: Build
on:
  push:
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm install

After (hardened):

name: Build
on:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      actions: read
    steps:
      - uses: actions/checkout@v4
      - run: npm install

Verify Effective Permissions

After the workflow runs, open the job in the Actions tab. Click the gear icon at the top-right of the job log and select “Set up job”. Expand that section to see the exact GITHUB_TOKEN permissions that were granted. Confirm that only contents: read and actions: read appear.

You can also query the permissions programmatically inside a step:

- name: Print token permissions
  run: |
    curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \
      https://api.github.com/repos/${{ github.repository }} \
      | jq '.permissions'

Exercise 2: Pin Actions by SHA

Why Tags Are Dangerous

When you write uses: actions/checkout@v4, you are referencing a Git tag. Tags are mutable — the action maintainer (or an attacker who compromises their account) can delete and recreate the tag pointing to entirely different code. Your workflow would then silently execute the new code on its next run. SHA pinning eliminates this risk because a commit SHA is immutable.

Step 1 — Find the SHA for an Action

Use the gh CLI to resolve a tag to its commit SHA:

# Resolve actions/checkout@v4 to a commit SHA
gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha'

If the tag is annotated (most are), the command above returns the tag object SHA. You need to dereference it to the commit:

TAG_SHA=$(gh api repos/actions/checkout/git/ref/tags/v4 --jq '.object.sha')
gh api repos/actions/checkout/git/tags/$TAG_SHA --jq '.object.sha'

Alternatively, visit the action’s repository on GitHub, click the tag, and copy the full commit SHA from the URL or the commit header.

Step 2 — Pin Common Actions

Replace every mutable tag with the full 40-character SHA. Always add a trailing comment with the version for readability:

steps:
  - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
  - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
    with:
      node-version: 20
  - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
    with:
      path: ~/.npm
      key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
  - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
    with:
      name: build-output
      path: dist/

Step 3 — Automate SHA Updates with Dependabot

Pinning by SHA means you no longer receive automatic tag-based updates. Dependabot solves this by opening pull requests whenever a pinned action publishes a new version.

Create the file .github/dependabot.yml:

version: 2
updates:
  - package-ecosystem: "github-actions"
    directory: "/"
    schedule:
      interval: "weekly"
    commit-message:
      prefix: "ci"

After you push this file, Dependabot will scan your workflows weekly and open PRs to bump the pinned SHAs. Each PR shows the diff of the action code, giving you a chance to review before merging.

If you prefer Renovate over Dependabot, add a renovate.json at the repository root:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended"],
  "github-actions": {
    "enabled": true
  }
}

Exercise 3: Secrets Protection

Repository Secrets vs. Environment Secrets

GitHub offers two levels of secret storage:

  • Repository secrets — available to every workflow and every job in the repository. Convenient but overly broad.
  • Environment secrets — available only to jobs that explicitly declare environment: <name>. This is the recommended approach for sensitive credentials.

Step 1 — Create an Environment with Protection Rules

In your repository, navigate to Settings → Environments and create an environment called production. Enable the following protection rules:

  1. Required reviewers — add at least one team member who must approve deployments.
  2. Wait timer — optionally add a delay (e.g., 5 minutes) to give reviewers time.
  3. Deployment branches — restrict to main only.

Now add your DEPLOY_TOKEN as a secret inside this environment, not at the repository level.

Step 2 — Reference the Environment in Your Workflow

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Deploy
        run: ./deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

The environment: production declaration means this job will pause and wait for a reviewer to approve it before any step runs. The DEPLOY_TOKEN secret is only available inside this environment — it cannot be accessed by other jobs or workflows that do not declare this environment.

Step 3 — Understand Fork Behavior

Secrets are not available to workflows triggered by pull_request events from forks. This is a critical security boundary. If you create a workflow that relies on secrets during PR checks, it will fail for external contributors:

# This step will fail for fork-based PRs because DEPLOY_TOKEN is empty
- name: Authenticated API call
  run: |
    curl -H "Authorization: Bearer $DEPLOY_TOKEN" https://api.example.com/health
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

This is by design — it prevents malicious forks from exfiltrating your secrets.

Step 4 — The Danger of pull_request_target

The pull_request_target trigger runs in the context of the base repository, which means it does have access to secrets. This is extremely dangerous if you also check out the PR’s head code:

# DANGEROUS — DO NOT DO THIS
on:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
        with:
          ref: ${{ github.event.pull_request.head.sha }}  # Checks out UNTRUSTED code
      - run: npm install  # Executes attacker-controlled code with access to secrets
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

An attacker can modify package.json to include a postinstall script that exfiltrates DEPLOY_TOKEN. Never combine pull_request_target with a checkout of the PR head unless you have explicitly validated and sandboxed the code.

Safe alternative: Use the standard pull_request trigger for build and test workflows. Reserve pull_request_target only for labeling or commenting workflows that never execute PR code.

Best Practice Summary

  • Store sensitive secrets in environments, not at the repository level.
  • Add required reviewers and branch restrictions to every environment that holds production credentials.
  • Use the pull_request trigger for CI. Avoid pull_request_target unless you fully understand the trust implications.
  • Design workflows so that jobs needing secrets are separated from jobs that run untrusted code.

Exercise 4: Additional Hardening

Prevent Duplicate Runs with Concurrency

Without a concurrency policy, pushing multiple commits in quick succession spawns multiple workflow runs that waste resources and can cause race conditions during deployment. Add a concurrency block at the workflow level:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

This cancels any in-progress run for the same workflow and branch when a new commit is pushed.

Set Timeout Limits

A hanging job can consume runner minutes indefinitely. Always set an explicit timeout:

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15

Choose a value that gives your build enough headroom but prevents runaway processes. For most Node.js or Go builds, 10 to 20 minutes is generous.

Restrict Workflow Triggers

Avoid bare triggers that fire on every branch:

# Too broad — runs on every push to every branch
on:
  push:

Instead, scope triggers to the branches that matter:

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

This reduces unnecessary runs and limits the attack surface for branch-based injection attacks.

Conditional Execution for Sensitive Steps

Use if: conditions to prevent sensitive steps from running in contexts where they should not:

- name: Deploy to production
  if: github.ref == 'refs/heads/main' && github.event_name == 'push'
  run: ./deploy.sh
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

This ensures the deploy step only runs on pushes to main, never on pull requests or other branches, even if the job itself is triggered.

The Final Hardened Workflow

Below is the complete hardened workflow alongside the original. Every security improvement is annotated with a comment.

Original (Insecure)

name: Build

on:
  push:
  pull_request_target:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm install
      - run: npm test
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
      - uses: actions/upload-artifact@v4
        with:
          name: build-output
          path: .

Hardened

name: Build

# HARDENED: Scoped triggers — only main branch, safe PR trigger
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

# HARDENED: Restrictive default permissions for all jobs
permissions:
  contents: read

# HARDENED: Cancel duplicate runs
concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

jobs:
  build:
    runs-on: ubuntu-latest
    # HARDENED: Explicit timeout
    timeout-minutes: 15
    # HARDENED: Per-job permissions (least privilege)
    permissions:
      contents: read
      actions: read
    steps:
      # HARDENED: All actions pinned by SHA
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
        with:
          node-version: 20
      - run: npm install
      - run: npm test
        # HARDENED: No secrets exposed in the build/test job
      - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
        with:
          name: build-output
          path: dist/

  deploy:
    needs: build
    runs-on: ubuntu-latest
    # HARDENED: Only runs on push to main
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    # HARDENED: Secrets gated behind environment with required reviewers
    environment: production
    timeout-minutes: 10
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - name: Deploy
        run: ./deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

Breaking It (Intentional Failure)

To solidify your understanding, deliberately break the hardened workflow and observe the consequences.

Test 1 — Remove the Permissions Block

Delete the top-level permissions: key and the per-job permissions. Push and run the workflow. It will still succeed, but if you inspect the job’s setup step, you will see the token now has read and write access to every scope. A compromised step could push code, delete branches, or modify releases.

Test 2 — Use an Unpinned Action

Change one action back to a tag reference:

- uses: actions/checkout@v4

The workflow still runs. But if the v4 tag is ever moved to a malicious commit, your workflow will execute that code without warning. There is no audit trail — the tag simply resolves to a different SHA. Pin it back to the SHA after this test.

Test 3 — Access Production Secrets from a PR

Create a feature branch and open a pull request. The deploy job will not run because of the if: condition. Even if you remove the condition, the DEPLOY_TOKEN environment secret is gated behind the production environment, which restricts deployment to the main branch and requires reviewer approval. The secret value will be empty in the PR context.

This is exactly the behavior you want — secrets are never available in untrusted contexts.

Cleanup

When you have finished the lab, delete the test repository to avoid cluttering your account:

gh repo delete gha-hardening-lab --yes

If you used a fork of an existing project, you can reset it instead:

git checkout main
git reset --hard origin/main
git push --force

Key Takeaways

  • Always declare a permissions block. Set a restrictive default at the workflow level and grant additional scopes per job only as needed.
  • Pin every third-party action by its full SHA. Tags are mutable and can be silently redirected to malicious code.
  • Use Dependabot or Renovate to keep pinned SHAs up to date automatically.
  • Store sensitive secrets in environments with required reviewers and branch restrictions — never at the repository level.
  • Use pull_request, not pull_request_target, for workflows that build or test PR code. The pull_request_target trigger grants secret access to potentially untrusted code.
  • Add concurrency, timeout-minutes, and branch-scoped triggers to reduce resource waste and shrink the attack surface.

Next Steps

Continue building your CI/CD security knowledge with these related guides: