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:
- Minimal permissions — restrict the
GITHUB_TOKENto only the scopes each job actually needs. - SHA pinning — reference every third-party action by its immutable commit SHA instead of a mutable tag.
- 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
ghCLI 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:
- Required reviewers — add at least one team member who must approve deployments.
- Wait timer — optionally add a delay (e.g., 5 minutes) to give reviewers time.
- Deployment branches — restrict to
mainonly.
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_requesttrigger for CI. Avoidpull_request_targetunless 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
permissionsblock. 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, notpull_request_target, for workflows that build or test PR code. Thepull_request_targettrigger 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:
- CI/CD Execution Models and Trust Assumptions — Understand how different CI/CD platforms model trust and where the boundaries break down.
- Separation of Duties and Least Privilege in CI/CD Pipelines — Design pipelines where no single actor or credential has more access than necessary.