The complete mental model for building a bot you can @mention on an issue to create a PR or review one — app identity, the three tokens, installations, webhooks, and the full server architecture.
There are three ways your server can act on GitHub. A personal access token is a human's credential — everything the bot does shows up as you, and a leak exposes everything you can touch. An OAuth App can only ever act as a signed-in user, with coarse scopes and long-lived tokens. A GitHub App is a first-class actor: it has its own identity (prbot[bot]), fine-grained permissions, per-repository installation, short-lived tokens, and built-in webhooks.
Everything in a GitHub App reduces to which of three credentials you're holding. Mixing them up is the #1 source of confusion.
A JWT you sign yourself with the app's RSA private key (downloaded once when you register the app). It proves "I am app #12345". Max lifetime 10 minutes (exp claim), iss = your app ID. It can only call app-management endpoints — list installations, mint tokens. It cannot touch repo content.
// payload of the JWT you sign with the private key (RS256) { "iat": 1718100000, "exp": 1718100600, // ≤ 10 min "iss": "12345" } // your App ID
The workhorse. Exchange your JWT at POST /app/installations/{installation_id}/access_tokens to get a ghs_… token that lives 1 hour and is scoped to that installation's repos and permissions. This is the token your bot uses to create branches, open PRs, and post reviews. Activity is attributed to yourapp[bot].
POST /app/installations/42183077/access_tokens Authorization: Bearer <your JWT> // → { "token": "ghs_abc…", "expires_at": "+1h" }
For "user signs in on my server". Standard web flow: redirect to github.com/login/oauth/authorize, exchange the callback code at /login/oauth/access_token. You get a ghu_… token (8 hours) plus a ghr_… refresh token (6 months). Its effective permission is the intersection of what the app may do and what the user may do — the app can never escalate past the user.
// 1. redirect the browser https://github.com/login/oauth/authorize?client_id=…&state=… // 2. exchange code server-side POST https://github.com/login/oauth/access_token // → { "access_token": "ghu_…", "refresh_token": "ghr_…" }
Authorization header?Registering the app gives it an identity, but zero access. Access comes when a user installs the app on their account or org and picks all repositories or only select repositories. GitHub reviews the app's requested permissions (e.g. Contents: read & write, Pull requests: read & write, Issues: read & write) at install time.
Each install creates an installation with a numeric installation_id. Your server learns it from the installation.created webhook (or GET /repos/{owner}/{repo}/installation) and should persist the mapping account → installation_id → repos — it's the key you'll need every time you mint a ghs_ token.
installation_id when the webhook arrives, you can still recover it later via GET /repos/{owner}/{repo}/installation (authenticated with your JWT) — but your webhook handler shouldn't need a lookup on the hot path: every event payload from an installed repo carries installation.id.Your app subscribes to events at registration time. For an @mention bot you want issue_comment (fires for comments on both issues and PRs — PRs are issues underneath) and pull_request. When someone comments, GitHub POSTs a JSON payload to your webhook URL.
Two things matter in the handler:
1. Verify the signature. GitHub sends X-Hub-Signature-256: sha256=…, an HMAC-SHA256 of the raw body using your webhook secret. Compute it yourself and compare with a constant-time check — otherwise anyone who finds your URL can puppet your bot.
2. Respond fast, work async. Reply 2xx within ~10 seconds and push the real work (cloning, reviewing, PR creation) onto a queue.
issue_comment webhook.Once the @mention is detected and verified, the worker mints a ghs_ token for payload.installation.id and drives the REST API. Everything it does appears as prbot[bot].
For "review their PRs": pull the diff, generate feedback, then submit one review with inline comments.
// 1. what changed? GET /repos/acme-corp/web-app/pulls/88/files // 2. submit the review — body + inline comments in one shot POST /repos/acme-corp/web-app/pulls/88/reviews { "event": "COMMENT", // or REQUEST_CHANGES / APPROVE "body": "Overall solid — two issues below.", "comments": [{ "path": "src/auth.ts", "line": 42, "body": "This token is logged in plaintext." }] }
APPROVE/REQUEST_CHANGES on a PR authored by the same identity — your bot can't formally approve its own PRs, and many orgs restrict bot approvals. COMMENT reviews always work.ghs_ token now fails with 401. Why?Putting it together, your service uses two of the three tokens for two different jobs: the ghu_ user token to know who is signing in on your site (and gate who may trigger the bot), and ghs_ installation tokens for everything the bot does. The JWT exists only to mint the latter.
ghu_), then installs the app on their repos (you store installation_id).@prbot review; GitHub delivers an issue_comment webhook.comment.user a registered customer / repo collaborator?ghs_ token from installation.id, creates the PR or posts the review, and replies on the thread.@prbot create a PR. The signature check proves the event came from GitHub — it says nothing about whether the commenter should command your bot. Gate on your own user records (that's what the sign-in on your server is for) and/or check their repo permission via GET /repos/{o}/{r}/collaborators/{user}/permission.ghu_ OAuth token actually used for?Register the app → users sign in (ghu_) and install it (installation_id) → verify webhooks → mint ghs_ → ship PRs and reviews as your bot.