Skip to content

GitHub App Identity

This page documents a working setup, verified live on 2026-09-04, for using a dedicated GitHub App as the identity behind gh CLI, raw git, and the GitHub MCP server (mcp__github-mcp-server__* tools) — instead of a human operator’s own personal access token or OAuth session. It includes the mistakes that shaped the final design, not just the finished commands.

Agent sessions doing GitHub work have historically reused the human operator’s own GitHub identity: a broadly-scoped personal access token or OAuth session (admin:enterprise, admin:org, repo, workflow). That has two problems:

  • Every agent action — commits, PR comments, API calls — is indistinguishable from the human’s own actions. There is no separately auditable identity.
  • There is no way to scope permissions down below “everything the human can do.”

The fix is a dedicated GitHub App as the agent identity: its own name (shows up as <name>[bot] on GitHub), its own scoped permission set, and a clean separation between routine agent work (always available, narrow scope) and the human’s own identity (a deliberate, human-only login step — genuinely gated, not just a convention).

The diagram below summarizes the resulting shape: the App identity is the default path for gh, git, and the MCP server, and the human’s personal identity is reachable only through an explicit, human-only escalation.

PlantUML diagram

One-time, in a browser, human-only step.

  1. Go to github.com/settings/apps/new. This works fine under a personal account — no organization is required to create the App.

  2. Under Webhook, uncheck Active. No webhook is needed for this use case, and this avoids having to manage a webhook secret.

  3. Under Repository permissions, set the permissions from the recommended permission set below directly — treat it as the starting point rather than GitHub’s defaults.

  4. Find “Where can this GitHub App be installed?” This setting is not on the General settings page, which is a common wrong guess. It is under the Advanced tab → Danger zone → Make public / Make private. Private (the default) means the App can only ever be installed on the account that created it. To install it on an organization, it must be made public first — this does not mean “anyone can install it automatically,” installation is still controlled per-organization separately.

  5. Generate a private key from the App’s settings page. This downloads a .pem file — the one secret in the whole setup.

  6. Note the App ID shown on the settings page — it is used in every subsequent step.

Install the App on the specific GitHub organization(s) or account(s) it needs to act on: from the App’s public page, choose Install App, then select specific repositories (not “All”) if you want to scope tightly. Repository-level scoping and permission-level scoping are two independent axes — narrowing one does not narrow the other.

Part 3 — gh-app-auth: the auth mechanism for gh CLI and git

Section titled “Part 3 — gh-app-auth: the auth mechanism for gh CLI and git”

gh-app-auth is a gh CLI extension that mints and caches App installation tokens and wires them into both gh and git’s own credential-helper protocol.

Do not confuse it with the similarly-named gh-token extension, which is inferior for this purpose: gh-token stores the private key as a bare file with no git-credential-helper integration, so it covers gh commands but not raw git clone/push. gh-app-auth stores the key encrypted in the OS keyring, caches minted tokens for about 55 minutes instead of re-minting on every call, and covers git as well as gh.

Setup (replace each YOUR_… placeholder with your own value; the commands paste as they are, since a placeholder in angle brackets would be read by the shell as a redirection):

Terminal window
gh extension install AmadeusITGroup/gh-app-auth
# Optional cleanup if gh-token was previously installed:
gh extension remove token
export GH_APP_PRIVATE_KEY="$(cat /path/to/private-key.pem)"
gh app-auth setup --app-id YOUR_APP_ID --patterns "github.com/YOUR_ORG/*" --name "YOUR_FRIENDLY_NAME"
unset GH_APP_PRIVATE_KEY
# Verify:
gh app-auth test --repo github.com/YOUR_ORG/YOUR_REPO
# Wire git itself (clone/push/etc. over HTTPS) to the same identity, scoped
# to the pattern above:
gh app-auth gitconfig --sync --global

gh app-auth setup auto-detects the installation ID from the App ID plus the pattern — there is no need to hunt for it manually. (If an installation ID is ever needed for other purposes, the older gh-token extension’s gh token installations --key /path/to/private-key.pem --app-id YOUR_APP_ID command can also list installation IDs directly, but gh-app-auth setup does not require this.)

Part 4 — Making gh default to the App identity

Section titled “Part 4 — Making gh default to the App identity”

The naive approach — running gh app-auth exec -- gh <command> on every call — works, but requires remembering the wrapper every time. The better approach, verified live: a shell function named gh that intercepts every invocation and injects the App token automatically, unless an explicit escalation signal is present (see Part 5).

~/.zshenv
gh() {
if [ "${GH_AS_ME:-}" = "1" ] || [ "${1:-}" = "app-auth" ]; then
command gh "$@"
else
command gh app-auth exec -- gh "$@"
fi
}

Two details in this function were found by hitting the failure, not by designing them upfront:

  • command gh, not bare gh, on every branch. Shell function resolution applies to every unqualified call inside the function body, including the one meant to invoke gh-app-auth’s own exec subcommand. A bare gh inside the function recurses into the function itself instead of reaching the real binary.
  • The || [ "${1:-}" = "app-auth" ] exemption. Without it, gh app-auth list (and setup/test/etc.) failed when run from outside a git repository, because the wrapper was forcing gh-app-auth’s own management subcommands through exec, which demands a repository/pattern context they don’t actually need — they manage gh-app-auth’s local config and mint their own auth internally. If adapting this pattern to a different extension, exempt that extension’s own management subcommands the same way.

The effect, and its limit: the wrapper is a zsh function, so it applies only to zsh. Inside zsh — an interactive shell, a zsh script, and any tool whose command runs under zsh — plain gh <anything> uses the App identity with no per-call ceremony. Outside zsh it does not exist: in bash, type gh resolves to the binary itself, and a bare gh call is unauthenticated once the human operator has logged out.

Verified on a working setup, 2026-09-10:

Terminal window
$ zsh -c 'type gh'
gh is a shell function
$ bash -c 'type gh'
gh is /opt/homebrew/bin/gh
$ bash -c 'gh auth status'
You are not logged into any GitHub hosts.

So bash scripts, CI runners, make recipes, and any program that execs the gh binary directly are not covered. For those, call the extension explicitly:

Terminal window
gh app-auth exec --repo github.com/YOUR_OWNER/YOUR_REPO -- gh YOUR_SUBCOMMAND

Outside a repository working directory the --repo flag is required; inside one it can be omitted.

gh-app-auth changes the credential used to clone, fetch and push. It does not change the author and committer recorded inside a commit, which still come from the user.name and user.email in git’s configuration.

That is intended here, and this guide offers no bot-identity recipe on purpose. Arda’s convention is that the git author is always the human operator: work produced with an agent’s help is still that person’s work, attributed to them, with the agent’s involvement recorded in the commit trailer rather than in the author field. Pointing user.email at the App’s bot account would move authorship to a machine account and break that convention.

So: the App identity governs what the credential is allowed to do; the human identity governs who the commit is by. They are separate on purpose, and a setup that unifies them is answering a different question than this page asks.

Once the App identity is the default, log the human operator out of gh entirely.

Use the GH_AS_ME=1 prefix. By this point the Part 4 wrapper is installed, so a bare gh auth logout is routed through gh app-auth exec — which needs a --repo outside a repository, and which is acting as the App rather than as the human whose session you are trying to end. The prefix bypasses the wrapper and targets the stored human session, from any directory:

Terminal window
GH_AS_ME=1 gh auth logout --hostname github.com

A standing personal OAuth session is itself a security hole under this design: it becomes a silent fallback for any code path that happens to call bare gh (from before the App wrapper existed) or that deliberately bypasses it.

Escalation, once logged out by default: the human runs GH_AS_ME=1 gh auth login themselves — a genuine, human-only action (browser or device OAuth, which cannot be triggered by an agent on the human’s behalf) — does the privileged thing, then optionally GH_AS_ME=1 gh auth logout again to return to the zero-standing-session default. Both carry the prefix for the same reason: the wrapper intercepts bare gh, and these two commands are precisely the ones that must reach the human’s own session rather than the App’s. Within the shell function from Part 4, GH_AS_ME=1 gh <command> is the explicit signal to use the human’s own identity (whether currently logged in, or currently logged out and thus failing) instead of the App’s.

The reasoning behind this shape, not just the mechanism: the design goal is that forgetting to signal escalation should fail loudly — a bare gh with no standing session simply errors “not logged in” — rather than silently succeed with more privilege than intended. A design that keeps the human ambiently logged in and relies on remembering to prefix gh-app-auth exec for routine work gets this backwards: forgetting the prefix there means every gh call silently uses the human’s full personal identity, which is the failure mode to avoid.

Part 6 — GitHub MCP server configuration

Section titled “Part 6 — GitHub MCP server configuration”

This part covers the stdio server, which runs locally. For the hosted remote endpoint — where the credential is a bearer token you mint yourself and the client re-runs a helper on every connection — see MCP Servers, which documents that path and why a static ${VAR} interpolation is the wrong shape for a one-hour credential.

The official github-mcp-server (exposed in Claude Code as the mcp__github-mcp-server__* tools) has native GitHub App authentication for its stdio mode — no wrapper, no gh-app-auth involved. It signs its own JWT, mints its own installation tokens, and refreshes them internally before they expire.

Two things cost real debugging time in this setup and are worth flagging prominently:

  1. The Docker image must be reasonably current. docker run ghcr.io/github/github-mcp-server with no explicit tag uses whatever is already cached locally rather than checking for a newer :latest. A locally-cached image about six months old (v0.33.0) silently lacked the --app-id/--app-installation-id/--app-private-key-path flags entirely, and errored GITHUB_PERSONAL_ACCESS_TOKEN not set regardless of which App-auth environment variables were passed. Fix: run docker pull ghcr.io/github/github-mcp-server:latest explicitly, and reference :latest (or a pinned newer version) explicitly in the run command rather than the bare untagged image name. App-auth support was verified working at v1.12.0.
  2. The stdio subcommand must be explicit. Omitting it — running just github-mcp-server --app-id ... with no subcommand — also produces the same misleading GITHUB_PERSONAL_ACCESS_TOKEN not set error, which masks the real issue (a missing subcommand, not a missing token).

Working configuration, registered via Claude Code’s claude mcp add at user scope so it is available in every project:

Terminal window
claude mcp add github-mcp-server -s user \
-- docker run -i --rm \
-v /path/to/private-key.pem:/secrets/github-app.pem:ro \
ghcr.io/github/github-mcp-server:latest stdio \
--app-id YOUR_APP_ID --app-installation-id YOUR_INSTALLATION_ID --app-private-key-path /secrets/github-app.pem

The private key is bind-mounted read-only into the container at a fixed in-container path (/secrets/github-app.pem). The --app-private-key-path flag must point at that in-container path, not the host path — the containerized process cannot see the host filesystem otherwise.

Verify: claude mcp get github-mcp-server should show Status: ✔ Connected. This can also be tested directly, outside Claude Code, by running the same docker run ... stdio ... command with stdin closed (< /dev/null) — a successful connection logs "GitHub MCP Server running on stdio" and a clean session-connect/disconnect cycle, versus an auth error if misconfigured.

This is a reasoned starting point for a coding-assistant identity, not universal law — every organization’s risk tolerance differs. The reasoning behind each bucket is meant to be adapted, not copied blindly.

LevelPermissionsWhy
Writecontents, issues, pull_requestsThe actual job: commit, comment, and manage PRs.
Write (situational)packages, actions, workflowspackages — deleting or restoring a broken published artifact genuinely requires write, not just read. actions — trigger/cancel workflow runs via API. workflows — edit .github/workflows/*.yml directly; this is the most supply-chain-sensitive item on the list and is a judgment call per organization.
Readadministration, checks, actions_variables, deployments, environments, repository_hooks, merge_queues, code_quality, security_events, vulnerability_alerts, repository_advisories, repository_custom_properties, pages (optional)Diagnostic value — can trace a problem to a repository setting without being able to change it.
NonesecretsA write grant here is a direct credential-exfiltration path if the private key or a minted token ever leaks. Note that secret names are not obtainable from the variables permission — variables and secrets are separate APIs, and listing repository secrets requires secrets: read. Read access does not expose secret values, which GitHub never returns from the read endpoints; so if diagnostic access to secret names is genuinely needed, secrets: read is the honest grant to ask for rather than a workaround.

metadata: read is mandatory and cannot be removed.

Only relevant if the App is installed on an organization.

LevelPermissionsWhy
Readmembers, organization_administration, organization_actions_variables, organization_custom_properties (and its near-duplicate-named custom_properties_for_organizations), organization_eventsDiagnostic visibility without write access.
Noneorganization_secretsSame reasoning as repository-level secrets.
None (write) / Read if wantedorganization_self_hosted_runners, organization_runner_custom_imagesWrite on either controls what code self-hosted CI runners execute — a classic privilege-escalation and supply-chain vector, and the level to refuse. Both do support read, which lists runners, runner groups and images without conferring management rights; that is legitimate diagnostic visibility and fits least privilege. Grant read if the diagnostic value is wanted, never write.
  • Account permissions: none needed. This category covers acting on a personal GitHub account context (email, gists, SSH/GPG keys, starring), which a repository/PR-focused coding assistant has no reason to touch.
  • Enterprise permissions: none needed, and mostly not requestable by third-party or public Apps anyway — this reinforces installing at the organization level (Part 2), not the enterprise level.

There is no API call reachable by an installation token that returns “get my own installation’s permissions.” To confirm what is currently granted (for example, after saving a change on the settings page), mint a short-lived App-level JWT directly with the private key (RS256, iss = App ID) and call GET /app/installations — that response includes each installation’s live permissions object. This needs the App’s JWT specifically; an installation token (what gh-app-auth and the MCP server normally use day-to-day) cannot call this endpoint — confirmed live, it 403s.

Terminal window
KEY=/path/to/private-key.pem
APP_ID=YOUR_APP_ID
NOW=$(date +%s)
b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
HEADER=$(printf '{"alg":"RS256","typ":"JWT"}' | b64url)
PAYLOAD=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$((NOW-60))" "$((NOW+300))" "$APP_ID" | b64url)
UNSIGNED="$HEADER.$PAYLOAD"
SIG=$(printf '%s' "$UNSIGNED" | openssl dgst -sha256 -sign "$KEY" | b64url)
JWT="$UNSIGNED.$SIG"
curl -s -H "Authorization: Bearer $JWT" -H "Accept: application/vnd.github+json" \
https://api.github.com/app/installations | python3 -m json.tool

This is a generally useful diagnostic technique on its own — it is how permission trimming was actually verified in this setup.

SymptomLikely causeResolution
GITHUB_PERSONAL_ACCESS_TOKEN not set from the MCP server despite App-auth flags being passedStale cached Docker image without App-auth support, or a missing stdio subcommanddocker pull ghcr.io/github/github-mcp-server:latest and reference the tag explicitly; confirm the run command includes stdio before the flags
gh app-auth list/setup/test fails when run outside a git repositoryThe gh shell wrapper is forcing gh-app-auth’s own management subcommands through gh app-auth exec, which needs a repo/pattern context they don’t haveAdd the [ "${1:-}" = "app-auth" ] exemption shown in Part 4
The gh shell function appears to recurse or hangA bare gh call inside the function body, instead of command ghUse command gh on every branch inside the function
A marker placed in ~/.zshrc never fires for tool-invoked commandsNon-interactive shells (Claude Code’s Bash tool, CI, scripts) do not source ~/.zshrcPut the gh wrapper function in ~/.zshenv instead
GET /app/installations returns 403Called with an installation token instead of an App-level JWTMint a fresh JWT as shown in Verifying live permissions
Trying to update a GitHub App’s permission set via the API fails or has no effectNo REST endpoint exists for changing a registered App’s permissionsChange permissions on the App’s own “Permissions & events” web UI page — there is no programmatic path