Security
Your Fine-Grained GitHub Token Gets 404, Not 403. Check These Four Things Before the URL.
James Joyner DEV Community
1 views
Your script calls the GitHub API with a fine-grained personal access token and gets 404 Not Found on a repository you can open in the browser. The URL is right. The repository exists. You will spend the next twenty minutes checking the URL anyway.
Stop. With fine-grained tokens, 404 usually means not permitted, not not there.
Why 404 and not 403
GitHub does not disclose the existence of resources you are not authorised to see. A private repository your token cannot reach returns the same 404 as a repository that does not exist. That is deliberate — it stops a token from being used to enumerate private repositories — and it means the debugging order most people use is backwards.
When a fine-grained token gets 404, check these before the URL:
Is the repository in the token's access list? Fine-grained tokens name repositories individually (or "all"). A repository created after the token was, is not in a "selected repositories" list.
Is the required permission set at all? Permissions are per resource and default to unset. A token with Issues: read and nothing else gets 404 from /repos/OWNER/REPO/pulls.
Is the resource owner right? A token created under your personal account cannot see an organisation's private repositories, and the owner cannot be changed after creation. Create a new token.
Has the organisation approved it? Organisations can require approval for fine-grained tokens. The token exists and does nothing until approved.
Only then look at the URL.
And 403 is often not permissions either
The mirror image: a 403 from an endpoint that worked a minute ago is usually the rate limit, not a permission change. Check the response headers before you touch the token:
gh api rate_limit --jq '.resources.core | "\(.remaining)/\(.limit) remaining, resets \(.reset | todate)"'
Authenticated requests get 5,000 per hour. A --paginate run over a large org can eat a meaningful slice of that.
There is no scopes header
Classic tokens returned an x-oauth-scopes header you could inspect. Fine-grained tokens have no scopes and return no such header. The only ways to know what one can do are to read its configuration in account settings, or to test an endpoint. The fastest liveness check:
gh api user --jq '.login'
A 401 there means invalid, revoked, or expired — the third being the one that bites on a Monday, because tokens created in the same setup session all expire on the same day.
Working out the permissions you actually need
Do not guess. Start from the endpoints:
List every endpoint the script calls.
Look up each one's documented fine-grained permission.
Take the union, at the weakest level each needs — read where read suffices.
Create the token with exactly that set.
Run it; fix any 403/404 by adding the specific permission the failing endpoint documents.
The mappings that come up constantly:
Task
Permission
Read repository metadata
Metadata: read (added automatically)
Read or write files, clone, push
Contents: read / write
Push commits, create tags and releases
Contents: write — there is no separate releases permission
Change a file under .github/workflows/
Contents: write and Workflows: write
List, label, comment on issues
Issues: write
Request reviewers, merge pull requests
Pull requests: write
Read workflow runs, download logs and artifacts
Actions: read
Trigger, cancel or re-run workflows
Actions: write
Post a check run
Checks: write
Manage repository secrets
Secrets: write
Rulesets, branch protection, settings
Administration: write
Two of these are where over-provisioning happens. Workflows: write is only needed to change workflow files — people add it "to be safe" and it should not be there. Administration: write covers repository settings, and on a personal-account token that includes deletion; a script that only needs to update a description does not need it.
If it runs unattended, it should not be a PAT at all
A personal access token represents a person. When they leave, the automation stops; until then, every action is attributed to them. For anything on a schedule, in CI, or shared by a team, use a GitHub App: installation-scoped, hourly tokens minted from a private key, its own rate limit, and its own identity in the audit log. Inside GitHub Actions on the workflow's own repository, use neither — the job's GITHUB_TOKEN with least-privilege permissions: is already there.
Rotation, when you do keep a PAT: create the replacement, update the secret store, verify, then revoke the old one. Reverse that order only for an exposure.
The full lesson — creating the token, organisation approval, reading a token's own configuration, expiry failure modes, fine-grained versus classic by scenario — is here: Fine-grained personal access tokens. The security angle, including what a "narrow, dated capability" does and does not protect you from: Fine-grained tokens.
Read original: https://dev.to/jjoyneriv/your-fine-grained-github-token-gets-404-not-403-check-these-four-things-before-the-url-1l99
← Previous
You Installed Git. Here Are the Next Ten Minutes.
Next →
AI Safety and Alignment: Building Trustworthy Agents That Do Not Fail You
Related
I Found an Undocumented MCP Server on OpenSea — and It Leaked Usernames for Any Wallet
Security
4
Dev.to (EN Zone)
Missing RLS: The Most Underrated Breach Cause of 2026
Security
4
DEV Community
Your Smart TV May Be Screenshotting Your Screen Every Half Second
Security
7
DEV Community
BlueMoon Exploit Kit Chains Recent Chrome, Windows Zero-Days
Security
7
SecurityWeek
Comments0
No comments yet — be the first