Skip to content
Latchkey LogoLatchkey home

Resource not accessible by personal access token in CI

Resource not accessible by personal access token is GitHub telling you that your fine-grained token reached the right repository and lacks the one permission this call needs. The wording is chosen to distinguish it from the same refusal aimed at a workflow token.

A refusal carrying an endpoint link on one route and a mutation name on the other
Two routes, two clues. REST attaches a documentation_url naming the endpoint; GraphQL appends the mutation in parentheses, as in "(enablePullRequestAutoMerge)".

What this error means

A step using a fine-grained personal access token fails with 403 while other steps using the same token succeed. Nothing names a permission. When the call went through REST the response carries a documentation link, and when it went through GraphQL the message carries the mutation name in parentheses. Those two details are the only things in the failure that narrow it, and both are easy to read past.

Two routes: the REST body quoted from Crackedcoder5TH/remembrance-oracle-toolkit#380, the GraphQL line from vorburger/nixfiles#33
403 {"message":"Resource not accessible by personal access token","documentation_url":"https://docs.github.com/rest/pulls/pulls#list-pull-requests","status":"403"}
GraphQL: Resource not accessible by personal access token (enablePullRequestAutoMerge)

The wording tells you which credential was used

GitHub has three refusals that look alike and are not interchangeable. Resource not accessible by integration means the caller was a GitHub App installation, which includes the automatic GITHUB_TOKEN. Resource not accessible by personal access token means the caller was a fine-grained personal access token. A classic token with insufficient scopes tends to produce a 404 or a scope-specific message instead.

That distinction is worth two minutes at the start. If you believed a step was using the workflow token and the message names a personal access token, the step is reading a secret you forgot was set, and the permission you are about to edit belongs to the wrong credential entirely.

Common causes

The token is missing the one permission this endpoint needs

The common case, and the reason the error is so narrow. Fine-grained permissions are per resource type, so a token that can read contents cannot open a pull request, add a label or write a check without each of those being granted separately. Everything else in the workflow keeps working.

The permission was granted at read when the call is a write

Each permission has levels, and the levels are easy to leave at the default while ticking the box. A token with Pull requests set to read passes the existence check and fails the write, which looks identical to not having the permission at all.

The step is using a different credential from the one you edited

The message names a personal access token, so if you have been editing the job permissions block you have been editing the wrong thing. A secret set long ago, or a default input on a third-party action, is supplying the credential that is actually failing.

The token belongs to an organization that must approve it

Worth ruling out when the permissions look correct in the token settings. A fine-grained token targeting an organization's repositories can require approval from an owner before it takes effect, and until then it behaves as though the permissions were never granted.

How to fix it

Use the response to name the permission

  1. If the failure carries a documentation_url, open it and read the permissions that endpoint requires.
  2. If it carries a mutation name in parentheses, look that mutation up instead.
  3. If it carries neither, find the call in the action's source, then use one of the two steps above.
  4. Grant that permission at the level the call needs, not at read by default.

Confirm which credential the step is using

Before changing any token, establish that the failing step reads the token you think it does. The wording already tells you the class of credential: a message naming an integration is the workflow token, and this one is a personal access token. They are configured in entirely different places.

Prefer the workflow token when the call is in its own repository

A fine-grained token is the right tool for reaching another repository and an unnecessary liability inside this one. If the call targets the repository running the workflow, deleting the token input and declaring the permission on the job removes an expiring secret and a second place to configure.

.github/workflows/triage.yml (illustrative)
jobs:
  label:
    runs-on: ubuntu-latest
    permissions:
      pull-requests: write
    steps:
      - run: gh pr edit "$NUMBER" --add-label triage
        env:
          GH_TOKEN: ${{ github.token }}
          NUMBER: ${{ github.event.pull_request.number }}

Treat a change from 404 to 403 as progress

If adding the repository to the token turned a not-found into this message, the repository gate is now open and only the permission gate remains. Do not revert the repository change while investigating the permission.

Read the clue the route left you

Neither form of the message names the missing permission, but both name the operation, and the operation maps to one permission in GitHub's reference. That mapping is the shortest path from this error to the checkbox.

Route the call tookDetail carried with the refusalHow to turn it into a permission
RESTA documentation_url naming the endpoint, such as the list-pull-requests pageOpen that page and read the permissions its own reference section requires
GraphQLThe mutation or query in parentheses after the message, as in the second line aboveLook the mutation up and read the fine-grained permission it lists
A third-party actionUsually neither, because the action prints only the messageIdentify the call the step makes, then use one of the rows above

Two independent gates, and only one of them is the permission

A fine-grained token has a repository list and a permission set, and both have to allow the call. The repository list is checked first, and failing it produces a 404 rather than this message, for the same reason any invisible resource does. So seeing this 403 at all is informative: it means the repository is on the list, and only the permission is wrong.

That narrows the search considerably. It also explains a common sequence in which someone adds the repository, sees the error change from 404 to this one, and concludes nothing improved. The change from 404 to 403 was progress: one of the two gates opened.

Why there is no recorded run on this page

Recording this would mean minting a personal access token, deliberately withholding one permission from it, and publishing the log. The token would be ours, its repository list would be ours, and the interesting fields, the endpoint in the link and the mutation in the parentheses, vary with the call you made rather than with anything we could stage. What generalizes is the relationship between the wording and the credential type, which is established from GitHub's own responses rather than from a run. The two lines above are quoted from public issues: one where the tool printed the full REST response body, and one where gh printed the GraphQL refusal with the mutation name still attached.

How to prevent it

  • Grant fine-grained permissions from the endpoint the workflow calls, rather than from a guess about what sounds related.
  • Record the token's expiry somewhere the team sees it, since a fine-grained token stops working on a date rather than on a change.
  • Keep one token per purpose, so a refusal identifies the workflow that needs attention.
  • Use the workflow token for calls inside the repository and reserve personal tokens for crossing out of it.

Frequently asked questions

How is this different from "not accessible by integration"?
The wording names the credential class. An integration is a GitHub App installation, which is what GITHUB_TOKEN is, so that version points at the job's permissions block. This version names a personal access token, so it points at the token's own permission settings. Editing the block will not affect a call made with a personal token.
Why does the message not say which permission is missing?
GitHub does not include it. What you get instead is the operation: a documentation_url naming the endpoint on REST, or the mutation in parentheses on GraphQL. Each of those has a documented permission requirement, so the operation is one lookup away from the answer. An action that prints only the message throws that clue away.
I granted the permission and it still fails. What else is there?
Check the level rather than the presence, since read and write are separate and a write call needs write. Then check whether the token targets an organization that requires an owner to approve fine-grained tokens, because until that approval is given the granted permissions do not take effect.
Why did I get 404 before and 403 now?
Because the two gates are checked in order. A repository that is not on the token's list is invisible, which returns 404 the same way any unseeable resource does. Once it is on the list the repository is visible and only the permission can refuse you, which is this 403. The change means one of the two problems is solved.

Related guides

References

Fine-grained tokens behave identically on Latchkey runners, at $0.0025/min at 2 vCPU against $0.006. Start free → 30-day trial · No credit card