How to Scope GitHub Actions Permissions to Least Privilege With actionlint
Learn how to scope GitHub Actions workflow permissions to least privilege step by step, and use the actionlint static checker to catch mistakes, including a real gap it cannot close.
Every GitHub Actions workflow run gets its own automatically generated credential called GITHUB_TOKEN, and by default that credential can do more than most workflows actually need. If a single step in a long CI pipeline pulls in a compromised third-party action or a poisoned dependency, that step inherits whatever GITHUB_TOKEN is allowed to do for the rest of the run: read your repository, and depending on your settings, possibly push commits, create releases, or publish packages too. Scoping that token down to only what each job actually needs, the security practice called least privilege, is one of the highest-value, lowest-effort changes you can make to a CI pipeline.
Table Of Content
- What You’ll Build and Learn
- Prerequisites
- Understanding GITHUB_TOKEN and the permissions Key
- The Rule That Trips Up Almost Everyone
- The Available Permission Scopes
- Where the Repository-Wide Default Lives
- Step 1: Create a Real Project With a Naive Workflow
- Step 2: Install actionlint and Get a Baseline
- Step 3: Inventory What Each Job Actually Needs
- Step 4: Lock the Workflow Down to Least Privilege
- Step 5: Catch Real Mistakes Before They Reach GitHub
- A Misspelled Scope Name
- An Invalid Value for a Real Scope
- Step 6: The Gap actionlint Cannot Close
- Common Mistakes and Gotchas
- How to Verify It All Works End to End
- Next Steps
In this tutorial you will take a real two-job CI workflow from GitHub’s permissive default down to a properly scoped, job-by-job permission model, and validate every change with actionlint, a real static analysis tool for GitHub Actions workflow files. Every command and every error message shown here was run against a real local Git repository and a real actionlint binary while writing this post, including a deliberately broken workflow that actionlint correctly rejects, and, just as important, a deliberately under-permissioned workflow that actionlint happily approves. Seeing both cases side by side is the point: it shows you exactly where automated checking stops and your own judgment has to take over.
What You’ll Build and Learn
- What
GITHUB_TOKENis, where its default permissions come from, and the one rule about thepermissionskey that trips up almost everyone the first time. - How to inventory a real workflow, step by step, to figure out the minimum permissions it actually needs.
- How to write a job-by-job
permissionsblock that grants elevated access only to the jobs that need it. - How to install and run actionlint locally to catch permission typos and invalid values before you ever push.
- A real, reproduced demonstration of what a static linter cannot catch, and what to check instead.
Prerequisites
- Git installed locally (this tutorial was run against Git 2.55). Check your version with
git --version. - A terminal you’re comfortable typing commands into.
- Basic YAML familiarity: you should recognize a
key: valuepair and indentation-based nesting. Nothing more advanced than that is required. - No prior GitHub Actions experience assumed. Every concept is explained before it’s used.
- Optional: a GitHub.com account, only if you want to push what you build here and watch a real workflow run. Every hands-on step in this tutorial is verified locally without one.
Understanding GITHUB_TOKEN and the permissions Key
When a GitHub Actions workflow starts, GitHub automatically generates a fresh, short-lived GITHUB_TOKEN for that run and injects it into the job’s environment. Steps can use it, directly or through actions like actions/checkout, to authenticate against the GitHub API on the repository’s behalf: cloning code, posting comments, creating releases, and more. You never have to create or store this token yourself, which is convenient, but it also means every job in a workflow gets one by default, whether that job needs broad access or not.
GitHub’s own security guidance is direct about the fix. Its secure use reference states plainly: “It’s good security practice to set the default permission for the GITHUB_TOKEN to read access only for repository contents. The permissions can then be increased, as required, for individual jobs within the workflow file.” That one sentence is the entire strategy this tutorial walks through in practice.
The Rule That Trips Up Almost Everyone
The permissions key can appear at the top level of a workflow (applying to every job by default) or inside an individual jobs.<job_id> block (applying to just that job). Here is the part that catches people off guard: GitHub’s own workflow syntax reference is explicit that “if you specify the access for any of these permissions, all of those that are not specified are set to none.” In other words, adding a permissions block doesn’t add extra access on top of whatever default existed. It replaces the entire permission set for that level. Anything you don’t list is switched off, not left alone.
This matters in a very concrete way: if a job needs both actions/checkout (which needs read access to repository contents) and the ability to comment on a pull request (which needs pull request write access), and you only remember to list the pull request scope, the checkout step will fail. You’ll see exactly that failure reproduced later in this tutorial.
The Available Permission Scopes
Every scope can be set to read, write, or none (write implies read). There are also two shorthands: permissions: read-all and permissions: write-all, which apply that access level to every scope at once, and are themselves the opposite of least privilege, so treat them as an escape hatch rather than a default. The actionlint binary used throughout this tutorial (version 1.7.12) recognizes the following scopes as valid: actions, artifact-metadata, attestations, checks, contents, deployments, discussions, id-token, issues, models, packages, pages, pull-requests, repository-projects, security-events, and statuses.
Worth noting for your own projects: GitHub occasionally adds new scopes to its live documentation faster than third-party tools catch up. A linter that’s a few versions behind may not yet recognize a brand-new scope name. If actionlint ever rejects a scope you’re confident is real, check the tool’s own version against GitHub’s current GITHUB_TOKEN documentation before assuming your YAML is wrong. Treat the linter’s error message as authoritative for the version you have installed, not as the permanent final word.
Where the Repository-Wide Default Lives
Every repository also has a baseline default that applies whenever a workflow doesn’t set permissions at all. You’ll find it under Settings, then Actions, then General, in a repository you own or administer. GitHub’s own documentation describes the two choices there in exactly these words: you can let GITHUB_TOKEN “have read and write access for all permissions (the permissive setting), or just read access for the contents and packages permissions (the restricted setting).” New repositories created since February 2023 default to the restricted setting, but repositories created earlier, or repositories inside an organization with its own inherited policy, may still default to the permissive one. It’s worth checking this setting directly rather than assuming.
Step 1: Create a Real Project With a Naive Workflow
Start with a small, real project so the permissions you’ll add later have something genuine to protect. This creates a tiny Python package with one test, then initializes it as a Git repository:
mkdir gha-permissions-demo && cd gha-permissions-demo
git init
mkdir -p src tests .github/workflows
Add a trivial module and a test for it:
# src/app.py
def add(a, b):
return a + b
# tests/test_app.py
from src.app import add
def test_add():
assert add(2, 3) == 5
Now add a first, naive CI workflow that simply runs the test suite on every push and pull request. This is the kind of workflow most people write first, before thinking about permissions at all:
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.13"
- run: pip install pytest
- run: pytest -q
Notice there’s no permissions key anywhere in this file. That’s deliberate for this first version: it means the job’s GITHUB_TOKEN gets whatever the repository’s default setting is, permissive or restricted, decided entirely outside this file. That’s an easy thing to overlook when you’re reading a workflow someone else wrote.
Step 2: Install actionlint and Get a Baseline
actionlint is a free, open source (MIT licensed) static checker built specifically for GitHub Actions workflow YAML. It catches unknown keys, invalid values, broken expressions, and, most usefully for this tutorial, invalid or misspelled permission scopes, all without needing to push anything to GitHub first.
On Windows, download the release archive directly and extract the executable (no separate installer needed):
curl -sL -o actionlint.zip https://github.com/rhysd/actionlint/releases/download/v1.7.12/actionlint_1.7.12_windows_amd64.zip
unzip actionlint.zip actionlint.exe
On macOS, Homebrew is the simplest route:
brew install actionlint
On Linux, or if you have a Go toolchain anywhere, go install works identically everywhere Go runs:
go install github.com/rhysd/actionlint/cmd/actionlint@latest
Confirm it’s working:
actionlint -version
1.7.12
installed by downloading from release page
built with go1.26.1 compiler for windows/amd64
Now run it from the repository root with no arguments. actionlint automatically finds every workflow under .github/workflows, so you don’t need to point it at a specific file:
actionlint
(no output, exit code 0)
A clean run with no output and exit code 0 means the YAML is well-formed and every key, value, and expression actionlint understands checks out. It is tempting to read that as “this workflow is secure.” It isn’t. It only means the syntax is valid. The naive workflow above passes cleanly right now, and it is still relying entirely on whatever permission default your repository happens to have. Syntax validity and least privilege are two completely different questions, and the rest of this tutorial is about the second one.
Step 3: Inventory What Each Job Actually Needs
Before writing a single permissions line, work out what each job genuinely touches. This is the step most teams skip, and skipping it is exactly how you end up either copy-pasting write-all to “make it work” or removing a scope that turns out to be load-bearing.
For this tutorial, the workflow will grow to three jobs. Walking through each one:
- test: checks out the repository and runs pytest. It never calls the GitHub API itself.
actions/checkoutneeds read access to repository contents to clone the code, so this job needscontents: readand nothing else. - comment-on-pr: a new job that posts a comment on the pull request once tests pass. It still needs to check out the repository first (
contents: read), and posting a PR comment through the GitHub API needs write access to pull requests, so it needspull-requests: writetoo. - release: a new job that creates a GitHub release when a tag is pushed, using the popular softprops/action-gh-release action. That action’s own README states its requirement explicitly: “This Action requires the following permissions on the GitHub integration token:
permissions: contents: write.” That’s a genuinely useful habit worth keeping: when a third-party action needs elevated access, a well-maintained one will usually document exactly which scope, in its own README, rather than making you guess.
Notice that none of these three jobs need anything close to write-all. The most privileged single job only needs one write scope.
Step 4: Lock the Workflow Down to Least Privilege
Now apply the inventory from Step 3. First, set a restrictive default at the top of the file, matching GitHub’s own recommendation from earlier: read-only access to contents, and nothing else, unless a specific job says otherwise.
Then add the two new jobs, each with only the extra scope it needs, layered on top of the same contents: read baseline (remember the rule from earlier: since these jobs declare their own permissions block, they must repeat contents: read themselves, or checkout will have nothing):
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: "3.13"
- run: pip install pytest
- run: pytest -q
comment-on-pr:
needs: test
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/checkout@v7
- uses: actions/github-script@v9
with:
script: |
github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: "Tests passed."
})
release:
needs: test
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
Save that over the earlier version and run actionlint again:
actionlint
(no output, exit code 0)
Still clean, but now the workflow’s blast radius is dramatically smaller. If the test job’s dependency chain were ever compromised, the worst it could do with GITHUB_TOKEN is read repository contents. Only comment-on-pr can write PR comments, and only release, which only ever runs on a tag push, can write repository contents to cut a release.
Step 5: Catch Real Mistakes Before They Reach GitHub
Typos happen. Here’s what actionlint actually does when they happen in a permissions block, reproduced exactly, not paraphrased.
A Misspelled Scope Name
Suppose contents gets typed as content by mistake, in an otherwise-complete workflow:
name: CI
on:
push:
branches: [main]
permissions:
content: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
Running actionlint against that exact file produces:
test.yml:7:3: unknown permission scope "content". all available permission scopes are "actions", "artifact-metadata", "attestations", "checks", "contents", "deployments", "discussions", "id-token", "issues", "models", "packages", "pages", "pull-requests", "repository-projects", "security-events", "statuses" [permissions]
|
7 | content: read
| ^~~~~~~~
exit code: 1
That’s not just “invalid input.” It hands you the full list of valid scope names in the error itself, which is a fast way to double check the exact spelling of the one you meant.
An Invalid Value for a Real Scope
Now suppose the scope name is right, but the value isn’t (a reasonable guess, since it reads naturally in English, but it isn’t one of the three real values):
name: CI
on:
push:
branches: [main]
permissions:
contents: readonly
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
test.yml:7:13: "readonly" is invalid as permission of scope "contents". available values are "read", "write", "none" [permissions]
|
7 | contents: readonly
| ^~~~~~~~
exit code: 1
Both of these are exactly the kind of mistake that’s easy to make at 5 PM on a Friday and easy to catch in under a second locally, well before it becomes a failed workflow run or, worse, a silently over-permissioned one.
Step 6: The Gap actionlint Cannot Close
This is the part most tutorials on this topic skip, and it matters more than the syntax checking above. actionlint validates that a scope name and value are syntactically legal. It has no way of knowing whether the specific actions your workflow calls will actually succeed with the scopes you’ve granted them, because that depends on what each individual action does at runtime, not on anything visible in the YAML’s structure.
Here is that gap, reproduced for real. Take the release job from Step 4, but deliberately grant it only read access instead of the write access its own README requires:
name: CI
on:
push:
tags: ['v*']
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: softprops/action-gh-release@v3
with:
generate_release_notes: true
actionlint ci-insufficient.yml
(no output, exit code 0)
Zero errors. actionlint has no built-in knowledge of what softprops/action-gh-release needs at runtime, so as far as it’s concerned this file is perfectly valid. On real GitHub infrastructure, this job would run, reach the release-creation step, and fail there with a permissions error from the GitHub API itself, not from actionlint.
The second gap is the “unlisted scopes become none” rule from earlier, and it’s worth seeing fail too. Take a job that only lists the scope for its new feature and forgets the one an earlier step depends on:
name: CI
on:
pull_request:
jobs:
comment-on-pr:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v7
- run: echo "this step needs contents access to have run at all"
actionlint ci-missing-contents.yml
(no output, exit code 0)
Also clean. But because this job declares its own permissions block containing only pull-requests: write, its contents scope is set to none, exactly as GitHub’s documentation describes. On GitHub, actions/checkout in this job would fail immediately trying to clone with a token that has no repository access, even though the YAML is entirely well-formed and actionlint has nothing to say about it.
The lesson isn’t that actionlint is weak. It catches an entire category of mistakes instantly and for free, and you should absolutely run it. The lesson is that a clean lint result answers “is this YAML valid,” not “will this job actually succeed,” and treating those as the same question is how under- and over-permissioned workflows both slip through.
Common Mistakes and Gotchas
- Forgetting that job-level permissions replace, not extend, the workflow-level default. If a job needs an extra scope, it needs the base scopes repeated too, exactly as shown in Step 6.
- Reaching for
write-allto “make CI pass” instead of finding the one missing scope. A 403 in an Actions log almost always names the specific permission problem; read the failed step’s log before widening anything. - Assuming a clean actionlint run means the workflow is secure. As Step 6 showed, it only means the syntax is legal. Combine linting with the manual inventory from Step 3, not instead of it.
- Not checking the repository or organization’s default Workflow permissions setting. A workflow with no
permissionskey at all silently inherits whatever that setting is, which can differ between repositories, especially older ones or those inside an organization with its own policy. - Guessing what a third-party action needs instead of reading its README. As with
softprops/action-gh-releasein this tutorial, well-maintained actions usually document their exact permission requirement; that’s a faster and more reliable source than trial and error.
How to Verify It All Works End to End
Locally, before anything touches GitHub:
- Run
actionlintfrom the repository root with no arguments and confirm it exits cleanly (exit code 0, no output), as in Steps 2 and 4. - Re-read your own inventory from Step 3 against the final YAML: does every job’s
permissionsblock, if it has one, include every scope its own steps need, not just the new one you were adding?
Once you push to a real repository (optional, and the only part of this tutorial that needs a GitHub.com account), verify on GitHub itself:
- Open a pull request against the branch this workflow runs on, and watch the Actions tab. A green check on
comment-on-prplus a real “Tests passed.” comment appearing on the PR confirmspull-requests: writewas scoped correctly. - Push a tag matching the workflow’s trigger and confirm the
releasejob actually creates a release. If it instead fails with a 403-style permissions error in its log, that error will name the exact missing scope, which is your cue to add precisely that one, then re-run, rather than reaching for a broader shortcut. - As a final, stronger check, temporarily remove a scope you believe is necessary and confirm the job now fails in exactly the way you’d expect. That’s proof you found a minimum, not just “a set that happens to work.”
Next Steps
Two directions worth exploring once this workflow is in place. First, if any job in your real projects needs to authenticate to a cloud provider like AWS, Azure, or Google Cloud, look into OpenID Connect (OIDC) instead of long-lived cloud credentials stored as secrets. GitHub’s own OpenID Connect documentation describes it simply: “OpenID Connect allows your workflows to exchange short-lived tokens directly from your cloud provider.” That uses the id-token: write scope from the list in this tutorial, granted only to the specific job that deploys, and removes the need to store a cloud credential as a GitHub secret at all. Setting up the trust relationship on the cloud provider’s side needs a real cloud account to test properly, which is outside the scope of what could be verified hands-on here, so treat GitHub’s own guide as the authoritative next step.
Second, consider running actionlint automatically instead of remembering to run it by hand. Many teams add it as its own fast step at the top of their CI workflow, or as a pre-commit hook, so a bad permissions edit gets caught in seconds on the next commit instead of surfacing as a failed run later. Either way, keep coming back to the same question this tutorial centered on: not “does this pass the linter,” but “have I actually worked out the minimum this job needs.”








No Comment! Be the first one.