Give an AI Agent GitHub Access with a GitHub App
Updated 2026-08-18
TL;DR
- Default to a GitHub App installation access token. It carries the
ghs_prefix, expires after one hour, belongs to an installation rather than to a person, and can be narrowed to named repositories and a subset of permissions at the moment it is minted. - A personal access token (classic) is the wrong shape for an agent. Its
reposcope grants full access to public and private repositories, there is no read-only scope for private repositories, and organization owners have no direct visibility into the token. - The mint flow is three claims and one POST: an RS256 JWT with
iatbackdated 60 seconds,expno more than 10 minutes ahead, andissset to the app’s client ID, exchanged atPOST /app/installations/INSTALLATION_ID/access_tokens. workflowsandsecretswrite access are the two permissions that convert a prompt injection into a CI compromise. An agent that opens pull requests and cannot push to the default branch is in a different risk class from one that can.- The control that holds when the agent is wrong is a ruleset on the default branch requiring a pull request and an approving review — with the agent’s app absent from the bypass list.
Who this is for
You are wiring an agent into repositories other people depend on — triaging issues, proposing fixes, updating documentation, reviewing diffs — and eventually someone will ask which commit the agent made and on whose authority. This guide covers GitHub App installation tokens end to end: registering the app, choosing permissions, signing the JWT, minting and narrowing the token, opening a pull request, and handing the token back. Skip it if the agent runs inside GitHub Actions on the repository it changes, because the automatic GITHUB_TOKEN is already scoped to that repository and that job, and your whole problem collapses into writing a permissions: block in the workflow file.
The problem
The first thing that works is a classic personal access token. An engineer generates one from their own account, ticks repo because the tutorial said to, pastes it into the agent’s environment, and every call succeeds on the first try. Nothing about this looks like a problem for weeks.
What that token actually granted is not “this repository”. GitHub’s own scope reference describes repo as granting full access to public and private repositories, and the token documentation is blunter still: a classic token grants access to all repositories within the organizations you have access to, as well as all personal repositories in your personal account. So the agent can read the private repository belonging to the client engagement the engineer joined last quarter, the personal repository holding their side project, and the infrastructure repository they have access to but have never opened. There is no read-only scope for private repositories — public_repo narrows to public ones, and repo is all or nothing.
The organization cannot see it. GitHub’s credential reference records that for personal access tokens (classic), organization owners lack direct visibility, and can only revoke a token whose value they already know or restrict token access entirely. Compare that to a fine-grained token, where owners can view and revoke individual tokens, or an app installation, which an owner can uninstall.
Nor does it expire on its own. GitHub’s organization policy documentation states plainly that personal access tokens (classic) do not have an expiration requirement. GitHub removes classic tokens that have not been used in a year, which is a cleanup mechanism, not a rotation policy — a token the agent uses hourly is never unused.
Then there is attribution, which is the part that turns an incident into an argument. Every call the agent makes is the engineer’s call. The commit history says the engineer wrote the change. The audit log says the engineer deleted the branch. Six months later, when a reviewer asks whether a person or a model authored a particular commit, there is no field to look at, because the credential never carried the distinction.
The last piece is capability, and it is the one most teams discover too late. Two permissions do far more than their names suggest. Write access to repository contents on the default branch means the agent’s push starts a workflow run without anyone reviewing what it pushed. Write access to files under .github/workflows means the agent can author the workflow itself — and while the REST endpoint that reads an Actions secret deliberately returns only the secret’s name and timestamps without revealing its encrypted value, a workflow the agent writes runs with every secret that workflow references. The API refuses to hand over the secret; the CI system hands it to a job the agent designed. That gap is where an injected instruction inside an issue comment stops being a curiosity and becomes an exfiltration path, and the failure pillar collects the shapes it takes.
Step by step
One path, start to finish: register an app, install it on one repository, mint a token from the private key, read a file, and open a pull request. Every endpoint, header, claim, and permission name below is taken from GitHub’s current documentation, linked at the end. Runnable sources: examples/connect-github/mint_installation_token.py and examples/connect-github/open_pull_request.py.
1. Register a GitHub App, not a token on a person
Register the app from developer settings under an account you control. Two choices at registration matter more than the rest.
“Where can this GitHub App be installed?” takes either Only on this account or Any account. An internal agent takes the first. There is no reason to make an app installable by strangers when its only installation is yours.
Register one app per agent, not one app for the company. Permissions are declared per app, installations are per app, and suspension is per installation. An app shared by four agents holds the union of what four agents need, and suspending it because one agent misbehaved stops all four.
Then generate a private key. The PEM file GitHub gives you is in PKCS#1 RSAPrivateKey format, which some JWT libraries will not load without conversion. An app can hold up to 25 private keys, and GitHub’s guidance is explicit about why you would want more than one: use multiple keys in order to rotate keys without downtime in the event of a key compromise. GitHub also says not to hard-code the key in your app even if the code is in a private repository, recommends a key vault, and describes an environment variable as the weaker alternative. Treat it accordingly — this key does not expire and it mints tokens for every installation of the app.
2. Choose permissions, and know what read buys versus write
GitHub App permissions are per-resource with an access level, not a flat list of scopes. These are the repository permissions an agent is most likely to touch, using the exact keys the API accepts in the permissions object:
| Permission | Read gives the agent | Write additionally gives |
|---|---|---|
metadata |
General repository information: contributor lists, languages, statistics. | Nothing. Every endpoint GitHub lists under this permission is at read level. |
contents |
File contents, commits, branches, releases. Required for HTTP-based Git access with the token. | Creating, updating, and deleting files; creating and deleting branches; cutting releases. |
pull_requests |
Pull requests, their reviews, and their comments. | Opening pull requests, commenting, requesting reviewers, submitting reviews. |
issues |
Issues and their comments. | Opening, editing, commenting on, and closing issues. |
actions |
Workflow runs, artifacts, job logs, execution history. | Cancelling and re-running workflows. |
secrets |
Repository Actions secret names and timestamps — never values. | Creating and overwriting secret values, LibSodium-encrypted under the repository public key. |
workflows |
— | Creating and updating files in the .github/workflows directory. |
Two of these deserve a second look before you tick them.
secrets is asymmetric in a way that misleads people in both directions. Read access genuinely cannot exfiltrate a secret: the get-a-secret endpoint returns name, created_at, and updated_at, described in GitHub’s reference as retrieving a secret without revealing its encrypted value. Write access is the dangerous half, and not because it lets the agent read anything — it lets the agent replace the value that a deployment job will use. An agent that can overwrite NPM_TOKEN controls what gets published under it.
workflows has only a write meaning — write is the sole value the API accepts for that key, where contents, issues, pull_requests, actions, and secrets all accept read or write. It exists because .github/workflows is the one directory where a content change is also a change to what runs in CI. An agent with contents: write but without workflows can edit every file in the repository except those, which is exactly the boundary you want.
You do not have to guess the minimum set. Start the agent with metadata: read and nothing else, run its real workload, and read the X-Accepted-GitHub-Permissions header off every 403 it produces. GitHub documents that header as a comma-separated list of the permissions required by the endpoint, with semicolons separating alternative sets that would each be sufficient — pull_requests=read,contents=read; issues=read,contents=read means either pair works. That gives you a permission list derived from the agent’s actual calls rather than from someone’s guess about them. Making that derivation a repeatable practice rather than a one-off is a governance problem, and the govern pillar treats it as one.
3. Install on select repositories only
Installing the app is a separate act from registering it, and it is where repository access is decided. Choose Only select repositories and name one. The installation is the ceiling, and the mint-time filter can only subtract from it, so an installation scoped to every repository in the organization leaves you re-deriving the narrowing on every single mint — and any token minted without one gets the lot. That ceiling also rises on its own, because a repository created next quarter joins an all-repositories installation without anyone deciding it should.
The token response tells you which you got. repository_selection comes back as all or selected, and it is worth asserting on in a start-up check: an agent that expects selected and receives all is an agent whose blast radius silently grew when somebody re-installed the app.
Installation is also the organization’s lever. An owner can uninstall the app to deactivate its tokens, and an app owner can suspend an installation with PUT /app/installations/INSTALLATION_ID/suspended, authenticated with a JWT, which blocks the app from accessing the GitHub API or webhook events without tearing down the installation’s configuration. Suspension is the right button during an incident; uninstalling is the right button after one.
4. Sign an RS256 JWT with the app’s private key
The JWT authenticates as the app itself. It carries exactly three claims and GitHub constrains all three:
algisRS256.iatis when the JWT was created. GitHub recommends setting it 60 seconds in the past to protect against clock drift.expmust be no more than 10 minutes into the future.issis your app’s identifier. GitHub accepts the app ID and recommends the client ID.
Building it by hand is about fifteen lines and makes every constraint visible:
issued_at = int(time.time())
header = {"alg": "RS256", "typ": "JWT"}
payload = {
"iat": issued_at - 60, # backdated for clock drift
"exp": issued_at + 540, # 9 minutes, inside GitHub's 10-minute ceiling
"iss": client_id, # app client ID; the app ID is also accepted
}
segments = [
_b64url(json.dumps(header, separators=(",", ":")).encode("utf-8")),
_b64url(json.dumps(payload, separators=(",", ":")).encode("utf-8")),
]
signature = private_key.sign(
".".join(segments).encode("ascii"),
padding.PKCS1v15(), # RS256 is RSASSA-PKCS1-v1_5 with SHA-256
hashes.SHA256(),
)
segments.append(_b64url(signature))
app_jwt = ".".join(segments)
Note the interaction between the two time claims: backdating iat by 60 seconds does not buy you an extra minute of exp. The ceiling is measured from now, so issued_at + 540 leaves a minute of headroom and issued_at + 600 leaves none.
Send it as Authorization: Bearer. GitHub’s REST authentication page accepts Authorization: token for most credentials but states that if you are passing a JSON web token you must use Authorization: Bearer.
Two things this JWT cannot do. It cannot read repository content — it is an app-level credential, and repository access comes from the installation token you are about to mint. And it cannot be used against the GraphQL API at all: GitHub states that the GraphQL API does not support any queries or mutations that require you to authenticate with a JWT.
5. Exchange the JWT for an installation access token
You need the installation id. GET /app/installations lists every installation of the app, and GET /repos/OWNER/REPO/installation returns the one on a specific repository. Both require a JWT. In a webhook-driven agent the id also arrives in the payload, which saves a call.
Then one POST:
response = requests.post(
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
headers={
"Accept": "application/vnd.github+json",
"Authorization": f"Bearer {app_jwt}",
"X-GitHub-Api-Version": "2022-11-28",
},
json={
"repositories": ["billing-service"],
"permissions": {"contents": "write", "pull_requests": "write", "metadata": "read"},
},
timeout=30,
)
A 201 returns token, expires_at, permissions, and repository_selection. The token carries the ghs_ prefix and expires after one hour.
Both body parameters are narrowing filters, and using them is the difference between a token that matches the installation and a token that matches this run. repositories takes repository names — GitHub accepts up to 500 — and repository_ids takes numeric ids. permissions takes a subset of what the app was granted; it cannot add a permission the app does not hold. If you omit permissions, the token gets everything the installation has, which is almost never what a single task needs.
Pin X-GitHub-Api-Version. GitHub currently supports 2026-03-10 and 2022-11-28, and requests without the header default to 2022-11-28. The examples pin 2022-11-28 deliberately: an explicit version means a future default change is a decision you make rather than a morning you spend debugging.
6. Read a file with the installation token
GET /repos/OWNER/REPO/contents/PATH returns file content base64-encoded by default. Size decides whether that works: files of 1 MB or smaller support every feature of the endpoint; between 1 MB and 100 MB only the raw and object media types work, and the object media type returns an empty content field; over 100 MB the endpoint is not supported at all.
That empty content field is the trap, because it decodes to an empty string rather than raising. read_file in examples/connect-github/open_pull_request.py checks the encoding field and refuses instead:
encoding = body.get("encoding")
if encoding != "base64":
raise ValueError(
f"{path} came back with encoding {encoding!r}; files over 1 MB must be "
"fetched with the application/vnd.github.raw+json media type instead"
)
return base64.b64decode(body["content"]).decode("utf-8"), body["sha"]
Keep the sha it returns. Updating a file requires the blob sha of the version you are replacing, which is also GitHub’s concurrency check: if someone else changed the file between your read and your write, the write fails rather than clobbering them.
7. Open a pull request instead of pushing to the default branch
This is the single highest-value design decision on the whole integration, and it costs three calls instead of one.
Create a branch at the current head with POST /repos/OWNER/REPO/git/refs, passing a fully qualified ref — GitHub rejects a ref that does not start with refs and contain at least two slashes — and the sha to point it at. Commit with PUT /repos/OWNER/REPO/contents/PATH, passing message, base64 content, the sha of the file you read, and branch. Then open the pull request:
created = call(
token, "POST", f"/repos/{repo}/pulls",
json_body={
"title": title,
"head": branch,
"base": base,
"body": "Opened by an automation. Nothing merges without a human approval.",
"maintainer_can_modify": True,
},
)
title, head, and base are the parameters that matter. A 422 here means validation failed — most often a head branch with no difference from base, or an open pull request that already exists for that pair. draft is the third cause and the confusing one: draft pull requests on private repositories have historically required a paid plan, and GitHub’s current pull request page carries no availability note either way, so treat a 422 that appears only when you set draft as a plan problem and confirm it against your own account rather than against a blog post. The example leaves draft unset for that reason.
The reason this matters is not politeness. A pull request produces a diff, a reviewer, a conversation, and a revert button. A push to the default branch produces a deploy. Both are contents: write; only one of them is reviewable.
8. Make the branch protection real
An agent that opens pull requests is only safer than one that pushes if pushing is actually blocked. Configure a ruleset on the default branch. The rules that carry the weight:
- Restrict updates — only actors with bypass permissions can push to matching branches.
- Require a pull request before merging, with Required approvals set to at least one.
- Require approval of the most recent reviewable push, which forces the approval to come from someone other than the person who pushed last.
- Dismiss stale pull request approvals when new commits arrive, so an approved diff cannot grow after approval.
- Block force pushes and Restrict deletions, both of which GitHub enables by default.
Now the part people get wrong. A ruleset’s bypass list can contain roles, teams, and GitHub Apps. Adding the agent’s app to it, which someone will propose the first time a workflow gets stuck, deletes the entire control. If the agent’s job genuinely requires bypassing review, that is a decision to argue about explicitly, not a checkbox to tick during an outage.
9. Revoke the token when the run ends
DELETE /installation/token revokes the installation token you are authenticating with and returns 204. GitHub’s own best-practice guidance is to revoke tokens as soon as you no longer need them, and the reason is arithmetic: a run that lasts 90 seconds otherwise leaves a working credential in memory, logs, and any crash dump for the remaining 58 minutes.
Handle the 401 case as success. A token that already expired is a token that is already dead, which is the outcome the call exists to produce.
Decision table
| Option | When it wins | Attribution | Scope granularity | Expiry and org control |
|---|---|---|---|---|
| Personal access token (classic) | Legacy tooling that cannot do anything else. Not a default. | The human who created it. Agent and person are indistinguishable. | Coarse scopes over every repository in every organization the human can reach; no read-only scope for private repositories. | No expiration requirement; owners lack direct visibility into individual tokens. |
| Fine-grained personal access token | A one-off script owned by a named person, in one organization, where GitHub recommends it over the classic token. | Still the human. Better logged, same conflation. | Per-repository selection plus per-resource read/write permissions, limited to a single user or organization. | 1 to 366 days or no expiration, subject to org policy; owners can view and revoke individual tokens and can require approval. |
| GitHub App installation token | Unattended agents. The default for anything running on a schedule or a webhook. | The app, not a person. Distinguishable from human activity in history and logs. | Installation-level repository selection, narrowed further per token by repositories and permissions. |
One hour, automatic; owners can suspend or uninstall the installation. |
| GitHub App user access token | The agent is acting on one human’s explicit request and the action should be recorded as theirs. | The user and the app. | Bounded by both the installation’s permissions and the user’s own access. | 8 hours by default, with a refresh token valid for 6 months — but expiration is an app setting you can opt out of, and an app that has opted out receives a token that never expires and no refresh token at all. |
That last row is the honest counterweight to this guide’s argument. An installation token attributes activity to the app, which is what you want for a nightly dependency bump and not what you want for “the agent merged this because Priya asked it to”. GitHub’s best-practice page is unambiguous: if your GitHub App takes an action on behalf of a user, it should always use a user access token instead of an installation access token. If you stay on installation tokens for operational reasons, the mapping from action to requesting human is now yours to record, and nobody will build it for you after the incident.
The fourth thing you can register on GitHub, the OAuth app, is missing from that table deliberately, and the reason is the answer to “should I build an OAuth app or a GitHub App for my agent?”. An OAuth app can only act on behalf of a user; it has no independent identity, so every one of the classic PAT’s attribution problems comes with it. It authorizes against the same coarse OAuth scopes a classic token uses — including the same all-or-nothing repo — and its tokens carry the gho_ prefix, which GitHub’s credential reference lists as long-lived with manual revocation. The organization control is the sharpest difference: an owner approves or denies an entire application for the whole organization, and turning restrictions on makes previously approved apps lose access to the organization’s resources immediately. Revocation exists and is not the gap — GitHub documents DELETE /applications/{client_id}/token for revoking a single token and DELETE /applications/{client_id}/grant for revoking a user’s whole grant, which is finer than anything an organization owner gets over a classic token. What is missing is everything that would narrow the token in the first place: no per-repository selection, and no installations, so no way to grant one deployment less than another. For an agent, an OAuth app is a classic personal access token with an authorization screen in front of it.
Note one detail that survives every organization policy choice. GitHub states that regardless of the personal-access-token policy an organization sets, personal access tokens will have access to public resources within the organization. Restricting token access protects private repositories; it does not make the organization opaque.
Checklist
- The agent authenticates with a
ghs_installation access token, and noghp_,github_pat_, orgho_value appears anywhere in its configuration. - The app is registered as Only on this account unless it is genuinely distributed.
- The private key is loaded from a vault or an injected secret, never from a file committed to a repository, and the app holds a second key so rotation does not require downtime.
- The installation is on select repositories, and a start-up check asserts
repository_selection == "selected". - Every token is minted with an explicit
permissionsobject rather than inheriting the installation’s full grant. - Neither
workflowsnorsecretsappears in the app’s permissions unless a named, reviewed task requires it. - The permission list was derived from
X-Accepted-GitHub-Permissionsheaders on real 403s, and the derivation is repeatable in CI. - A ruleset on the default branch requires a pull request with at least one approval, and the agent’s app is not in its bypass list.
- The agent calls
DELETE /installation/tokenin afinallyblock. - Retry logic classifies a 403 by checking
retry-after, thenx-ratelimit-remaining, then the documented error message — and readsX-Accepted-GitHub-Permissionsonly to name the missing permission, never to decide whether the failure is retryable. - You can answer, from your own records, which human’s request produced any given commit the app authored.
Failure modes
401 “Bad credentials” seconds after minting a perfectly good JWT
Symptom: the token exchange returns 401 immediately, with a key you just downloaded and an app you just registered. The same code worked on a colleague’s laptop.
Cause: almost always one of three things. The machine’s clock is fast, so the iat GitHub receives is in the future — this is exactly what the recommended 60-second backdate exists to absorb, and skipping it is the most common version of this bug. Or exp is more than 10 minutes ahead, which GitHub rejects outright. Or iss is not the app’s identifier: the client ID and the app ID both work, and the installation id, which is a different number sitting right next to them in the URL bar, does not.
Fix: log the three claims before signing, then check them against a trusted clock. If they are right, verify the key belongs to this app by comparing fingerprints — GitHub documents openssl rsa -in PATH_TO_PEM_FILE -pubout -outform DER | openssl sha256 -binary | openssl base64 for exactly this, and the result should match the fingerprint shown next to the key in the app’s settings.
403 “Resource not accessible by integration”
Symptom: a call that works with your own personal access token fails under the app, with Resource not accessible by integration in the body.
Cause: the token lacks a permission the endpoint requires. The corresponding message for a fine-grained token is Resource not accessible by personal access token. Neither is a rate limit and neither will clear on retry.
Fix: read X-Accepted-GitHub-Permissions off that response, which lists the permission sets that would have worked. Add the smallest one, and note that adding a permission to an installed app does not apply retroactively — the installation has to accept the new permission before tokens carry it, so the fix involves the organization, not just your code.
Do not use that header as your classifier, though. GitHub introduced it as the fine-grained counterpart to x-accepted-oauth-scopes, so that callers can discover which permissions a route needs, and documents it nowhere as exclusive to failures. Classify on the error message instead — GitHub’s troubleshooting page names Resource not accessible by integration and Resource not accessible by personal access token for exactly this — and check the rate-limit signals first, because a 403 is also how throttling arrives. Use the header for the diagnosis it genuinely gives you, which is which permission was missing.
404 on a repository you can see in your browser
Symptom: the app returns 404 for a repository that plainly exists and that you have open in another tab.
Cause: three different conditions collapse into this one status. The installation may not include that repository. The repositories array you passed at mint time may not include it. Or the request is not authenticated in a way that reaches the resource — GitHub’s rule is that a request to a private resource that is not properly authenticated returns 404 rather than 403, to avoid confirming that a private repository exists. GitHub words that rule around authentication rather than around permissions, so read a 404 as “this credential cannot see it” rather than as a precise diagnosis of why.
Fix: call GET /installation/repositories with the installation token and compare the list against what you expected. That distinguishes “not in the installation” from “in the installation but the token was narrowed” without guessing. Treat 404 on a repository you believe you have as a permissions bug, not a typo.
The token expires 60 minutes into a two-hour run
Symptom: a long backfill or a large-repository crawl runs cleanly and then every request starts returning 401 at roughly the same moment.
Cause: the installation access token expired after one hour, and the process cached it at start-up.
Fix: mint on demand rather than at start-up. Keep expires_at next to the token, re-mint when it is inside a couple of minutes of expiry, and treat re-minting as ordinary rather than exceptional — the JWT signing is local and the exchange is one request. Do not paper over this by extending the JWT lifetime; the JWT and the installation token are different credentials with different ceilings, and the 10-minute JWT ceiling is not adjustable either.
A prompt-injected agent rewrites CI
Symptom: none at first. Later, a secret appears somewhere it should not, and the commit that made it possible is a small, plausible change to a workflow file, authored by the app.
Cause: the exfiltration path described at the end of The problem, reached from the other end. The app holds workflows: write, or holds contents: write on a branch CI runs from, and the agent treated untrusted text as instruction — an issue body, a pull request comment, a dependency’s README, a code comment in a diff it was asked to review. Any of those is a channel into a model that also holds a credential.
Fix: remove workflows from the app’s permissions and keep it removed. Where CI genuinely must be agent-editable, split it: a separate app with a separate installation, workflows: write, no contents: write elsewhere, and a ruleset requiring code owner review on .github/**. Then keep the two credentials in different processes, because a single process holding both is a single injection away from holding their union.
Throttled, and the retry logic makes it worse
Symptom: the agent’s throughput collapses. Some requests return 403, some return 429, and a naive retry loop turns a two-minute pause into a twenty-minute one.
Cause: GitHub’s primary rate limit for a GitHub App installation is 5,000 requests per hour, or 15,000 if the installation is on a GitHub Enterprise Cloud organization. Installations with more than 20 repositories earn another 50 requests per hour per repository, and installations with more than 20 users another 50 per user, up to a documented ceiling of 12,500. Separately, secondary rate limits cap concurrency at 100 requests and content-generating requests at 80 per minute — and an agent opening pull requests in a loop is generating content.
For choosing between credential types rather than debugging one, the rest of the table matters: unauthenticated requests get 60 per hour; an authenticated user gets 5,000 per hour, which covers both classic and fine-grained personal access tokens and also governs GitHub App user access tokens, since their requests count against the user’s own limit and are pooled with anything another app does on that user’s behalf; GITHUB_TOKEN in Actions gets 1,000 per hour per repository. So rate limits are not a reason to prefer an app at small scale — a personal access token and an installation are both 5,000 an hour. They become one at large scale, because an installation scales upward as repositories and users are added and a personal account does not.
Fix: honour the signals in GitHub’s documented order. If retry-after is present, wait that many seconds. Otherwise, if x-ratelimit-remaining is 0, wait until x-ratelimit-reset, which is UTC epoch seconds. Otherwise wait at least a minute, and back off exponentially for secondary limits. Note that the rate limit headers ride along with every response, so it is their values that carry information, not their presence — _throttle_wait in examples/connect-github/open_pull_request.py reads the values in that order and returns nothing when neither signal fires. The structural fix is to stop polling: GitHub’s guidance is to subscribe to webhook events instead of polling the API for data.
Every commit says the app’s name and nobody knows who asked
Symptom: an auditor asks who requested a change. Git blame names the app. The GitHub audit log names the app. Nobody can get further.
Cause: an installation access token attributes activity to the app by design — that is the property that makes it better than a token on a person, and it is also the property that erases the requester. GitHub is explicit that installation tokens are useful for automations that act independently of users, and that user access tokens are the mechanism for attributing activity to a user.
Fix: either move to user access tokens for user-initiated actions, or carry the requester yourself: put the requesting identity in the pull request body and in a commit trailer, and log the request id alongside the installation id in your own audit store. Do it at the moment of the request; reconstructing it later from chat history is the work nobody has time for during an incident.
Two things give an auditor a handle in the meantime. GET /app, authenticated with the app’s JWT, returns the app’s slug, which is the stable identifier to match against rather than the display name someone may rename. And on the commits endpoints, the top-level author is a GitHub account object carrying a type field, distinct from commit.author, which is raw Git metadata that need not correspond to any account at all — so machine-authored commits are separable from human ones without parsing a username. GitHub’s schema does not enumerate the values type takes, so read it off your own data before writing a query against it.
Doing this at scale
The single-repository version above takes an afternoon. What it turns into is not more of the same.
The private key is the first thing that changes character. One app with one key is a secret in a vault. A dozen agents, each with its own app so that suspension is per-agent, is a dozen non-expiring keys that each mint hourly tokens for every installation of their app — and the two rotation events, planned and post-compromise, both require the second key to already exist. GitHub gives you 25 slots per app precisely so rotation is not an outage, but scheduling it, verifying the new key mints successfully before deleting the old one, and doing that across a fleet is a job somebody has to hold.
The permission set is the second. Every new repository that joins an installation widens what the installation reaches, so a token minted without an explicit permissions object drifts toward maximum privilege without anyone changing a line of code. Per-run narrowing has to be the default, and something has to check that it stayed the default.
The audit trail is the third, and it is the one that fails quietly. Installation tokens tell you the app acted. Your logs have to supply the rest — which agent, on whose request, against which repository, under which permission set — and they have to supply it in a form an auditor can query without reading application logs. Every one of these problems has an obvious solution at one repository and no obvious solution at eighty.
That lifecycle is what Agentic Fabriq exists to hold. Credentials live in the control layer rather than in the agent: the agent holds a token for the layer and calls a named connection, policy is evaluated per request, and each call is attributed to an agent and to the user it acted for.
import asyncio
import os
from af_sdk.fabriq_client import FabriqClient
async def main() -> None:
async with FabriqClient(
base_url="https://dashboard.agenticfabriq.com",
auth_token=os.environ["AF_TOKEN"],
) as af:
result = await af.invoke_connection(
"github_platform",
method="list_pull_requests",
parameters={"repository": "acme/billing-service", "state": "open"},
)
for pull_request in result.get("pull_requests", []):
print(pull_request)
asyncio.run(main())
The runnable version is examples/connect-github/list_open_pull_requests.py. The property that matters is not the shorter code — it is that the GitHub App private key never enters the agent process, so a compromised agent leaks a revocable gateway token rather than the key that mints tokens for every repository the app is installed on. Connection and method names are per-deployment, so run afctl tools list against your own gateway instead of trusting github_platform and list_pull_requests here. What a layer like Agentic Fabriq sells is that lifecycle, not the API call; everything above this section remains correct if you would rather own the lifecycle yourself.
Further reading
Start with the connect pillar for how this pattern generalizes. The vocabulary changes per provider — GitHub calls them permissions, Google calls them scopes — but the ordering does not: a credential that belongs to a system rather than a person, narrowed at mint time, short-lived, and revocable by the organization. Connecting an AI agent to Gmail is the same argument in OAuth’s terms, and the contrast is instructive: Gmail’s refresh token is the long-lived object you must protect, while GitHub’s equivalent is the app private key, and the access tokens on both sides are cheap. The govern pillar covers making permission derivation a policy rather than a per-integration habit, and the fail pillar collects what happens when it is neither.
Primary sources for everything asserted above:
- Generating a JSON Web Token (JWT) for a GitHub App — RS256, the
iatbackdate recommendation, the 10-minuteexpceiling, andiss. - Generating an installation access token for a GitHub App — the exchange request and the one-hour expiry.
- Authenticating as a GitHub App installation — the
repositoriesandpermissionsnarrowing parameters and the 500-repository limit. - REST API endpoints for GitHub Apps — the token endpoint’s body parameters, response fields, status codes, and the suspend endpoint.
- REST API endpoints for installations —
DELETE /installation/tokenandGET /installation/repositories. - Managing private keys for GitHub Apps — the PKCS#1 format, the 25-key limit, the rotation guidance, and the fingerprint command.
- Permissions required for GitHub Apps — which endpoints each permission gates, at which access level.
- Editing a GitHub App’s permissions — updated permissions do not take effect on an installation until that account approves them.
- Best practices for creating a GitHub App — minimum permissions, revoking tokens early, webhooks over polling, and installation versus user access tokens.
- Refreshing user access tokens — the eight-hour default, the six-month refresh token, and what an app that opts out of expiration receives instead.
- About OAuth app access restrictions — approval and denial of an OAuth app for a whole organization, and what happens when restrictions are first enabled.
- REST API endpoints for OAuth authorizations —
DELETE /applications/{client_id}/tokenrevoking a single token, andDELETE /applications/{client_id}/grantrevoking a user’s whole grant. - X-Accepted-GitHub-Permissions header for fine-grained permission actors — the header’s purpose, its semicolon-separated alternative sets, and its relationship to
x-accepted-oauth-scopes. - REST API endpoints for commits — the distinction between the top-level
authoraccount object andcommit.authorGit metadata. - GitHub credential types — the
ghp_,github_pat_,gho_,ghu_,ghs_, andghr_prefixes with each credential’s lifetime and organization control. - Managing your personal access tokens — what a classic token reaches, and GitHub’s recommendation of fine-grained tokens over it.
- Setting a personal access token policy for your organization — approval requirements, maximum lifetime policy, and the public-resources caveat.
- Scopes for OAuth apps — the
repo,public_repo, andworkflowscope definitions. - Troubleshooting the REST API —
Resource not accessible by integration, theX-Accepted-GitHub-Permissionsformat, the 404-for-403 behaviour, and the retry ordering. - Rate limits for the REST API — installation limits, the per-repository and per-user scaling, and the secondary limits.
- API versions — the supported
X-GitHub-Api-Versionvalues and the default. - REST API endpoints for repository contents — base64 encoding, the 1 MB and 100 MB thresholds, and the media types.
- REST API endpoints for Git references — the fully qualified
refrequirement. - REST API endpoints for pull requests — the create-a-pull-request body parameters and the 422 response.
- REST API endpoints for GitHub Actions secrets — what the get-a-secret response does and does not contain, and the LibSodium requirement.
- Available rules for rulesets — the rule names quoted above and the fact that bypass lists can include GitHub Apps.