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.
Motivation
Section titled “Motivation”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.
Part 1 — Create the GitHub App
Section titled “Part 1 — Create the GitHub App”One-time, in a browser, human-only step.
-
Go to
github.com/settings/apps/new. This works fine under a personal account — no organization is required to create the App. -
Under Webhook, uncheck Active. No webhook is needed for this use case, and this avoids having to manage a webhook secret.
-
Under Repository permissions, set the permissions from the recommended permission set below directly — treat it as the starting point rather than GitHub’s defaults.
-
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.
-
Generate a private key from the App’s settings page. This downloads a
.pemfile — the one secret in the whole setup. -
Note the App ID shown on the settings page — it is used in every subsequent step.
Part 2 — Install the App
Section titled “Part 2 — Install the App”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):
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 --globalgh 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).
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 baregh, on every branch. Shell function resolution applies to every unqualified call inside the function body, including the one meant to invokegh-app-auth’s ownexecsubcommand. A bareghinside the function recurses into the function itself instead of reaching the real binary.- The
|| [ "${1:-}" = "app-auth" ]exemption. Without it,gh app-auth list(andsetup/test/etc.) failed when run from outside a git repository, because the wrapper was forcinggh-app-auth’s own management subcommands throughexec, which demands a repository/pattern context they don’t actually need — they managegh-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:
$ 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:
gh app-auth exec --repo github.com/YOUR_OWNER/YOUR_REPO -- gh YOUR_SUBCOMMANDOutside a repository working directory the --repo flag is required; inside one
it can be omitted.
Commit identity is deliberately unchanged
Section titled “Commit identity is deliberately unchanged”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.
Part 5 — The escalation model
Section titled “Part 5 — The escalation model”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:
GH_AS_ME=1 gh auth logout --hostname github.comA 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:
- The Docker image must be reasonably current.
docker run ghcr.io/github/github-mcp-serverwith 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-pathflags entirely, and erroredGITHUB_PERSONAL_ACCESS_TOKEN not setregardless of which App-auth environment variables were passed. Fix: rundocker pull ghcr.io/github/github-mcp-server:latestexplicitly, 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. - The
stdiosubcommand must be explicit. Omitting it — running justgithub-mcp-server --app-id ...with no subcommand — also produces the same misleadingGITHUB_PERSONAL_ACCESS_TOKEN not seterror, 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:
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.pemThe 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.
Part 7 — Recommended permission set
Section titled “Part 7 — Recommended permission set”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.
Repository permissions
Section titled “Repository permissions”| Level | Permissions | Why |
|---|---|---|
| Write | contents, issues, pull_requests | The actual job: commit, comment, and manage PRs. |
| Write (situational) | packages, actions, workflows | packages — 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. |
| Read | administration, 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. |
| None | secrets | A 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.
Organization permissions
Section titled “Organization permissions”Only relevant if the App is installed on an organization.
| Level | Permissions | Why |
|---|---|---|
| Read | members, organization_administration, organization_actions_variables, organization_custom_properties (and its near-duplicate-named custom_properties_for_organizations), organization_events | Diagnostic visibility without write access. |
| None | organization_secrets | Same reasoning as repository-level secrets. |
| None (write) / Read if wanted | organization_self_hosted_runners, organization_runner_custom_images | Write 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 and Enterprise permissions
Section titled “Account and Enterprise permissions”- 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.
Changing permissions later
Section titled “Changing permissions later”Verifying live permissions
Section titled “Verifying live permissions”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.
KEY=/path/to/private-key.pemAPP_ID=YOUR_APP_IDNOW=$(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.toolThis is a generally useful diagnostic technique on its own — it is how permission trimming was actually verified in this setup.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause | Resolution |
|---|---|---|
GITHUB_PERSONAL_ACCESS_TOKEN not set from the MCP server despite App-auth flags being passed | Stale cached Docker image without App-auth support, or a missing stdio subcommand | docker 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 repository | The 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 have | Add the [ "${1:-}" = "app-auth" ] exemption shown in Part 4 |
The gh shell function appears to recurse or hang | A bare gh call inside the function body, instead of command gh | Use command gh on every branch inside the function |
A marker placed in ~/.zshrc never fires for tool-invoked commands | Non-interactive shells (Claude Code’s Bash tool, CI, scripts) do not source ~/.zshrc | Put the gh wrapper function in ~/.zshenv instead |
GET /app/installations returns 403 | Called with an installation token instead of an App-level JWT | Mint 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 effect | No REST endpoint exists for changing a registered App’s permissions | Change permissions on the App’s own “Permissions & events” web UI page — there is no programmatic path |
See also
Section titled “See also”gh-app-authon GitHub — the extension used forghCLI andgit.github-mcp-serveron GitHub — the official GitHub MCP server, used here instdiomode with native App authentication.- GitHub Apps documentation — official reference for App creation, installation, and permissions.
- MCP Servers — the hosted remote endpoint,
headersHelper, and how a short-lived credential is kept fresh.
Copyright: © Arda Systems 2025-2026, All rights reserved