Skip to content

GitHub Apps: Installable Identities for Automation

Lesson 5 of 10Intermediate → Advanced13 min readGitHub Engineering · GitHub APIVerified: GitHub.com, August 2026

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.

Personal access tokenOAuth AppGitHub App
Acts asThe personThe person who authorised itItself
PermissionsThe person’sCoarse scopesDeclared, per-resource
Repository accessThe person’sThe person’sOnly where installed
Token lifetimeUntil expiryUntil revoked~1 hour, renewed
Survives the creator leavingNoNoYes
Webhooks built inNoNoYes
Rate limitThe person’sThe person’sIts 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.

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.

From private key to API call

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.

Private keyHeld by you, never transmittedSigned JWTProves app identity, ~10 minutesInstallation tokenScoped to one installationAPI callsUsing the installation tokenExpiry (~1 hour)Renew by repeating the exchange

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.

Terminal window
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:

Terminal window
curl -sS https://api.github.com/app/installations \
-H "Authorization: Bearer $JWT" \
-H "Accept: application/vnd.github+json" \
| jq '.[] | {id, account: .account.login}'

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.

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.

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.

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”.

The two-step exchange, written once properly:

app_auth.py
"""Authenticate as a GitHub App and obtain installation tokens."""
from __future__ import annotations
import os
import time
from datetime import datetime, timezone
import jwt # PyJWT
import 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._token

The 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.

An App installed on several organisations has an installation per account, and the token is scoped to one of them.

Terminal window
# All installations
curl -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 want
curl -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.

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:

Terminal window
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.

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.

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.

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 doesPermission
Reads repository metadataMetadata: read (implicit)
Reads codeContents: read
Pushes commits or creates branchesContents: write
Comments on pull requestsPull requests: write
Reports check resultsChecks: write
Creates or labels IssuesIssues: write
Reads workflow runsActions: read
Changes repository settingsAdministration: 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.

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.

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-repo

That gives you the App’s identity and scoping without running a service, and the token expires with the job.

Replacing a PAT-based automation with an App is a contained project:

  1. 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.
  2. Install it on the repositories the automation touches.
  3. Change the authentication code to the JWT-to-installation-token exchange. Everything downstream is unchanged, since both produce a bearer token.
  4. Run both in parallel briefly if you can, comparing behaviour.
  5. 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.

You can complete this on a personal account.

  1. Register a GitHub App with Issues read-and-write and Metadata read. Generate and download a private key.
  2. Install it on one repository you own.
  3. Generate a JWT with the Python snippet above.
  4. List installations and note the ID.
  5. Exchange the JWT for an installation token and inspect its permissions and expires_at.
  6. Use it to list Issues successfully, then try to read a repository the App is not installed on.
  7. Delete the private key from disk when finished.

Step 6 demonstrates installation scoping directly — the second call fails despite the App being perfectly valid.

Before debugging behaviour, confirm the App is configured as you think.

Terminal window
# The App's own record — requires the JWT, not an installation token
curl -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:

Terminal window
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.

Actions taken by an App appear under a bot account named <slug>[bot]. That matters when filtering or excluding automated activity:

Terminal window
# Exclude bot-authored pull requests from a report
gh pr list --json number,author \
--jq '.[] | select(.author.login | endswith("[bot]") | not) | .number'
# Find everything one App did
gh search issues --author "app/acme-review-bot" --limit 50

The 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.

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:

Terminal window
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.

  • 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.

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.

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:

Terminal window
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.

Professional ToolkitThe fine-grained token permissions matrix, App-vs-PAT guide and gh api recipes are in the Professional Toolkit.