Quick Navigation

Fix GitHub Actions Permission Denied (EACCES, 403, Write Blocked)

Permission / Auth

Last 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: write on the job
  • EACCES on file write? Use $GITHUB_WORKSPACE or /tmp instead of system dirs
  • Docker push to ghcr.io denied? Add packages: write to 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_TOKEN is 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/local and /opt are 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: write to job
    • Use $GITHUB_WORKSPACE or /tmp for file writes
    • Add packages: write for Docker push
    • Add actions: write for cache save
  • 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

  1. Read the error message carefully. Does it say GITHUB_TOKEN? EACCES? 403 Forbidden? read-only file system? Each one points to a different fix.
  2. Identify the failing step. Is it git push? A file write? A Docker operation? An action calling the GitHub API?
  3. Check your YAML. Does the job have a permissions block? Does it cover the right scope?
  4. 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 releasescontents: write
Open, close, or comment on issuesissues: write
Review or comment on pull requestspull-requests: write
Create or manage deploymentsdeployments: write
Manage environmentsenvironments: write
Push packages to ghcr.iopackages: write
Save or read action cachesactions: write
Trigger workflow dispatchesactions: write
Read secretsAvailable 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:

DirectoryWritable?When to use it
$GITHUB_WORKSPACEYesYour repo checkout. Default working directory.
/tmpYesTemporary files. Wiped between jobs.
$HOMEYesThat’s /home/runner. Good for local tool installs.
/usr/localNoSystem-level. Needs sudo. Avoid it.
/optNoSystem-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_TOKEN is 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:

  1. Go to your repo > Settings
  2. Click Actions > General
  3. Scroll to “Workflow permissions”
  4. If it says “Read repository contents and packages permissions”, switch to “Read and write permissions”
  5. 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_TOKEN over 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_WORKSPACE or /tmp for all file writes

Official References


Related High-Performance Guides

Scroll to Top