GitHub Actions — Permissions¶
Start Here — What Is a Permission?¶
When GitHub Actions runs a workflow, it needs to do things — push code, publish a website, talk to a cloud provider, read your repo. But doing things requires access. GitHub does not give your workflow unlimited access by default. Instead, it gives a token with specific permissions you define.
Think of it like a hotel key card: - The hotel (GitHub) creates a key card (token) for your stay (workflow run) - The key card only opens the doors you need — not the kitchen, not the server room - When you check out (workflow ends), the key card is destroyed
This key card is called secrets.GITHUB_TOKEN.
What Is secrets.GITHUB_TOKEN?¶
When a workflow run starts, GitHub automatically creates a temporary token. You never create it yourself — GitHub generates it, injects it into the workflow as secrets.GITHUB_TOKEN, uses it during the run, then destroys it when the run ends.
This token is what your workflow uses to: - Log in to GitHub Container Registry (GHCR) - Push commits back to the repository - Publish to GitHub Pages - Comment on pull requests - And more
How it appears in a workflow¶
For example, to log in to GHCR before pushing a Docker image:
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} # ← the auto-generated token
A registry login needs three things: a registry URL, a username, and a password. GitHub uses the auto-generated token as the password. You do not need to create or store this password — GitHub handles it.
The Two Access Levels¶
For every permission, there are two possible values:
| Value | What it means |
|---|---|
read | The workflow can see this thing but cannot change it |
write | The workflow can see AND modify this thing |
write always includes read — if you can write, you can also read.
GitHub's Permission Sections¶
GitHub divides its services into sections. Each section can be granted read or write independently. Here are the most important ones:
| Section | What it controls |
|---|---|
contents | Repository code, files, branches, commits, releases |
packages | GitHub Container Registry (GHCR) — pushing/pulling images |
pages | GitHub Pages — publishing static websites |
id-token | OIDC token — passwordless login to AWS, GCP, Azure |
pull-requests | Opening, commenting on, and merging pull requests |
actions | Triggering and managing other workflows |
You declare these in a permissions: block in your workflow file.
How to Write Permissions¶
Basic syntax¶
permissions:
contents: read
packages: write
This means: - contents: read — the workflow can read your repo code but cannot push or commit - packages: write — the workflow can push images to GHCR
Two shorthand values¶
permissions: read-all # every section gets read access
permissions: write-all # every section gets write access (dangerous)
write-all is almost always wrong — it gives the workflow more power than it needs.
Deny everything (most secure starting point)¶
permissions: {}
This gives the workflow no access at all. Then you add back only what is needed.
Where to Put the Permissions Block¶
You can put permissions: at two levels:
1. Workflow level (applies to every job)¶
name: My Workflow
on: [push]
permissions: # ← applies to ALL jobs
contents: read
packages: write
jobs:
build:
...
deploy:
...
2. Job level (applies only to that specific job)¶
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # ← only this job gets this
steps:
...
deploy:
runs-on: ubuntu-latest
permissions:
packages: write # ← only this job gets this
steps:
...
Which should you use?¶
| Situation | Recommendation |
|---|---|
| Single-job workflow | Workflow level is fine |
| Multi-job workflow (build → test → deploy) | Job level — only the deploy job gets write access |
| Security-sensitive / production | Always job level, minimal scope |
The Five Most Common Permission Setups¶
1. Pushing a Docker image to GHCR¶
permissions:
contents: read
packages: write
contents: read— workflow needs to read your repo code to build the imagepackages: write— workflow needs to push the image to GHCR
Wrong version:
permissions:
contents: write # ❌ unnecessary — you are not committing anything back
packages: write
contents: write when you only need to read is giving away more access than needed. 2. Deploying a static site to GitHub Pages (community action)¶
permissions:
contents: write
Used with peaceiris/actions-gh-pages. This action pushes your built site as a commit to the gh-pages branch — which is why it needs contents: write.
Wrong version:
permissions:
pages: write # ❌ this does NOT work with peaceiris action
contents: write
pages: write is only for GitHub's own official deploy action, not the community one. 3. Deploying to GitHub Pages (official GitHub action)¶
permissions:
pages: write
id-token: write
contents: read
Used with actions/deploy-pages@v4. GitHub's official Pages action uses OIDC (id-token) to authenticate with the Pages service directly — it does not push to gh-pages branch at all.
Wrong version:
permissions:
contents: write # ❌ not needed — this action does not commit to the repo
pages: write
4. Deploying to AWS / GCP / Azure without passwords (OIDC)¶
permissions:
id-token: write
contents: read
id-token: write lets the workflow mint a short-lived OIDC token that cloud providers (AWS, GCP, Azure) accept as proof of identity — no stored secrets needed.
Wrong version:
permissions: write-all # ❌ massively over-permissioned
5. Read-only workflow (just running tests)¶
permissions:
contents: read
Or simply:
permissions: read-all
A test-only workflow does not need to write anything.
How the Token Behaves Differently Based on Who Triggers the Workflow¶
This is critical to understand when working with pull requests.
| Who triggers the workflow | Token power |
|---|---|
| You, pushing to your own branch | Full — reads and writes work |
| You, opening a PR from your own branch | Full — reads and writes work |
| A stranger opening a PR from a fork | Read only — write is blocked by GitHub automatically |
The third row is GitHub's automatic security behavior. You do not write this anywhere — GitHub enforces it regardless of your permissions: block. This prevents a malicious contributor from pushing code that steals your secrets or publishes rogue images to your registry.
Practical example — your Docker image workflow¶
- name: Build and push
uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
This line means:
PR opened → github.event_name = "pull_request"
→ 'pull_request' != 'pull_request' = FALSE
→ push: false
→ image is built but NOT pushed to GHCR ✅
PR merged → github.event_name = "push"
→ 'push' != 'pull_request' = TRUE
→ push: true
→ image is built AND pushed to GHCR ✅
Wrong version:
push: true # ❌ image pushed on every PR, every commit, every trigger
This would publish an unreviewed image every time anyone opens a PR.
Correct vs Wrong — Side-by-Side Reference¶
| Goal | Correct | Wrong | Why wrong |
|---|---|---|---|
| Push Docker image to GHCR | packages: write + contents: read | write-all | Over-permissioned |
| Deploy with peaceiris Pages action | contents: write | pages: write | Wrong permission for this action |
| Deploy with official Pages action | pages: write + id-token: write | contents: write | Not how official action works |
| Only push on merge, not on PR | push: ${{ github.event_name != 'pull_request' }} | push: true | Publishes on unreviewed PRs |
| Multi-job pipeline | Job-level permissions only on deploy job | Workflow-level write-all | Every job gets write access it doesn't need |
| Protect against fork PRs | GitHub handles this automatically | Enabling fork write access in repo settings | Fork PR could push to your registry |
The Secure Pattern (Best Practice Template)¶
name: Build and Deploy
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
permissions: {} # deny everything at workflow level
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read # only read — nothing to write here
steps:
- uses: actions/checkout@v4
- name: Build
run: make build
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # only this job can push to GHCR
steps:
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
push: ${{ github.event_name != 'pull_request' }}
Key Rules to Remember¶
secrets.GITHUB_TOKENis auto-generated — you never create or store it- The
permissions:block controls what that token is allowed to do writealways includesread- Fork PRs automatically get read-only tokens — this is not configurable in the workflow, it is a GitHub platform rule
push: ${{ github.event_name != 'pull_request' }}ensures images are only published after merge, never during PR review- Always start with the minimum permission needed, then add more only if the workflow fails
- Job-level permissions are safer than workflow-level in multi-job pipelines