TRENDING
Rows of identical brass-colored apartment mailboxes with small locks and name labels along an orange corridor wall
October 9, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
October 9, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
Cast-iron late Qing dynasty coin minting press with a large flywheel, displayed in a museum case
October 9, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google
Rows of closed oak library card catalog drawers, each with a brass pull and a blank label holder
October 9, 2026
How to Encrypt PII in Python and Keep It Searchable With Blind Indexes
Close-up of a vintage Western Electric manual telephone switchboard with orange lamps, red patch cords plugged into jacks, a rotary dial and a black handset
October 9, 2026
Microsoft’s Agent Lightning v1.0 Turns Agent Training Into a Sample-Accounting Problem
09 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two orange safety relief valves on grey pressure vessels in an industrial plant
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A lugworm lying on wet sand and mud at low tide
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 232 Posts
News 234 Posts
Learning Hub 204 Posts
Home/Learning Hub/How to Scope GitHub Actions Permissions to Least Privilege With actionlint
Learning Hub

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.

August 8, 2026 13 Min Read
51

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_TOKEN is, where its default permissions come from, and the one rule about the permissions key 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 permissions block 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: value pair 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/checkout needs read access to repository contents to clone the code, so this job needs contents: read and 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 needs pull-requests: write too.
  • 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-all to “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 permissions key 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-release in 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:

  1. Run actionlint from the repository root with no arguments and confirm it exits cleanly (exit code 0, no output), as in Steps 2 and 4.
  2. Re-read your own inventory from Step 3 against the final YAML: does every job’s permissions block, 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:

  1. Open a pull request against the branch this workflow runs on, and watch the Actions tab. A green check on comment-on-pr plus a real “Tests passed.” comment appearing on the PR confirms pull-requests: write was scoped correctly.
  2. Push a tag matching the workflow’s trigger and confirm the release job 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.
  3. 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.”

Tags:

CI/CD SecurityDevSecOpsGitHub ActionsSupply Chain SecurityYAML

Share

U.S. Air National Guard intelligence analysts monitor computer workstations while conducting defensive cyber operations
Previous Post

Google’s New Threat Actor Codenames Show Why Cybersecurity Still Can’t Agree on a Name

A man works on a laptop at a desk in a modern office coworking space
Next Post

Zscaler Finds Ransomware Crews Targeting Managers Over the C-Suite

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Oct
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
08 Oct
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
Trending
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
October 8, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
October 8, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026