Fix GitHub Actions Permission Denied (EACCES, 403, Write Blocked)
Permission / AuthLast Updated: April 28, 2026 | Author: DevOps Engineering Team | Platforms: GitHub Actions, CI/CD Workflows
Quick Answer: Fix GitHub Actions “Permission Denied”
GitHub Actions throwing “Permission denied” usually means one of two things: your GITHUB_TOKEN is missing the right scope, or your step is trying to write somewhere the runner won’t let it. This guide covers GitHub Actions environments specifically.
- Git push fails? You need
permissions: contents: writeon the job - EACCES on file write? Use
$GITHUB_WORKSPACEor/tmpinstead of system dirs - Docker push to ghcr.io denied? Add
packages: writeto your permissions - Cache save returns 403? Add
actions: write - Some third-party action fails with 403? Check what scopes it needs and grant them
The root cause is almost always a missing or wrong permissions declaration, not a bug in GitHub Actions.
Common Root Causes
GITHUB_TOKENis read-only by default (security design, not a bug)- PRs from forks get an extra-restricted token that cannot write
- Org or repo settings override YAML permissions
- Runner directories like
/usr/localand/optare not writable by the runner user - Popular actions request their own permission scopes
1-Click Diagnostic Command
# Check what permissions your workflow declares
grep -A5 "permissions:" .github/workflows/*.yml
# Verify repo-level workflow permission setting
echo "Check: Settings > Actions > General > Workflow permissions"
Permission Denied Fix Map
- Root Cause
- GITHUB_TOKEN read-only (default)
- Missing permission scope in YAML
- Repo settings override YAML
- Fork PR extra restrictions
- Filesystem not writable
- Quick Fix
- Add
permissions: contents: writeto job - Use $GITHUB_WORKSPACE or /tmp for file writes
- Add packages: write for Docker push
- Add actions: write for cache save
- Add
- Permanent Fix
- Set repo to “Read and write permissions”
- Use least-privilege per job
- Use pull_request_target or workflow_run for fork PRs
- Prevention
- Always declare permissions explicitly
- Never use sudo on runners
- Avoid system directory installs
Quick Diagnosis Flow
- Read the error message carefully. Does it say
GITHUB_TOKEN?EACCES?403 Forbidden?read-only file system? Each one points to a different fix. - Identify the failing step. Is it
git push? A file write? A Docker operation? An action calling the GitHub API? - Check your YAML. Does the job have a
permissionsblock? Does it cover the right scope? - Check repo settings. Go to Settings > Actions > General > Workflow permissions. If it says “Read repository contents and packages”, that overrides your YAML. Switch to “Read and write permissions”.
Quick Verification
# Validate checkout with official token
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Confirm write access to workspace
run: |
touch $GITHUB_WORKSPACE/test-write
rm $GITHUB_WORKSPACE/test-write
echo "Write access OK"
What Does This Error Mean?
Every GitHub Actions workflow runs on a runner, an isolated VM or container. The runner gets a GITHUB_TOKEN for authentication, but by default this token is read-only. It can check out code, read files, and query the API, but it can’t push commits, create releases, upload packages, or save caches.
This is a standard security control, not a bug in GitHub Actions or your workflow.
Typical Error Output
# Git push denied
Error: fatal: could not read Username for 'https://github.com'
remote: Permission to owner/repo.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/owner/repo.git/': The requested URL returned error: 403
# File system denied
Error: EACCES: permission denied, open '/usr/local/lib/node_modules/...'
Error: EACCES: permission denied, mkdir '/home/runner/work/repo/repo/dist'
# API / token denied
Error: Resource not accessible by integration (403)
Error: Insufficient permission for the action 'actions/checkout@v4'
# Docker denied
Error: denied: requested access to the resource is denied
unauthorized: authentication required
Error Scenario Reference
| Error Message | What’s happening | The fix |
|---|---|---|
Permission to owner/repo.git denied |
Git push blocked | permissions: contents: write |
EACCES: permission denied, open |
Writing to a protected dir | Use $GITHUB_WORKSPACE or /tmp |
denied: requested access to the resource is denied |
Docker registry push blocked | permissions: packages: write |
Cache service responded with 403 |
Cache save blocked | permissions: actions: write |
Resource not accessible by integration |
API endpoint blocked | Grant the matching permission scope |
403 Forbidden |
Token missing required scope | Check YAML permissions + repo settings |
read-only file system |
System directory write attempt | Use local install path instead |
Insufficient permission |
Third-party action scope missing | Grant the specific scope it needs |
Permission Scope Quick Reference
| You’re trying to… | Permission you need |
|---|---|
| Create or update releases | contents: write |
| Open, close, or comment on issues | issues: write |
| Review or comment on pull requests | pull-requests: write |
| Create or manage deployments | deployments: write |
| Manage environments | environments: write |
| Push packages to ghcr.io | packages: write |
| Save or read action caches | actions: write |
| Trigger workflow dispatches | actions: write |
| Read secrets | Available by default, no extra scope needed |
Step-by-Step Fixes
Fix 1: Git Push Denied (Most Common)
Your workflow tries to push a commit, create a tag, or publish a release, and GitHub blocks it because the token is read-only.
Broken (no permissions block):
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: |
echo "updated" > file.txt
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "auto-update"
git push
Fixed:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- run: |
echo "updated" > file.txt
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add .
git commit -m "auto-update"
git push
One line: permissions: contents: write. This gives the job’s GITHUB_TOKEN permission to push to the repository.
Where to put permissions
# Workflow level (every job inherits it)
permissions:
contents: write
jobs:
build:
# inherits contents: write
deploy:
# inherits contents: write
# Job level (per-job, most common)
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
# Step level (rare, fine-grained)
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Push changes
permissions:
contents: write
run: git push
Fix 2: EACCES / File System Permission Denied
Your step tries to write to a directory the runner user doesn’t own. Common with global npm installs, pip installs, or tools that default to system directories.
The problem:
- run: npm install -g some-package
# EACCES: permission denied, mkdir '/usr/local/lib/node_modules/some-package'
Three ways to fix it:
# Option A: Install locally instead of globally
- run: npm install some-package
# Option B: Use a writable path under $HOME
- run: |
npm install --prefix $HOME/.local some-package
export PATH="$HOME/.local/bin:$PATH"
# Option C: Use /tmp for anything temporary
- run: |
mkdir -p /tmp/my-workspace
# do whatever you need in /tmp/my-workspace
Writable directory reference for Ubuntu runners:
| Directory | Writable? | When to use it |
|---|---|---|
$GITHUB_WORKSPACE | Yes | Your repo checkout. Default working directory. |
/tmp | Yes | Temporary files. Wiped between jobs. |
$HOME | Yes | That’s /home/runner. Good for local tool installs. |
/usr/local | No | System-level. Needs sudo. Avoid it. |
/opt | No | System-level. Not writable. |
Fix 3: Docker Push to ghcr.io Denied
Pushing images to GitHub Container Registry needs packages: write. Without it you get the generic “denied: requested access” error.
Broken (no packages permission):
- name: Push Docker image
uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
# denied: requested access to the resource is denied
Fixed:
jobs:
build-image:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
For Docker Hub you can’t use GITHUB_TOKEN. You need a Docker Hub access token saved as a repo secret:
- uses: docker/login-action@v3
with:
registry: docker.io
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
Fix 4: Cache Save Returns 403
actions/cache needs actions: write to save caches. Without it, cache restore works but save silently fails. You might not notice until the next run when the cache miss slows everything down.
# Broken - cache save returns 403
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
# Fixed - grant actions:write
permissions:
actions: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
Same applies to actions/setup-node with its built-in caching. The underlying mechanism is the same.
Fix 5: “Resource Not Accessible by Integration” (403)
The catch-all permission error. The token hit an API endpoint it’s not allowed to access. Check the permission scope reference table above and grant the matching scope.
Full permissions example for a deploy pipeline:
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write
issues: write
pull-requests: write
deployments: write
packages: write
actions: write
Don’t blindly copy this. Only grant what your workflow actually needs. The principle of least privilege exists for a reason.
Fix 6: Fork PRs (Special Case)
Workflows triggered by PRs from forks have extra restrictions:
GITHUB_TOKENis always read-only. No exceptions.- Repository secrets are not available.
- Environment secrets are not available.
Cannot override with YAML. This protects repos from malicious forks.
Your options:
# Option A: pull_request_target
# Runs in the BASE repo's context instead of the fork.
# WARNING: untrusted fork code runs with your repo's permissions.
# Only safe if you don't check out the fork's code.
on:
pull_request_target:
# Option B: workflow_run
# Trigger a separate workflow after the PR workflow finishes.
on:
workflow_run:
workflows: ["Build and Test"]
types: [completed]
# Option C: PAT stored as an org secret
# Bypasses fork restrictions entirely, but token has full repo access.
- uses: actions/checkout@v4
with:
token: ${{ secrets.PAT_WITH_WRITE_ACCESS }}
Option B is usually the safest. Option A is convenient but risky if you check out fork code.
Fix 7: Repository Settings Override
Your YAML might be perfect and still fail. Check the repo settings:
- Go to your repo > Settings
- Click Actions > General
- Scroll to “Workflow permissions”
- If it says “Read repository contents and packages permissions”, switch to “Read and write permissions”
- Save
When this is on read-only mode, it overrides everything in your YAML. Your permissions: contents: write gets silently ignored.
In organizations, the admin might have locked this setting. You’ll see “Organization settings restrict workflow permissions.” Ask the admin to change it at the org level.
FAQ (AI & Google Optimized)
Q: I added permissions: contents: write but it still fails. Why?
A: Two things to check. First, go to Settings > Actions > General > Workflow permissions. If it’s “Read repository contents”, it overrides your YAML. Switch to “Read and write permissions”. Second, make sure the permissions block is on the right job or step. If it’s on job A but the failing step is in job B, it won’t help.
Q: Can I just use sudo to fix EACCES?
A: Technically yes on hosted Ubuntu runners. But it’s a bad idea. Files created with sudo end up owned by root, which causes permission conflicts later when the runner user tries to clean up or cache things. Install to $HOME/.local or use --prefix instead.
Q: Works on main branch, fails on PR branches. What gives?
A: If the PR comes from a fork, the token is locked to read-only. No YAML permission block can change that. For fork PRs, look into pull_request_target or workflow_run events as alternatives.
Q: What’s the difference between job-level and workflow-level permissions?
A: Workflow-level applies to every job. Job-level applies to one job. Step-level applies to one step. If multiple levels specify the same scope, the most specific one wins (step > job > workflow). Most workflows only need job-level permissions.
Q: How do I figure out which scope I need for a specific action?
A: Check the action’s documentation. Most well-maintained actions list their required permissions. If they don’t, the GitHub docs have a full permission scope reference that maps each operation to its required scope.
Official Best Practices
- Prefer HTTPS +
GITHUB_TOKENover SSH for all CI workflows - Always declare permissions explicitly per job (never leave defaults)
- Store all credentials in GitHub Encrypted Secrets
- Never use sudo on runners for file system operations
- Use least-privilege Deploy Keys only when SSH is mandatory
- Use
$GITHUB_WORKSPACEor/tmpfor all file writes
Official References
- Assigning permissions to jobs – GitHub Docs
- Automatic token authentication – GitHub Docs
- Using secrets in GitHub Actions – GitHub Docs