Skip to content

Fix: GitHub Actions permission denied (EACCES, 403, or Permission to X denied)

FixDevs · (Updated: )

Part of:  Docker, DevOps & Infrastructure

Quick Answer

How to fix GitHub Actions permission denied errors caused by GITHUB_TOKEN permissions, checkout issues, artifact access, npm/pip cache, and Docker socket access.

The Error

Your GitHub Actions workflow fails with:

Error: HttpError: Resource not accessible by integration

Or variations:

remote: Permission to user/repo.git denied to github-actions[bot].
fatal: unable to access 'https://github.com/user/repo.git/': The requested URL returned error: 403
Error: EACCES: permission denied, open '/home/runner/.npm/_cacache/...'
Error: Process completed with exit code 128.
hint: The 'user/repo' repository doesn't match the 'push' event
Error: Resource not accessible by personal access token

The workflow does not have permission to perform the action. GitHub Actions uses tokens with specific scopes, and the default permissions might not be enough.

Why This Happens

Every GitHub Actions workflow runs with an automatically generated token called GITHUB_TOKEN. The token is scoped to the repository running the workflow and inherits its permissions from three layered sources: the organization’s default policy, the repository’s “Workflow permissions” setting, and the explicit permissions: block in the YAML. The most restrictive of the three wins. If your repository ships with the post-2023 default (“Read repository contents and packages permissions”), every workflow starts read-only and any write operation — pushing a commit, posting a PR comment, publishing a package — fails until you grant it.

The token also changes shape based on the triggering event. A push to your own branch gets a fully scoped token. A pull_request from a fork gets a read-only token regardless of permissions:, because forks can run untrusted code and GitHub does not want a malicious PR to escalate into write access on the upstream repo. A pull_request_target from a fork runs with the upstream’s token and the base branch’s code, which is powerful but also the source of many supply-chain incidents — never check out and execute the fork’s code under this trigger.

EACCES errors that mention /home/runner/... are a separate class. They are not about the GitHub token at all; they are real filesystem permission errors inside the runner VM. Usually they happen because a previous step ran as root (Docker container actions default to root) and left files behind that the next non-root step cannot overwrite. Knowing which class of error you are looking at — token scope vs filesystem ownership — is the difference between five minutes and five hours of debugging.

Common causes:

  • Default token permissions too restrictive. GitHub’s default is read-only for most permissions since 2023.
  • Pushing code requires write access. The default token cannot push to the repo without explicit contents: write.
  • Creating PRs or issues requires write access. The default token cannot interact with issues/PRs without pull-requests: write.
  • Accessing other repositories. The GITHUB_TOKEN only works for the current repository.
  • Forked repository restrictions. Pull requests from forks have read-only tokens for security.
  • File permission issues in the runner. npm, pip, or Docker need specific file permissions.

Platform and Environment Differences

The runner you choose changes the OS, the default shell, the user account, and even the available tooling. A workflow that passes on ubuntu-latest may fail on windows-latest with what looks like the same permission error but is actually rooted in a completely different cause.

GitHub-hosted Ubuntu runners historically tracked Ubuntu 22.04, then rolled ubuntu-latest to 24.04 in late 2024 / early 2025. The runner user is runner (uid 1001), the home directory is /home/runner, and Docker is preinstalled with the runner in the docker group. EACCES on /home/runner/.npm almost always means a previous container step ran as root and wrote into that directory; fix it by setting --user $(id -u):$(id -g) on the docker run, or by running sudo chown -R runner:runner ~/.npm before the failing step.

macos-latest runners run on Apple Silicon (macos-14 / macos-15). They have Xcode installed but the exact version is pinned to a specific macos image release — xcode-select -p may point to a different Xcode than your local machine, and a permission-denied error on signing or notarization usually means the keychain is locked or the provisioning profile does not match. Use actions/cache rather than manual /Users/runner/Library/Caches access, because the cache action handles the macOS-specific path quoting and ACLs correctly.

windows-latest runners default the shell to PowerShell, not bash. A line like chmod +x script.sh silently does nothing on Windows runners because the underlying NTFS does not have a POSIX permission bit. Setting defaults: run: shell: bash at the workflow level falls back to Git Bash, which makes Linux-style commands behave but still does not give real POSIX permissions. For Windows-only steps that need to elevate, use runas or run the action as administrator via the runner’s service account — there is no sudo equivalent.

Self-hosted runners run as whatever user installed them. If the install was done with sudo, the runner runs as root and every file it writes is root-owned; the next workflow that tries to clean up as a different user fails with EACCES. The official advice is to install the runner as a dedicated non-root user, give that user passwordless sudo only for the commands it actually needs, and never share a runner between trust boundaries.

GITHUB_TOKEN default permission tightening (2023) changed the baseline for all new repositories. Repos created before April 2023 inherit the old default (“Read and write permissions”); repos created after that inherit the new restrictive default. The org-level setting under Settings then Actions then General can lock the default for all repos in the org. If your workflow worked on an old repo and fails on a new one with identical YAML, this is almost always the cause.

Container actions vs JS actions vs composite actions scope permissions differently. JS actions run directly on the runner with the runner’s environment. Container actions run inside a Docker image as root by default, which is why files created by a container action are root-owned and trigger EACCES for subsequent non-root steps. Composite actions inherit the calling job’s environment and permissions — they cannot escalate.

Fix 1: Set Workflow Permissions

Add explicit permissions at the workflow or job level:

Workflow-level permissions:

name: CI
on: push

permissions:
  contents: write
  pull-requests: write
  issues: write
  packages: write

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Has write access"

Job-level permissions (more granular):

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v4

  deploy:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pages: write
      id-token: write
    steps:
      - run: echo "Deploy with write access"

Common permission scopes:

PermissionUse case
contents: readCheckout code
contents: writePush commits, create releases
pull-requests: writeComment on PRs, create PRs
issues: writeCreate/comment on issues
packages: writePush to GitHub Container Registry
pages: writeDeploy to GitHub Pages
id-token: writeOIDC authentication (AWS, GCP)
actions: readRead workflow runs

Pro Tip: Always use the minimum permissions needed. Setting permissions: write-all works but is a security risk. List only the specific permissions your workflow requires. This follows the principle of least privilege.

Fix 2: Fix Git Push Permissions

Pushing commits from a workflow requires contents: write:

jobs:
  auto-fix:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v4
      - run: |
          git config user.name "github-actions[bot]"
          git config user.email "github-actions[bot]@users.noreply.github.com"
          # Make changes...
          git add .
          git commit -m "Auto-fix"
          git push

If push still fails with 403:

The actions/checkout action uses the GITHUB_TOKEN by default. Verify the token has write access:

- uses: actions/checkout@v4
  with:
    token: ${{ secrets.GITHUB_TOKEN }}
    persist-credentials: true

For pushing to a different branch:

- run: |
    git checkout -b auto-fixes
    git push origin auto-fixes

For general git push errors, see Fix: error: failed to push some refs.

Fix 3: Fix Repository Settings

The repository’s default permissions might override workflow settings:

  1. Go to SettingsActionsGeneral

  2. Under Workflow permissions, select:

    • Read and write permissions (for most use cases)
    • Or keep Read repository contents and set permissions per workflow
  3. Check Allow GitHub Actions to create and approve pull requests if your workflow creates PRs.

For organization repositories:

Organization-level settings can restrict Actions permissions for all repos. Check:

Settings → Actions → General (at the organization level).

Common Mistake: Setting permissions: contents: write in the workflow but the repository setting restricts the token to read-only. The repository setting takes precedence. Ask a repository admin to update the settings.

Fix 4: Fix Forked Repository Permissions

Pull requests from forks always have read-only GITHUB_TOKEN. This is a security feature — a fork could run malicious code with write access to the upstream repo.

Workaround — use pull_request_target for trusted operations:

on:
  pull_request_target:
    types: [opened, synchronize]

jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - run: |
          gh pr edit ${{ github.event.pull_request.number }} --add-label "needs-review"
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Warning: pull_request_target runs with the base branch’s code and token permissions. Never checkout and execute code from the forked PR with pull_request_target — this is a security vulnerability.

Fix 5: Fix npm and pip Cache Permissions

File permission errors in the GitHub Actions runner:

Error: EACCES: permission denied, mkdir '/home/runner/.npm'

Fix for npm:

- uses: actions/setup-node@v4
  with:
    node-version: 20
    cache: 'npm'

- run: npm ci

The actions/setup-node action handles caching properly. If you manage caching manually:

- name: Cache npm
  uses: actions/cache@v4
  with:
    path: ~/.npm
    key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}

Fix for pip:

- uses: actions/setup-python@v5
  with:
    python-version: '3.12'
    cache: 'pip'

- run: pip install -r requirements.txt

For npm permission errors in general, see Fix: npm EACCES permission denied global install.

Fix 6: Use a Personal Access Token (PAT)

When the GITHUB_TOKEN is insufficient (e.g., triggering workflows in other repos, accessing private repos):

  1. Create a PAT at Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens
  2. Add it as a repository secret: Settings → Secrets → Actions → New repository secret
  3. Use it in the workflow:
steps:
  - uses: actions/checkout@v4
    with:
      token: ${{ secrets.MY_PAT }}

  - run: |
      git push
    env:
      GITHUB_TOKEN: ${{ secrets.MY_PAT }}

For cross-repository access:

- uses: actions/checkout@v4
  with:
    repository: org/other-repo
    token: ${{ secrets.MY_PAT }}

Note: PATs have broader access than GITHUB_TOKEN. Use fine-grained tokens with minimum required permissions, and set an expiration date.

Fix 7: Fix Docker Permissions

Docker commands might need special permissions in the runner:

- name: Build and push
  run: |
    docker build -t my-image .
    docker push ghcr.io/user/my-image

Fix: Log in to the container registry:

- name: Login to GHCR
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
  run: |
    docker build -t ghcr.io/${{ github.repository }}/my-image .
    docker push ghcr.io/${{ github.repository }}/my-image

Requires packages: write permission:

permissions:
  packages: write
  contents: read

For Docker permission issues outside GitHub Actions, see Fix: Docker volume permission denied.

Fix 8: Fix GitHub Pages Deployment

Deploying to GitHub Pages requires specific permissions:

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/configure-pages@v4
      - uses: actions/upload-pages-artifact@v3
        with:
          path: dist
      - id: deployment
        uses: actions/deploy-pages@v4

Also enable Pages in the repository: Settings → Pages → Source: GitHub Actions.

Still Not Working?

Check the Actions log for the exact error. The error message usually specifies which permission is missing.

Check for branch protection rules. If the target branch has protection rules requiring PR reviews or status checks, the GITHUB_TOKEN cannot bypass them (even with contents: write).

Check for CODEOWNERS. If the repository has a CODEOWNERS file requiring specific reviewers, automated PRs might be blocked.

Check for IP allowlists. GitHub Enterprise organizations might restrict Actions to specific IP ranges.

Check token expiration. Fine-grained PATs expire. If a workflow suddenly stops working, check if the PAT expired.

Debug the GITHUB_TOKEN permissions:

- name: Check token permissions
  run: |
    curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
      https://api.github.com/repos/${{ github.repository }} | jq '.permissions'

Check runner image churn. ubuntu-latest, macos-latest, and windows-latest are floating tags. When GitHub bumps them (e.g., 22.04 to 24.04 in late 2024), preinstalled tool versions move with them and some paths change. A workflow that hardcoded /usr/lib/python3.10/ breaks the day the image switches to 3.12. Pin to an explicit image (ubuntu-22.04, macos-14) when reproducibility matters and watch the runner-images repo for deprecation announcements.

Check root-owned files from a previous container action. If an earlier step used a uses: docker://... or container-based action that ran as root, files written under $GITHUB_WORKSPACE are root-owned. A later non-root step then fails with EACCES when trying to write into the same directory. Add a cleanup step: sudo chown -R $(id -u):$(id -g) ${{ github.workspace }} after the offending action.

Check shell differences on Windows. A step that runs chmod +x on windows-latest is a no-op because NTFS does not store the executable bit the same way. Subsequent ./script.sh invocations then fail with “Permission denied” even though the chmod step reported success. Either set shell: bash and rely on Git Bash’s POSIX emulation, or run the script with an explicit interpreter (bash ./script.sh) to bypass the executable-bit check entirely.

For other GitHub Actions failures, see Fix: GitHub Actions process completed with exit code 1.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles