GitHub Apps vs PATs: Scoping Machine Access to Repos
A leaked personal token hands over your whole account. Scope every automation with a GitHub App or a fine-grained PAT — and rotate tokens by design.
Netbay Engineering
Netbay Engineering
On this page
Every piece of automation eventually asks for a credential. The cheapest answer — a personal access token with broad rights and no expiry — is also the one whose theft gives an attacker your entire account, across every repository you can reach. This post compares the two real choices: GitHub Apps, which are machine identities with short-lived tokens, and fine-grained personal access tokens, which are your identity worn narrowly. Then it gives you the machinery to mint either one correctly.
What is actually leaking
Personal tokens leak for boring reasons:
- They live in scripts, cron files, and CI variables that nobody revisits for years.
- Classic PATs have no expiry date at all, so a stale token from the last decade keeps working silently.
- They are scoped to your account, which usually means every repository you can read and write.
Most of the credential controls GitHub has added in recent years exist because the old default — a token as powerful as the account holder — was unsustainable. The pattern to internalize: the token should be able to do less than you can, and should stop working even if you forget about it.
Fine-grained PATs: your identity, narrowed
A fine-grained personalized access token is still your login wearing a costume, but now a deliberate one. When you create it you choose a resource owner, select specific repositories, and tick permissions from a granular list — contents read-only for one repo, issues write for another. You also set an expiry, up to roughly a year, and every token can be revoked independently.
This is the right tool when the actor is semantically *you*: a one-off migration script, a machine you administer, a temporary workflow. The rule is to make the permission list a strict superset of nothing — grant the least that works, and set the shortest expiry the task tolerates.
GitHub Apps: a principal with a binder
A GitHub App is a separate identity with its own name, avatar, and permissions. Apps are registered to an organization or user, then *installed* on the repositories that need them, and the permission envelope is applied per installation. The crucial property is the installation access token (IAT): it is short-lived — about an hour — and it is minted by the app itself using a JWT signed with the app's private key. No long-lived secret is ever the credential; the private key signs short-lived ones.
That makes apps the right home for anything long-running: triage bots, status reporters, webhook subscribers (webhook delivery itself is a second, separate feature that justifies an app on its own), and org-wide automation that should survive individual people leaving the team.
Minting an installation token from the CLI looks like this:
#!/usr/bin/env bash
# short-lived installation token for a GitHub App
APP_ID=123456
PRIVKEY=/path/to/app.pem
INSTALL_ID=987654
now=$(date +%s)
exp=$((now + 600))
header=$(printf '{"alg":"RS256","typ":"JWT"}' | openssl base64 -A | tr '+/' '-_' | tr -d '=')
payload=$(printf '{"iat":%s,"exp":%s,"iss":%s}' "$now" "$exp" "$APP_ID" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
signing_input="$header.$payload"
sig=$(printf '%s' "$signing_input" | openssl dgst -sha256 -sign "$PRIVKEY" | openssl base64 -A | tr '+/' '-_' | tr -d '=')
jwt="$signing_input.$sig"
gh api -X POST "app/installations/$INSTALL_ID/access_tokens" -H "Authorization: Bearer $jwt" -H "Accept: application/vnd.github+json" --jq .tokenThe JWT payload carries "iat" and "exp" a few minutes apart and "iss" set to the app ID; the expiry is the only enforcement that matters — an hour later the produced token is dead. When you are inside GitHub Actions, you do not need any of this, because the platform mints an installation token for you:
name: app-auth-demo
on:
push:
jobs:
demo:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: 123456
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- run: >-
gh api repos/netbay/demo/contents/README.md
-H "Authorization: Bearer ${{ steps.app-token.outputs.token }}"The pattern to copy everywhere: private keys and setup tokens live in encrypted secrets, never in files, and the token used at runtime lives for an hour.
Which one to pick
A compact decision guide:
- One-off scripts and personal tooling: fine-grained PAT with an expiry in your calendar.
- Any bot, webhook consumer, or org-wide automation: GitHub App.
- Inside CI: GITHUB_TOKEN or an app-minted token, never a copied PAT.
- Credentials that must survive an employee leaving: GitHub App, without question.
Rotation hygiene
Whichever you choose, treat rotation as part of the design. Set expiry dates in your calendar at creation time, store every secret in an encrypted store, give each workload its own token so revocation is surgical, and audit installed apps and their permissions quarterly. On any suspected leak, revoke the token first and ask questions after — the scoped world makes that a small action, which is exactly the point.
Takeaway: fine-grained PATs narrow your identity to a repo and an expiry; GitHub Apps remove the human identity entirely and expire every hour. Scope the least, rotate on a schedule, and your automation stops being the account-shaped hole in your security. The bot you harden this way is happy running on a Lucknow DC01 VPS from Netbay, deployable in under 60 seconds at netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance