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 setting | What 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: readPlaced 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@v5You 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
pytestand 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 thepermissions: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.xmlBefore 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:
| Scope | Controls |
|---|---|
contents | Reading repo files, pushing commits, creating releases |
issues | Reading and commenting on issues |
pull-requests | Reading, commenting on, and merging pull requests |
packages | Publishing to GitHub Packages |
deployments | Creating and updating deployment statuses |
id-token | Requesting 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.