Diagnostic GitHub Actions
GITHUB_TOKEN is read-only and the error just says 403
The workflow builds fine and then dies on the last step with 403: Resource not accessible by integration. The token is right there in the environment. Nothing changed in the code. What changed, some time ago and for everyone, is the default: GITHUB_TOKEN now starts read-only, and any step that pushes a commit, cuts a release or comments on an issue needs the permission granted explicitly.
The default permission set for GITHUB_TOKEN is read-only. Writing anything — a commit, a tag, a release, a comment, a package — needs a permissions: block naming the scope.
The error message never says which scope is missing, which is what makes it slow to diagnose. Add the narrowest permission the job actually needs, at job level rather than workflow level, so one step that needs write access does not grant it to every other job in the file.
The problem in plain words
Resource not accessible by integration is the same message whether you are missing contents: write, packages: write, issues: write or id-token: write. It names no scope and suggests no fix.
The confusion deepens because the same workflow may have worked in an older repository. The default changed for new repositories and organisations, so two repositories in the same account can behave differently with identical YAML — which sends people looking for a difference in the code that is not there.
Why it happens
The default is deliberately restrictive. A token with write access to everything, available to every workflow including ones triggered by outside contributions, is a large blast radius. Read-only by default is the right call and it broke a lot of workflows written before it.
The API cannot tell you what it wanted. Reporting the missing scope would tell an attacker what to aim for, so the message stays generic. That is defensible security and unhelpful debugging, and you have to reason about it from the operation instead.
Declaring one permission drops all the others. A permissions: block is a complete replacement, not an addition. Adding contents: write to a job that also comments on the PR removes its pull-requests: write, so fixing one 403 produces another.
How to fix it
Check the repository default first
Two settings interact: the organisation or repository default, and any permissions: in the workflow. Read the default before editing YAML.
gh api repos/OWNER/REPO/actions/permissions/workflow \
--jq '{default:.default_workflow_permissions, canApprovePR:.can_approve_pull_request_reviews}'
Map the failing operation to its scope
The message will not tell you, so work backwards from what the step does. Pushing commits or tags needs contents: write. Creating a release needs contents: write. Commenting on a PR needs pull-requests: write. Publishing a package needs packages: write. OIDC for cloud auth needs id-token: write.
Grant at job level, and grant everything that job needs at once
A block replaces the defaults rather than extending them, so list every scope the job uses together.
jobs:
release:
permissions:
contents: write # push tags, create the release
pull-requests: write # comment with the release notes
runs-on: ubuntu-latest
Job level, not workflow level: the build job has no business holding write access.
Audit the repositories that do not fail
An organisation that still defaults to permissive is a bigger risk than a 403. The script reports both directions — workflows that will fail, and repositories where every token can write.
How to check it worked
Re-run the failing job. If it fails again with the same 403, the scope was wrong rather than absent — check the operation against the list above. Then confirm the grant is as narrow as you think:
gh run view RUN_ID --json jobs --jq '.jobs[].name'
gh api repos/OWNER/REPO/actions/permissions/workflow --jq '.default_workflow_permissions'
The full code
The script reports each repository's default workflow permission and scans workflow files for jobs that perform a write without declaring a matching scope. It also flags the opposite problem: repositories still defaulting to write, where every workflow holds more access than it needs.
"""Audit GITHUB_TOKEN permissions: too little to work, or more than needed.
The 403 message never names the missing scope, so this maps the operation a job
performs back to the scope it requires. It also flags repositories still defaulting
to write permissions, which is the same problem pointing the other way.
"""
import argparse
import logging
import os
import re
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("actions_permissions_audit")
API = "https://api.github.com"
# Operations that write, and the scope each one needs. The API will not tell you.
WRITE_HINTS = [
(re.compile(r"\bgit\s+push\b|actions/create-release|softprops/action-gh-release"),
"contents: write"),
(re.compile(r"gh\s+release\s+create|\bgh\s+api\b.*releases"), "contents: write"),
(re.compile(r"gh\s+pr\s+comment|actions/github-script.*createComment"),
"pull-requests: write"),
(re.compile(r"docker/build-push-action|npm\s+publish|gh\s+api.*packages"),
"packages: write"),
(re.compile(r"aws-actions/configure-aws-credentials|id-token"), "id-token: write"),
]
def needed_scopes(workflow_text):
"""Pure decision function: which scopes does this workflow appear to need?"""
return sorted({scope for pattern, scope in WRITE_HINTS
if pattern.search(workflow_text)})
def declared_scopes(workflow_text):
"""Scopes the workflow actually grants, anywhere in the file."""
return sorted(set(re.findall(r"^\s*([a-z-]+):\s*write\s*$", workflow_text, re.M)))
def gaps(workflow_text):
"""What the workflow needs but has not granted."""
have = {s.split(":")[0] for s in declared_scopes(workflow_text)}
return [s for s in needed_scopes(workflow_text) if s.split(":")[0].strip() not in have]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--repo", required=True)
ap.add_argument("--workflow-dir", default=".github/workflows")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN")
return 2
headers = {"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json"}
r = requests.get(f"{API}/repos/{args.repo}/actions/permissions/workflow",
headers=headers, timeout=30)
if r.ok:
default = r.json().get("default_workflow_permissions")
if default == "write":
log.warning("%s defaults to WRITE: every workflow token can push, release "
"and comment whether it needs to or not", args.repo)
else:
log.info("%s defaults to %s", args.repo, default)
from pathlib import Path
failed = False
for wf in sorted(Path(args.workflow_dir).glob("*.y*ml")):
text = wf.read_text(encoding="utf-8")
missing = gaps(text)
if missing:
failed = True
log.error("%s needs %s but does not declare it",
wf.name, ", ".join(missing))
else:
log.info("%s: permissions look sufficient", wf.name)
if failed:
log.error("a missing scope surfaces as: 403 Resource not accessible by "
"integration -- the message never says which one")
return 1 if failed else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Audit GITHUB_TOKEN permissions: too little to work, or more than needed.
*
* The 403 message never names the missing scope, so this maps the operation a job
* performs back to the scope it requires.
*/
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
const API = 'https://api.github.com';
// Operations that write, and the scope each one needs. The API will not tell you.
const WRITE_HINTS = [
[/\bgit\s+push\b|actions\/create-release|softprops\/action-gh-release/, 'contents: write'],
[/gh\s+release\s+create|\bgh\s+api\b.*releases/, 'contents: write'],
[/gh\s+pr\s+comment|actions\/github-script.*createComment/, 'pull-requests: write'],
[/docker\/build-push-action|npm\s+publish|gh\s+api.*packages/, 'packages: write'],
[/aws-actions\/configure-aws-credentials|id-token/, 'id-token: write'],
];
/** Pure decision function: which scopes does this workflow appear to need? */
export function neededScopes(text) {
return [...new Set(WRITE_HINTS.filter(([re]) => re.test(text)).map(([, s]) => s))].sort();
}
export function declaredScopes(text) {
return [...new Set([...text.matchAll(/^\s*([a-z-]+):\s*write\s*$/gm)].map((m) => m[1]))].sort();
}
export function gaps(text) {
const have = new Set(declaredScopes(text));
return neededScopes(text).filter((s) => !have.has(s.split(':')[0].trim()));
}
async function main() {
const repo = process.argv[process.argv.indexOf('--repo') + 1];
const dir = '.github/workflows';
const token = process.env.GITHUB_TOKEN;
if (!token) { console.error('set GITHUB_TOKEN'); process.exit(2); }
const res = await fetch(`${API}/repos/${repo}/actions/permissions/workflow`, {
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json' },
});
if (res.ok) {
const { default_workflow_permissions: d } = await res.json();
if (d === 'write') console.warn(`${repo} defaults to WRITE: every workflow token can push`);
else console.log(`${repo} defaults to ${d}`);
}
let failed = false;
for (const f of (await readdir(dir)).filter((n) => /\.ya?ml$/.test(n))) {
const text = await readFile(path.join(dir, f), 'utf8');
const missing = gaps(text);
if (missing.length) { failed = true; console.error(`${f} needs ${missing.join(', ')}`); }
else console.log(`${f}: permissions look sufficient`);
}
if (failed) {
console.error('a missing scope surfaces as: 403 Resource not accessible by integration');
}
process.exit(failed ? 1 : 0);
}
if (import.meta.url === `file://${process.argv[1]}`) main();
Add a test
The mapping from operation to scope is the whole value here, since the API refuses to tell you. Worth testing that a workflow which already grants what it needs is not flagged, or the report becomes noise people ignore.
from actions_permissions_audit import needed_scopes, declared_scopes, gaps
PUSHES = """
jobs:
release:
steps:
- run: git push origin main
"""
PUSHES_WITH_GRANT = """
jobs:
release:
permissions:
contents: write
steps:
- run: git push origin main
"""
def test_a_push_needs_contents_write():
assert "contents: write" in needed_scopes(PUSHES)
def test_a_workflow_that_grants_what_it_needs_is_not_flagged():
assert gaps(PUSHES_WITH_GRANT) == []
def test_a_workflow_missing_the_grant_is_flagged():
assert gaps(PUSHES) == ["contents: write"]
def test_declared_scopes_are_read_from_anywhere_in_the_file():
assert "contents" in declared_scopes(PUSHES_WITH_GRANT)
def test_a_read_only_workflow_needs_nothing():
assert needed_scopes("jobs:\n test:\n steps:\n - run: pytest") == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { neededScopes, gaps } from './actions-permissions-audit.mjs';
const PUSHES = 'jobs:\n release:\n steps:\n - run: git push origin main\n';
const GRANTED = 'jobs:\n release:\n permissions:\n contents: write\n'
+ ' steps:\n - run: git push origin main\n';
test('a push needs contents: write', () => {
assert.ok(neededScopes(PUSHES).includes('contents: write'));
});
test('a workflow that grants what it needs is not flagged', () => {
assert.deepEqual(gaps(GRANTED), []);
});
test('a workflow missing the grant is flagged', () => {
assert.deepEqual(gaps(PUSHES), ['contents: write']);
});
test('a read-only workflow needs nothing', () => {
assert.deepEqual(neededScopes('jobs:\n test:\n steps:\n - run: pytest'), []);
});
FAQ
What does 'Resource not accessible by integration' mean?
The GITHUB_TOKEN lacks the scope for the operation the step attempted. The message is identical whether the missing scope is contents, packages, issues or id-token, because naming it would tell an attacker what to aim for.
Why does the same workflow work in another repository?
The default changed for new repositories and organisations. An older repository may still default to write permissions, so two repositories in the same account can behave differently with identical YAML.
Should permissions go at workflow level or job level?
Job level. A workflow-level block grants the scope to every job in the file, including the build job that has no reason to push anything. Grant the narrowest scope to the one job that needs it.
I added contents: write and now a different step fails. Why?
A permissions block replaces the defaults rather than adding to them. Declaring one scope drops all the others, so a job that also comments on the PR loses pull-requests: write. List every scope the job needs together.
Is defaulting the whole organisation to write a reasonable shortcut?
It removes the errors and enlarges the blast radius of every workflow, including ones triggered by outside contributions. The audit flags it for that reason rather than treating it as a fix.
Related field notes
- Secrets are empty strings in fork PRs
- Three pushes run three full pipelines
- A cache miss that is really a rate limit
Sources
Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.
- Controlling permissions for GITHUB_TOKEN — GitHub Docs
- GitHub Actions: Control permissions for GITHUB_TOKEN — GitHub Changelog
- Workflow syntax: permissions — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.