Hardening GitHub Actions with Least Privilege
GitHub Actions has become a core part of modern CI/CD pipelines. It can build software, run tests, publish packages, create releases, deploy applications, update pull requests, and interact with many parts of a GitHub repository. That power also makes workflow permissions an important security boundary. A workflow does not merely execute commands; it can receive a GitHub-provided authentication token and, depending on its permissions, potentially modify repository contents, packages, issues, pull requests, deployments, or other resources. The safest design is therefore not to ask, “What permissions might this workflow need?” but instead: “What is the minimum permission that each job must have to perform its function?” This article explains how to apply that principle using least privilege by default.
1. Overview
GitHub automatically creates a temporary GITHUB_TOKEN for workflow jobs. This token allows workflows to authenticate against GitHub without developers having to create and store a long-lived personal access token for routine repository operations. The security problem arises when a workflow receives more privileges than it actually needs. If an action, dependency, build script, or injected command is compromised, the attacker may be able to use the workflow’s token with exactly the permissions granted to that job. Least privilege reduces that blast radius. A hardened model normally follows four principles:
- Deny unnecessary GitHub token permissions by default.
- Grant permissions explicitly rather than relying on implicit defaults.
- Prefer job-level permissions over broad workflow-level write access.
- Separate read-only CI jobs from privileged publishing or deployment jobs.
Core principle: start with no permissions, then add only the minimum permissions required for the workflow or job to succeed.
2. Understanding GitHub Actions and GitHub Actions Permissions
2.1 What Is GitHub Actions?
GitHub Actions is GitHub’s automation platform. Automation is described using YAML workflow files stored in: .github/workflows/. A workflow contains one or more jobs, and each job contains steps. A basic CI workflow might look like this:
name: CI
on:
pull_request:
push:
branches:
- main
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
run: npm test
Although this appears to be a simple build configuration, the job can also have access to GitHub’s automatically generated GITHUB_TOKEN.
2.2 Why the Default Permission Approach Can Fail
Relying on GitHub Actions permission defaults can create unnecessary security risk because the effective permissions of GITHUB_TOKEN may depend on repository, organization, or enterprise-level configuration rather than being clearly defined inside the workflow itself. A workflow that does not explicitly declare permissions can therefore be harder to review, audit, and safely move between repositories with different settings. Another common problem is granting broad workflow-level access such as permissions: write-all simply because one stage of the pipeline requires write access. This gives build, test, dependency installation, and third-party action execution more privilege than they need and increases the potential blast radius if any of those components are compromised. Explicit job-level permissions avoid this problem by documenting the intended security boundary directly in the workflow and ensuring that privileged capabilities are available only to the jobs that actually require them.
2.3 What Is the GITHUB_TOKEN?
When a workflow job begins, GitHub creates a temporary authentication token known as GITHUB_TOKEN. Internally, it is associated with the GitHub App installation used by GitHub Actions for the repository. The token is scoped to the repository containing the workflow and expires after the job completes or reaches its effective lifetime limit. The important security property is that the token’s capabilities depend on the permissions granted to the workflow job. For example, a workflow could potentially require permissions such as:
| Permission | Typical Purpose |
|---|---|
contents: read | Read repository contents. |
contents: write | Modify repository contents, tags, or releases where supported. |
pull-requests: write | Modify or comment on pull requests. |
issues: write | Create or modify issues and issue comments. |
packages: write | Publish packages. |
deployments: write | Create or update deployments. |
security-events: write | Upload supported security analysis results. |
attestations: write | Create artifact attestations. |
id-token: write | Request an OpenID Connect token for federated authentication. |
3. Complete Example: Hardened GitHub Actions Workflow with Least Privilege
The following example brings the key least-privilege concepts together in one GitHub Actions workflow. It starts with no permissions by default and grants only the permissions required by each individual job.
name: Hardened CI/CD Pipeline
on:
pull_request:
push:
branches:
- main
# ---------------------------------------------------------
# DENY BY DEFAULT
# ---------------------------------------------------------
# No job receives GITHUB_TOKEN permissions unless explicitly
# granted below.
permissions: {}
jobs:
# =========================================================
# 1. BUILD AND TEST
# =========================================================
# This job only needs to read repository contents.
# It does not need any write permissions.
# =========================================================
test:
name: Build and Test
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Run tests
run: npm test
# =========================================================
# 2. SECURITY SCAN
# =========================================================
# The scanner only needs access to read the source code.
# No write permission is granted.
# =========================================================
security-scan:
name: Security Scan
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Run security scanner
run: ./scripts/security-scan.sh
# =========================================================
# 3. PULL REQUEST COMMENT
# =========================================================
# This job demonstrates narrowly scoped write access.
#
# It can read repository contents and write to pull requests,
# but it cannot modify repository contents, packages,
# deployments, or other resources.
#
# It only runs for pull-request events.
# =========================================================
pr-comment:
name: Post PR Result
needs:
- test
- security-scan
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Post validation result
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh pr comment "$PR_NUMBER" \
--body "Build, tests, and security checks completed successfully."
# =========================================================
# 4. PACKAGE PUBLISHING
# =========================================================
# Package write access is granted only to this job.
#
# The job cannot run from a pull request. It only runs after
# successful validation on a push to the main branch.
# =========================================================
publish:
name: Publish Package
needs:
- test
- security-scan
if: >
github.event_name == 'push' &&
github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build package
run: npm run build
- name: Publish package
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ github.token }}
# =========================================================
# 5. PRODUCTION DEPLOYMENT
# =========================================================
# This job demonstrates OIDC-based authentication.
#
# id-token: write allows the job to request an OIDC token.
# It is intentionally granted only to the deployment job.
#
# No long-lived cloud credential needs to be stored in GitHub
# when the cloud provider supports workload identity federation.
# =========================================================
deploy:
name: Deploy to Production
needs:
- publish
if: >
github.event_name == 'push' &&
github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Authenticate to cloud provider using OIDC
run: ./scripts/cloud-login.sh
- name: Deploy application
run: ./scripts/deploy.sh
# =========================================================
# 6. NEGATIVE PERMISSION VERIFICATION
# =========================================================
# This optional job demonstrates how least privilege can be
# verified.
#
# The job only has contents: read, so an API request requiring
# issue write access should fail.
#
# Use such tests only in a controlled test repository.
# =========================================================
verify-least-privilege:
name: Verify Permission Restrictions
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Verify repository access
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api \
repos/${{ github.repository }} \
--jq '.full_name'
- name: Confirm unauthorized write operation is blocked
env:
GH_TOKEN: ${{ github.token }}
run: |
HTTP_STATUS=$(curl -s \
-o /tmp/response.json \
-w "%{http_code}" \
-X POST \
-H "Authorization: Bearer $GH_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/${{ github.repository }}/issues \
-d '{"title":"Least privilege verification"}')
if [ "$HTTP_STATUS" = "201" ]; then
echo "ERROR: Write operation unexpectedly succeeded."
exit 1
fi
echo "PASS: Unauthorized write operation was blocked."
3.1 Code Explanation
The Hardened CI/CD Pipeline workflow is designed around the principle of least privilege, meaning that every job receives only the minimum GitHub permissions required to perform its task.
The workflow is triggered in two cases: when a pull_request event occurs and when code is pushed to the main branch.
At the workflow level, permissions: {} establishes a deny-by-default security posture by removing all GITHUB_TOKEN permissions unless a job explicitly requests them. This prevents jobs from automatically inheriting broader repository privileges and makes every permission decision visible in the workflow configuration.
The first job, test, runs the build and test process on an ubuntu-latest GitHub-hosted runner. It receives only contents: read, which is sufficient for actions/checkout@v4 to read and check out the repository.
The job then installs dependencies using npm ci and executes the project’s test suite using npm test. Because it has no write permissions, code executed during dependency installation or testing cannot use the job’s GITHUB_TOKEN to modify repository contents, packages, pull requests, or other protected GitHub resources.
The second job, security-scan, follows the same read-only model. It receives only contents: read, checks out the source code, and runs the repository’s security scanning script using ./scripts/security-scan.sh.
Keeping the security scanner read-only reduces the impact of a compromised scanner, dependency, or script because it cannot use the GitHub token to change repository resources.
The pr-comment job demonstrates narrowly scoped write access. It declares needs: [test, security-scan], meaning that it runs only after both the test and security-scan jobs have completed successfully.
The if: github.event_name == 'pull_request' condition ensures that the job executes only for pull-request workflows. This job receives contents: read and pull-requests: write, allowing it to interact with the pull request without receiving unrelated permissions such as contents: write, packages: write, or deployments: write.
The environment variable GH_TOKEN is assigned ${{ github.token }}, which exposes the job’s temporary GitHub token to the GitHub CLI, while PR_NUMBER obtains the current pull-request number from ${{ github.event.pull_request.number }}.
The command gh pr comment then posts a message confirming that the build, tests, and security checks were completed successfully.
The publish job demonstrates how higher privileges can be isolated to a specific stage of the pipeline. It depends on both the test and security-scan jobs and contains an additional condition requiring github.event_name == 'push' and github.ref == 'refs/heads/main'.
This means package publishing cannot occur during pull-request validation and is limited to successful pushes to the main branch.
The job receives contents: read so it can check out the repository and packages: write so it can publish a package, while all other GitHub permissions remain unavailable.
It checks out the repository, installs dependencies with npm ci, builds the package using npm run build, and then publishes it with npm publish.
The NODE_AUTH_TOKEN environment variable is populated with ${{ github.token }}, providing the publishing command with the temporary token available to that job.
Because packages: write exists only in this job, the build, test, security-scan, pull-request, and deployment jobs do not automatically receive package-publishing privileges.
The deploy job is responsible for production deployment and runs only after the publish job succeeds. It repeats the branch and event checks to ensure that deployment occurs only for a push to the main branch.
The job also declares environment: production, allowing GitHub Environment protections such as required reviewers, deployment restrictions, and environment-specific controls to be applied where configured.
Its permission set contains contents: read and id-token: write. The id-token: write permission does not provide general repository write access; instead, it allows the job to request an OpenID Connect, or OIDC, identity token that can be exchanged with a supported cloud provider for short-lived credentials.
The workflow therefore avoids granting OIDC capability to jobs that do not need cloud authentication and can avoid storing long-lived cloud credentials when the cloud platform supports workload identity federation.
After checking out the repository, the deployment job runs ./scripts/cloud-login.sh to authenticate with the cloud provider using OIDC and then executes ./scripts/deploy.sh to deploy the application.
The final job, verify-least-privilege, demonstrates how the permission model can be tested rather than merely assumed. This job receives only contents: read.
Its first step sets GH_TOKEN to ${{ github.token }} and calls gh api repos/${{ github.repository }} to confirm that the token can perform the expected read operation and retrieve repository information.
The second step deliberately attempts a GitHub API operation that requires write access by sending a POST request to the repository’s issues endpoint.
The command captures the HTTP response status in HTTP_STATUS. If GitHub unexpectedly returns 201, indicating that the issue was successfully created, the workflow prints an error and exits with status 1.
This causes the verification job to fail because the supposedly read-only token had more privilege than expected. If the request is rejected, the script prints a success message confirming that the unauthorized operation was blocked.
This negative permission test should be performed only in a controlled or test repository because its purpose is to validate that prohibited actions genuinely fail.
Overall, the workflow demonstrates a strong least-privilege architecture by starting with permissions: {}, assigning read-only permissions to build and security jobs, giving pull-request write access only to the pull-request automation job, granting package-write access only to the publishing job, and granting OIDC token capability only to the deployment job.
It also restricts privileged operations to appropriate events and branches, separates high-privilege tasks from lower-trust build execution, and explicitly verifies that a token cannot perform operations outside its authorized permission boundary.
4. Conclusion
Hardening GitHub Actions permissions with least privilege by default is one of the most effective ways to reduce the security risk of CI/CD automation. The key idea is to treat every workflow job as its own security boundary and grant only the permissions that are strictly required for that job to complete its task. Using permissions: {} at the workflow level creates a secure deny-by-default baseline, while job-level permissions such as contents: read, pull-requests: write, packages: write, and id-token: write make access explicit, auditable, and easier to review. Separating build, test, security scanning, publishing, and deployment stages also limits the impact of compromised dependencies, scripts, or third-party actions because privileged credentials are not exposed to every part of the pipeline. Branch and event conditions further reduce risk by ensuring that sensitive operations such as package publication and production deployment execute only in trusted contexts, while OIDC enables short-lived cloud authentication without relying on long-lived credentials. Just as importantly, least privilege should be verified rather than assumed: inspecting effective workflow permissions and testing that unauthorized operations fail provides confidence that the intended security boundary is actually enforced. In practice, the safest GitHub Actions design is simple: start with no permissions, grant only what is needed, isolate privileged operations, restrict when they can run, and continuously verify that workflows cannot perform actions beyond their intended responsibilities. This approach reduces the blast radius of a compromised workflow and creates a more predictable, maintainable, and secure CI/CD environment.
4.1 How to Verify That Least Privilege Is Working
Least privilege should be validated both through configuration review and runtime testing. Review each workflow job and confirm that every declared permission corresponds to an operation performed by that job, with unrelated permissions left unavailable. Inspect GitHub Actions job logs to confirm the effective GITHUB_TOKEN permissions, and perform controlled negative tests to ensure that read-only jobs cannot successfully create issues, modify pull requests, publish packages, push repository changes, or perform other unauthorized write operations. Permission changes inside workflow files should receive explicit security review, especially when they introduce write access or id-token: write. For larger environments, these checks can also be automated through policy validation or static analysis so workflows that request excessive permissions are detected before they are merged.


