Skip to content

Github Permission Model


Every workflow run you’ve written so far has been quietly carrying a passenger: GITHUB_TOKEN. You never generated it, never stored it as a secret, never typed its name into a with: block — and yet every actions/checkout step, every API call GitHub Actions made on your behalf, used it. It’s time to actually look at what that token can do, and more importantly, what it shouldn’t be allowed to do.

What GITHUB_TOKEN Actually is

At the start of every workflow run, GitHub mints a brand-new token, scoped to that one run, and throws it away the moment the run finishes. It’s not a personal access token, it’s not tied to your account, and it’s not something you manage in a secrets vault — GitHub handles its entire lifecycle automatically.

You’ve been using it implicitly this whole course. When actions/checkout clones your repository, it authenticates with GITHUB_TOKEN. When a workflow comments on a pull request or creates a release, it’s GITHUB_TOKEN doing the talking, unless you’ve explicitly swapped in something else.

    sequenceDiagram
    participant Trigger as Workflow trigger
    participant GH as GitHub
    participant Run as Workflow run
    participant API as GitHub API

    Trigger->>GH: Event fires (push, PR, etc.)
    GH->>Run: Mint short-lived GITHUB_TOKEN
    Run->>API: Authenticated requests (checkout, comments, etc.)
    Run->>Run: Job completes
    GH->>GH: Token expires / discarded
  

The fact that it’s short-lived and auto-rotated is genuinely good news — there’s no long-lived credential sitting in your repo settings for this one. But “auto-managed” doesn’t mean “harmless.” By default, this token can read and write to a wide range of things in your repository: contents, issues, pull requests, packages, deployments, and more.

Default Permissions, and Why They’re Too Generous

Depending on your repository or organization settings, GITHUB_TOKEN defaults to one of two permission sets:

Default settingWhat it grants
Permissive (older default)Read and write access to almost everything in the repo
Restricted (current default for new repos)Read-only access to repo contents, no write access anywhere

Even the restricted default is a blanket setting — it doesn’t know that this specific job only needs to read your code and run tests. It just grants the same broad read access to every job in every workflow, regardless of what that job actually does.

This matters more than it might seem. If an attacker manages to inject a malicious step into one of your workflows — through a compromised third-party action, for instance — that step runs with whatever permissions GITHUB_TOKEN has for that job. A token that can only read repo contents is a much smaller prize than one that can also write to packages or manage deployments.

Note

This is a different problem from the AWS credentials you set up back in the deployment section. GITHUB_TOKEN only affects what a workflow can do inside GitHub — your repo, its issues, its packages. It has nothing to do with your cloud credentials.

The permissions: Block

You can override the default and declare exactly what a workflow — or a single job — is allowed to do, using a permissions: block. It lists individual scopes (contents, issues, pull-requests, packages, and others) and sets each to read, write, or none.

Placed at the top level of the workflow file, it sets the default for every job in that file:

permissions:
  contents: read

Placed inside a specific job, it overrides the top-level setting for just that job:

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v5

You can also shut everything off entirely and grant scopes back one at a time:

permissions: {}

An empty block means “no permissions at all” — useful as a starting point when you want to reason from zero rather than from “everything except what I’ve removed.”

Least Privilege, in Practice

The principle is simple to state: a job should be able to do exactly what it needs to do, and nothing else. In practice, that means asking one question for every job in your workflow — “what does this job’s own logic actually touch?” — and writing the permissions block to match.

Take a job you’ve already built: the one that runs your test suite and uploads a coverage report as a workflow artifact. Walk through what it actually does:

  • Checks out your repository → needs contents: read
  • Runs pytest and generates a coverage report → touches nothing on GitHub itself
  • Uploads that report with actions/upload-artifact → also doesn’t need any GitHub API scope; artifact storage isn’t gated by the permissions: block

So the whole job’s real requirement is read access to the repo contents, full stop:

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - name: Checkout
        uses: actions/checkout@v5

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.13"

      - name: Install dependencies
        run: python -m pip install --upgrade pip pytest pytest-cov

      - name: Run tests with coverage
        run: python -m pytest --cov --cov-report=xml

      - name: Upload coverage report
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: coverage.xml

Before this chapter, that job would’ve run under whatever the repository-wide default was — possibly with write access to issues, pull requests, and packages it will never touch. Scoping it down to contents: read doesn’t change what the job does. It changes what damage a compromised step inside that job could do, which is the entire point.

Contrast that with a job that actually needs to write something back to GitHub — say, one that comments on a pull request with test results:

jobs:
  comment-results:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Checkout
        uses: actions/checkout@v5
      - name: Post comment
        run: gh pr comment ${{ github.event.pull_request.number }} --body "Tests passed."
        env:
          GH_TOKEN: ${{ github.token }}

Here, pull-requests: write is genuinely earned — the job’s own logic needs it. That’s the whole exercise: match the grant to the actual behavior, job by job, rather than accepting whatever the repository default hands you.

A Quick Reference For Common Scopes

You won’t use most of these day to day, but it helps to know what’s available when you’re deciding what a job actually needs:

ScopeControls
contentsReading repo files, pushing commits, creating releases
issuesReading and commenting on issues
pull-requestsReading, commenting on, and merging pull requests
packagesPublishing to GitHub Packages
deploymentsCreating and updating deployment statuses
id-tokenRequesting an OIDC token — you’ll use this one directly in a few chapters

That last one is worth remembering. It doesn’t do anything on its own yet, but it’s the exact permission that makes it possible to talk to AWS without storing a single long-lived secret — which is where this section is headed.

Tip

A good habit going forward: start every new workflow with permissions: {} or permissions: contents: read at the top level, then add scopes back only when a job’s own steps genuinely need them. It’s much easier to notice a missing permission — your job just fails with a clear error — than to notice an unused one sitting there as a liability.

Last updated on