A GitHub App is an installable integration identity with explicitly configured permissions and repository access.
The key word is identity. An App is not a token belonging to a person; it is its own actor. It appears in audit logs as itself, its permissions are declared and visible, and it keeps working when the person who created it leaves.
Apps, OAuth Apps and PATs
Section titled “Apps, OAuth Apps and PATs”| Personal access token | OAuth App | GitHub App | |
|---|---|---|---|
| Acts as | The person | The person who authorised it | Itself |
| Permissions | The person’s | Coarse scopes | Declared, per-resource |
| Repository access | The person’s | The person’s | Only where installed |
| Token lifetime | Until expiry | Until revoked | ~1 hour, renewed |
| Survives the creator leaving | No | No | Yes |
| Webhooks built in | No | No | Yes |
| Rate limit | The person’s | The person’s | Its own, scaling |
The row that decides most cases is the third. An OAuth App or a PAT can reach everything its authorising user can reach. A GitHub App reaches only the repositories it is installed on — an organisation owner grants that explicitly, and can see and revoke it.
The two-step authentication
Section titled “The two-step authentication”This is what people find unfamiliar, and the reason is worth understanding.
The App proves it is itself with a signed JWT, and then asks for a token scoped to one installation. Those are different claims: being the app, versus being the app acting on this organisation’s repositories.
A sequence: the app holds a private key; it signs a JWT proving its identity; it exchanges the JWT for an installation access token scoped to one installation; it uses that token for API calls; and the token expires after about an hour.
The JWT
Section titled “The JWT”Generated locally, signed with the App’s private key, valid for a short window. It authenticates the App itself and can only be used against a few App-level endpoints.
import time, jwt # PyJWT
with open("private-key.pem", "rb") as fh: private_key = fh.read()
now = int(time.time())payload = { "iat": now - 60, # backdated to tolerate clock skew "exp": now + 540, # 9 minutes; the maximum is 10 "iss": APP_ID,}encoded = jwt.encode(payload, private_key, algorithm="RS256")The iat backdating matters: a small clock difference between your machine and GitHub otherwise
produces an “issued in the future” rejection that looks like a key problem.
The installation token
Section titled “The installation token”curl -sS -X POST \ "https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens" \ -H "Authorization: Bearer $JWT" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28"{ "token": "ghs_EXAMPLE_NOT_A_REAL_TOKEN", "expires_at": "2026-08-24T07:12:33Z", "permissions": { "issues": "write", "metadata": "read" }}That token is used exactly like any other bearer token, and expires in about an hour. Long-running
processes should re-request rather than caching it indefinitely — checking expires_at and renewing
a few minutes early is the usual pattern.
Finding installation IDs:
curl -sS https://api.github.com/app/installations \ -H "Authorization: Bearer $JWT" \ -H "Accept: application/vnd.github+json" \ | jq '.[] | {id, account: .account.login}'The private key
Section titled “The private key”User access tokens
Section titled “User access tokens”Apps can also act on behalf of a user, through a web flow producing a user access token.
The resulting token is bounded by the intersection of the App’s permissions and the user’s own access — so an App with Issues write, used by someone with read-only access to a repository, cannot write there. That intersection is the property that makes Apps suitable for multi-tenant products.
Use installation tokens for automation acting on its own; use user tokens when the App does something as a person and attribution matters.
Webhooks
Section titled “Webhooks”Apps receive webhooks natively — one delivery URL configured on the App, receiving events from every installation, with the payload identifying which one.
This is what makes Apps the right shape for event-driven integration. The alternative — a PAT plus per-repository webhook configuration — means configuring every repository separately and maintaining that as repositories come and go.
Rate limits
Section titled “Rate limits”Installation tokens get their own budget, separate from any person’s, and it scales with the size of the installation rather than being a flat per-user figure. For automation touching many repositories this is a practical advantage on top of the security argument.
When an App is worth it
Section titled “When an App is worth it”Apps cost more to set up than a token. That cost is worth paying when:
- Automation acts for a team or organisation rather than a person
- It must survive staff changes
- It runs across many repositories
- You want auditable, revocable, visible permissions
- You need webhooks without per-repository configuration
- You are building something other people install
A PAT remains reasonable for a personal script on your own repositories. The transition point is when something stops being “my script” and becomes “our automation”.
A complete authentication implementation
Section titled “A complete authentication implementation”The two-step exchange, written once properly:
"""Authenticate as a GitHub App and obtain installation tokens."""
from __future__ import annotations
import osimport timefrom datetime import datetime, timezone
import jwt # PyJWTimport requests
API = "https://api.github.com"APP_ID = os.environ["GITHUB_APP_ID"]PRIVATE_KEY = os.environ["GITHUB_APP_PRIVATE_KEY"] # PEM contents, from a secret store
def app_jwt() -> str: """A short-lived JWT proving we are this App.""" now = int(time.time()) return jwt.encode( {"iat": now - 60, "exp": now + 540, "iss": APP_ID}, PRIVATE_KEY, algorithm="RS256", )
class InstallationToken: """Caches an installation token and renews it before it expires."""
def __init__(self, installation_id: int) -> None: self.installation_id = installation_id self._token: str | None = None self._expires: datetime | None = None
def value(self) -> str: if self._token and self._expires: remaining = (self._expires - datetime.now(timezone.utc)).total_seconds() if remaining > 300: # renew five minutes early return self._token
response = requests.post( f"{API}/app/installations/{self.installation_id}/access_tokens", headers={ "Authorization": f"Bearer {app_jwt()}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", }, timeout=10, ) response.raise_for_status() data = response.json()
self._token = data["token"] self._expires = datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) return self._tokenThe renewal margin is the detail worth copying. Renewing exactly at expiry means a request in flight when the token expires fails; five minutes of headroom removes an entire class of intermittent failure that is miserable to reproduce.
The private key comes from the environment, never from a file in the repository.
Finding the right installation
Section titled “Finding the right installation”An App installed on several organisations has an installation per account, and the token is scoped to one of them.
# All installationscurl -sS "$API/app/installations" \ -H "Authorization: Bearer $JWT" \ -H "Accept: application/vnd.github+json" \ | jq '.[] | {id, account: .account.login, repository_selection}'
# The installation for one repository — usually what you wantcurl -sS "$API/repos/OWNER/REPO/installation" \ -H "Authorization: Bearer $JWT" \ -H "Accept: application/vnd.github+json" \ | jq '{id, account: .account.login}'The second form is the one to use in an event handler: a webhook payload tells you the repository, and this resolves it to the installation whose token you need. Hardcoding an installation ID works until the App is installed somewhere else.
repository_selection is either all or selected, which tells you whether the installation covers
every repository in the account or a named list.
Scoping a token further
Section titled “Scoping a token further”An installation token can be narrowed at request time — useful when a job only needs one repository even though the App is installed on thirty:
curl -sS -X POST "$API/app/installations/$INSTALLATION_ID/access_tokens" \ -H "Authorization: Bearer $JWT" \ -H "Accept: application/vnd.github+json" \ -d '{ "repositories": ["one-repo"], "permissions": { "issues": "read" } }'You can request a subset of the App’s permissions and a subset of its repositories, never more. This is least privilege applied per operation rather than per App, and it is worth doing for anything handling untrusted input — a token that can only read Issues on one repository is a much smaller problem if the process holding it is compromised.
Operational considerations
Section titled “Operational considerations”Key rotation. Generate a new private key, deploy it, verify, then remove the old one. Both work during the overlap, so there is no outage.
Multiple keys. An App can have several valid keys simultaneously, which is what makes zero-downtime rotation possible.
Suspension. An installation can be suspended by the account owner, which stops it working without uninstalling. A previously working App returning 403 across one organisation and not others is usually this.
Rate limits scale. An installation’s limit grows with the number of repositories and users, so a large installation gets a considerably higher budget than a personal token.
Attribution. Actions appear as the App, not as a person. That is the property that makes audit logs meaningful — and it means an App commenting on pull requests should say something identifying itself, since “some app did this” is unhelpful to whoever reads it later.
Common mistakes
Section titled “Common mistakes”Committing the private key. The whole credential, permanently distributed.
Using the JWT for ordinary API calls. It only works on App-level endpoints; use the installation token.
Caching an installation token past expiry. Check expires_at and renew early.
Forgetting iat backdating. Clock skew produces confusing rejections.
Assuming the App can reach everything. It reaches only where installed.
Expecting installation tokens to act as a person. They act as the App; use a user token when attribution matters.
Building an App when a PAT would do. For a personal script, it is overhead.
Designing an App’s permissions
Section titled “Designing an App’s permissions”The permission set is chosen at registration and changing it later requires every installation to re-approve — which, across an organisation, means chasing administrators. Getting it approximately right at the start is worth some thought.
Work from the operations, not from convenience:
| The App does | Permission |
|---|---|
| Reads repository metadata | Metadata: read (implicit) |
| Reads code | Contents: read |
| Pushes commits or creates branches | Contents: write |
| Comments on pull requests | Pull requests: write |
| Reports check results | Checks: write |
| Creates or labels Issues | Issues: write |
| Reads workflow runs | Actions: read |
| Changes repository settings | Administration: write |
Two rules save trouble later.
Never request write where read suffices. An App that comments on pull requests needs
Pull requests: write but not Contents: write — it is not pushing code. Organisations reviewing
installation requests do read these, and an App asking for content write access to post comments
looks careless at best.
Ask for what you will need soon, not everything you might ever need. Adding a permission means
re-approval; asking for Administration: write speculatively means many organisations will decline
to install at all.
Webhook events and the App
Section titled “Webhook events and the App”An App receives events from every installation at one URL, and the payload identifies which:
@on("pull_request", "opened")def pr_opened(payload: dict) -> None: installation_id = payload["installation"]["id"] repo = payload["repository"]["full_name"] number = payload["pull_request"]["number"]
token = InstallationToken(installation_id).value() comment(token, repo, number, "Thanks — a reviewer will look shortly.")The installation key is what makes multi-tenancy work: one deployment serves every organisation
that installs the App, and the token is resolved per event.
The events an App receives are configured on the App itself rather than per repository. That is the main operational advantage over repository webhooks — installing on a new repository subscribes it automatically, with nothing to configure.
Common App architectures
Section titled “Common App architectures”Three shapes cover most uses, and choosing between them is mostly about where the work happens.
Event-driven service. A long-running process receiving webhooks and acting. Suits anything needing low latency — commenting, labelling, reporting status. Needs hosting and an endpoint.
Scheduled job with App credentials. A cron job that authenticates as the App and sweeps. Suits reporting, drift detection and anything time-based. No endpoint needed, so much simpler to run.
Actions workflow using an App token. A workflow that mints an installation token to act beyond its own repository. Suits cross-repository automation where you already have Actions, and avoids hosting anything at all.
The third is underused and often the right answer:
- name: Mint an installation token id: app-token uses: actions/create-github-app-token@v3 with: app-id: ${{ vars.APP_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} owner: ${{ github.repository_owner }}
- name: Act across repositories env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: gh issue list --repo other-org/other-repoThat gives you the App’s identity and scoping without running a service, and the token expires with the job.
Migrating from a personal token
Section titled “Migrating from a personal token”Replacing a PAT-based automation with an App is a contained project:
- Register the App with the permissions the existing automation actually uses — check the endpoints, do not assume from the PAT’s scopes, which are almost certainly broader.
- Install it on the repositories the automation touches.
- Change the authentication code to the JWT-to-installation-token exchange. Everything downstream is unchanged, since both produce a bearer token.
- Run both in parallel briefly if you can, comparing behaviour.
- Cut over, then revoke the personal token. This step is the one that gets skipped, leaving the old credential live indefinitely.
The observable differences after cutover: audit logs attribute actions to the App rather than a person, the rate limit budget is separate, and the automation keeps working when that person’s account changes. Those are the reasons for the migration, and it is worth confirming all three actually happened rather than assuming.
Exercise
Section titled “Exercise”You can complete this on a personal account.
- Register a GitHub App with Issues read-and-write and Metadata read. Generate and download a private key.
- Install it on one repository you own.
- Generate a JWT with the Python snippet above.
- List installations and note the ID.
- Exchange the JWT for an installation token and inspect its
permissionsandexpires_at. - Use it to list Issues successfully, then try to read a repository the App is not installed on.
- Delete the private key from disk when finished.
Step 6 demonstrates installation scoping directly — the second call fails despite the App being perfectly valid.
Verifying an App’s setup
Section titled “Verifying an App’s setup”Before debugging behaviour, confirm the App is configured as you think.
# The App's own record — requires the JWT, not an installation tokencurl -sS https://api.github.com/app \ -H "Authorization: Bearer $JWT" \ -H "Accept: application/vnd.github+json" \ | jq '{name, slug, permissions, events}'{ "events": ["pull_request", "issue_comment"], "name": "Acme Review Bot", "permissions": { "issues": "write", "metadata": "read", "pull_requests": "write" }, "slug": "acme-review-bot"}That output settles two frequent questions: what the App is permitted to do, and which events it will actually receive. An App not receiving an event it should is usually subscribed to the wrong list, and this is where you see it.
Then confirm the installation covers what you expect:
curl -sS "https://api.github.com/installation/repositories" \ -H "Authorization: Bearer $INSTALLATION_TOKEN" \ -H "Accept: application/vnd.github+json" \ | jq '{total: .total_count, repos: [.repositories[].full_name]}'This uses the installation token, not the JWT, and lists exactly the repositories that token can reach. A 404 on a repository you expected is almost always because it is not in this list.
Identifying an App’s actions
Section titled “Identifying an App’s actions”Actions taken by an App appear under a bot account named <slug>[bot]. That matters when filtering
or excluding automated activity:
# Exclude bot-authored pull requests from a reportgh pr list --json number,author \ --jq '.[] | select(.author.login | endswith("[bot]") | not) | .number'
# Find everything one App didgh search issues --author "app/acme-review-bot" --limit 50The app/ prefix in search and the [bot] suffix in author fields are two different conventions for
the same thing, which is mildly annoying and worth knowing when a filter silently matches nothing.
Operational failure modes
Section titled “Operational failure modes”Four things that go wrong with Apps in production, and what each looks like.
Clock skew. The JWT is rejected as issued in the future. Backdating iat by sixty seconds fixes
it; the symptom is intermittent authentication failures that correlate with nothing.
Expired installation token cached too long. Requests start failing partway through a long job.
Renew a few minutes before expires_at rather than at it.
Installation suspended. The App stops working for one account while continuing elsewhere. Returns 403, and is invisible unless you check the installation.
Permissions changed but not accepted. Adding a permission requires each installation to approve it. Until an administrator does, the App runs with the old set — so a newly deployed feature fails with 403 on some installations and works on others.
That last one is the most confusing in a multi-tenant App, because the code is identical and the behaviour is not. Checking the installation’s actual permissions rather than the App’s declared ones is the diagnostic:
curl -sS -X POST "https://api.github.com/app/installations/$ID/access_tokens" \ -H "Authorization: Bearer $JWT" -H "Accept: application/vnd.github+json" \ | jq '.permissions'The token’s permissions are what that installation actually granted, which may lag what the App now
requests.
What you learned
Section titled “What you learned”- An App is an identity, not a person’s token, and it survives the creator leaving.
- Authentication is two steps: a JWT proving the App, exchanged for an installation-scoped token.
- Installation tokens last about an hour and must be renewed.
- Apps reach only the repositories they are installed on.
- The private key is the entire credential — never commit it, and rotate it if exposed.
- User access tokens are bounded by the intersection of App permissions and user access.
- Apps receive webhooks natively, without per-repository configuration.
The short version
Section titled “The short version”A GitHub App is an identity, and that single property produces every advantage over a personal token: it appears as itself in audit logs, its permissions are declared and reviewable, its access is granted per installation, its tokens expire in about an hour, and it keeps working when the person who created it leaves.
The cost is a setup you do once — register, generate a key, install, implement the JWT-to-token exchange — and a private key that must be treated as the entire credential, because it is.
For anything a team depends on, that trade is clearly worth it. For a script you run on your own repositories, it is not, and a fine-grained token is the better answer.
App manifests
Section titled “App manifests”Registering an App by hand means filling a form with a dozen fields — name, URLs, permissions, events — and getting them right. For an App other people will run their own instance of, that is a documentation burden and a source of misconfiguration.
The manifest flow replaces it: you describe the App as JSON, GitHub creates it, and hands back the credentials.
{ "name": "Acme Review Bot", "url": "https://example.com", "hook_attributes": { "url": "https://example.com/webhook", "active": true }, "redirect_url": "https://example.com/setup/callback", "public": false, "default_permissions": { "issues": "write", "pull_requests": "write", "metadata": "read" }, "default_events": ["pull_request", "issue_comment"]}The user is redirected to GitHub with that manifest, approves it, and is sent back to
redirect_url with a temporary code. Exchanging the code returns the App’s full credentials —
including its private key, which is the only time it is ever available:
curl -sS -X POST "https://api.github.com/app-manifests/$CODE/conversions" \ -H "Accept: application/vnd.github+json" \ | jq '{id, slug, webhook_secret, pem: (.pem | length)}'This is how self-hosted integrations offer a one-click setup: the person deploying it never fills in a form, and the permissions are exactly what the manifest declared rather than whatever they guessed.