Fix: GitHub Actions permission denied (EACCES, 403, or Permission to X denied)
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 integrationOr 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: 403Error: 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' eventError: Resource not accessible by personal access tokenThe 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_TOKENonly 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:
| Permission | Use case |
|---|---|
contents: read | Checkout code |
contents: write | Push commits, create releases |
pull-requests: write | Comment on PRs, create PRs |
issues: write | Create/comment on issues |
packages: write | Push to GitHub Container Registry |
pages: write | Deploy to GitHub Pages |
id-token: write | OIDC authentication (AWS, GCP) |
actions: read | Read workflow runs |
Pro Tip: Always use the minimum permissions needed. Setting
permissions: write-allworks 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 pushIf 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: trueFor pushing to a different branch:
- run: |
git checkout -b auto-fixes
git push origin auto-fixesFor 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:
Go to Settings → Actions → General
Under Workflow permissions, select:
- Read and write permissions (for most use cases)
- Or keep Read repository contents and set permissions per workflow
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: writein 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 ciThe 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.txtFor 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):
- Create a PAT at Settings → Developer Settings → Personal Access Tokens → Fine-grained tokens
- Add it as a repository secret: Settings → Secrets → Actions → New repository secret
- 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-imageFix: 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-imageRequires packages: write permission:
permissions:
packages: write
contents: readFor 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@v4Also 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.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: GitHub Actions Artifacts Not Working — Upload Fails, Download Empty, or Artifact Not Found
How to fix GitHub Actions artifact issues — upload-artifact path patterns, download-artifact across jobs, retention days, artifact name conflicts, and the v3 to v4 migration.
Fix: GitHub Actions Secret Not Available — Environment Variable Empty in Workflow
How to fix GitHub Actions secrets that appear empty or undefined in workflows — secret scope, fork PR restrictions, environment protection rules, secret names, and OIDC alternatives.
Fix: GitHub Actions Matrix Strategy Not Working — Jobs Not Running or Failing
How to fix GitHub Actions matrix strategy issues — matrix expansion, include/exclude patterns, failing fast, matrix variable access, and dependent jobs with matrix outputs.
Fix: GitHub Actions Docker Build and Push Failing
How to fix GitHub Actions Docker build and push errors — registry authentication, image tagging, layer caching, multi-platform builds, and GHCR vs Docker Hub setup.