GitHub Actions workflows often use the GITHUB_TOKEN for authentication, and by default, it has broad permissions. This article explains how to restrict those permissions to the principle of least privilege.
Let’s see this in action. Imagine a workflow that only needs to read repository contents and push a branch.
name: Limited Token Example
on: [push]
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # Only read access to repository contents
pages: write # Only write access to GitHub Pages
# No other permissions are granted by default
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Needed to push branches
- name: Set up Git
run: |
git config --global user.name 'GitHub Actions'
git config --global user.email 'actions@github.com'
- name: Create and push a new branch
run: |
git checkout -b feature/new-branch
echo "new content" >> new_file.txt
git add .
git commit -m "Add new file"
git push origin HEAD:refs/heads/feature/new-branch
In this example, the permissions block is crucial. contents: read means the GITHUB_TOKEN can only fetch the repository’s code. pages: write would allow deploying to GitHub Pages if that were the job’s purpose. If this job tried to, say, create a release, it would fail because contents: read doesn’t grant contents: write or releases: write.
The GITHUB_TOKEN is a temporary, automatically generated token that GitHub Actions uses to authenticate to GitHub on behalf of your workflow. It’s associated with the repository where the workflow is running. By default, it has read-write access to most resources. This is convenient but can be a security risk if a workflow is compromised. An attacker could potentially use the default broad permissions to perform actions they shouldn’t, like pushing to main, creating releases, or modifying repository settings.
The permissions key in your workflow file allows you to explicitly define the minimum required permissions for a job. You can set permissions at the workflow level (applying to all jobs) or at the job level (overriding workflow-level permissions for a specific job). The available permission scopes include:
actions: read/write access to GitHub Actions secrets, configurations, and logs.checks: read/write access to check runs.contents: read/write access to repository contents. This is the most commonly modified permission.readallows cloning and fetching, whilewriteallows pushing, creating/deleting branches, and modifying files.deployments: read/write access to deployments.id-token: grants access to the OIDC identity token.issues: read/write access to issues.discussions: read/write access to discussions.metadata: read access to repository metadata (e.g., branch protection rules).packages: read/write access to GitHub Packages.pages: read/write access to GitHub Pages.pull-requests: read/write access to pull requests.repository-projects: read/write access to repository projects.security-events: read/write access to security events.secrets: read/write access to repository secrets.statuses: read/write access to commit statuses.
You can specify read, write, or none for most of these scopes. For example, contents: read grants only read access to repository contents. contents: none would prevent the token from accessing repository content at all.
If you don’t explicitly set permissions for a job, it will inherit the default permissions set at the workflow level. If no workflow-level permissions are set, it inherits the repository’s default token permissions. As of December 2023, the default for workflows is contents: read, deployments: read, discussions: none, issues: none, metadata: read, packages: read, pages: read, pull-requests: read, repository-projects: read, security-events: read, statuses: read. Crucially, contents: write is NOT the default for workflows. This means if your workflow needs to push code, you must explicitly grant contents: write.
When you set permissions in your workflow, it overrides the repository’s default token permissions for that specific job. This is where the principle of least privilege comes into play. Instead of letting the GITHUB_TOKEN have broad access, you define precisely what it needs.
For instance, a workflow that only builds and tests your code might only need contents: read. If it needs to publish an artifact, it might need packages: write. If it’s a deployment workflow, it might need deployments: write and contents: read (to pull the latest code).
The id-token permission is special. When granted, it allows your job to request an OIDC (OpenID Connect) identity token. This token can be used to authenticate with external cloud providers (like AWS, Azure, GCP) without needing to store long-lived credentials as GitHub secrets. Granting id-token: write enables the actions/oidc-array-action and allows you to request an identity token.
A common mistake is forgetting to grant contents: write when a workflow needs to push code or create branches. If your job fails with a "permission denied" error when trying to push, this is the first thing to check. Another common oversight is granting more permissions than necessary, such as contents: write when only contents: read is needed.
If you’re using a composite action that itself performs operations requiring specific permissions, the GITHUB_TOKEN permissions are evaluated for the workflow job that invokes the action, not for the action itself. The action runs with the permissions granted to the job.
The next step after fine-tuning token permissions is understanding how to use the OIDC identity token to securely access external cloud resources, eliminating the need for static secrets.