Skip to main content
Settings
Color Mode
Theme Skin
Background

Appearance preferences are saved in this browser only.

Environment
Current Environment Production

Built with JEKYLL_ENV=production. Changes require deployment.

Theme & Build
Jekyll v3.10.0
Last Build Jul 14, 10:34
Page Location
Page Info
Layout article
Collection hacks
Path _hacks/scope-mcp-fine-grained-pat.md
URL /hacks/scope-mcp-fine-grained-pat/
Date 2026-07-14

Scope the token before your MCP server gets the keys: a fine-grained PAT that can't touch prod

An MCP server is a program you let a language model drive. You point it at GitHub, hand it a token, and now the model can open issues, read code, comment on PRs — whatever the token allows. That last clause is the whole ballgame: the server inherits every permission of the token you give it. Not the ones you meant to use. All of them.

So the question that decides whether a hallucinated tool call can force-push to main is not “how good is the model” — it’s “what can this token do.” And the token most tutorials hand you is a classic Personal Access Token, which is the worst possible answer.

This one came from the sister site’s Agentic MCP Server Mastery quest — they walk you through building the server; this is the part where you make sure it can’t burn the house down.

First, look at the token you were about to use

I have a classic PAT sitting in this environment. Let me ask GitHub what it can actually do — the API tells you, in the X-OAuth-Scopes header on any authenticated request:

$ gh api -i user | grep -i '^x-oauth-scopes:'
X-Oauth-Scopes: admin:enterprise, admin:gpg_key, admin:org, admin:org_hook,
admin:public_key, admin:repo_hook, admin:ssh_signing_key, audit_log, codespace,
copilot, delete:packages, delete_repo, gist, notifications, project, repo, user,
workflow, write:discussion, write:network_configurations, write:packages

(That’s one real header, wrapped to fit. It really does include delete_repo and admin:org.)

Twenty-one scopes. repo alone is read/write to every repository this account can see. delete_repo does exactly what it says. admin:org can add and remove people. This is the token you were about to paste into an env block and hand to a program that decides what to do based on the vibes of a chat message.

The reason people reach for it is that it’s the easy one — one checkbox, “repo”, done. The easy token is the dangerous one. That is the entire lesson; the rest is how to not do that.

Which kind of token is it, anyway

Before you wire anything up, you can tell a token’s blast radius from its prefix alone. Handy when you’re auditing a config file and find a bare string in an env:

```bash lh:run for t in ghp_R2d2c3po github_pat_11ABCDE_xyz ghs_installtoken gho_oauthflow; do case “$t” in github_pat_) pfx=”github_pat_”; kind=”fine-grained PAT — per-repo, per-permission (scope it)” ;; ghp_) pfx=”ghp_”; kind=”classic PAT — coarse scopes, the dangerous easy one” ;; ghs_) pfx=”ghs_”; kind=”GitHub App installation token — scoped by the app” ;; gho_) pfx=”gho_”; kind=”OAuth token” ;; *) pfx=”?”; kind=”not a recognized GitHub token” ;; esac printf ‘%-13s -> %s\n’ “$pfx” “$kind” done


ghp_ -> classic PAT — coarse scopes, the dangerous easy one github_pat_ -> fine-grained PAT — per-repo, per-permission (scope it) ghs_ -> GitHub App installation token — scoped by the app gho_ -> OAuth token


**You'll know it worked when** the token your MCP server is holding starts with `github_pat_`, not `ghp_`. If you find a `ghp_` in an agent's config, that's the thing to fix before anything else.

## Build the token that can't hurt you

A fine-grained PAT flips the model: instead of coarse scopes across everything, you pick **which repositories** and **which permissions**, and everything you don't grant is denied by default. Create one at **Settings → Developer settings → Personal access tokens → Fine-grained tokens → Generate new token** (or go straight to `github.com/settings/personal-access-tokens/new`).

This step happens in a browser, not a terminal — there's no honest `gh` command to paste for it, because fine-grained tokens can't be minted from the API. What you set:

- **Resource owner:** you (or, for an org repo, the org — which means an org owner has to approve the token before it works; more on that below).
- **Repository access → Only select repositories:** pick the *one* repo the agent needs. This is the allowlist. A token that can only see `me/my-project` cannot touch `me/prod-infra` no matter what the model asks.
- **Permissions:** grant the minimum. For a read-mostly agent: **Contents → Read-only**, **Issues → Read-only**, **Pull requests → Read-only**, **Metadata → Read-only** (that last one is mandatory and auto-selected). Leave everything else at "No access."
- **Expiration:** set one. 30 days is fine; a token for a robot should not outlive the task.

Generate it, copy the `github_pat_...` string once (GitHub shows it exactly once), and move on.

### How do you know what permission to grant?

You don't have to guess. Every REST endpoint advertises the access it requires in a response header. On a classic token that's `X-Accepted-OAuth-Scopes`:

```console
$ gh api -i repos/bamr87/lifehacker.dev/issues | grep -i '^x-accepted-oauth-scopes:'
X-Accepted-Oauth-Scopes: repo

The endpoint accepts repo — the giant coarse scope. That’s the problem fine-grained tokens solve: they replace one repo grant with a menu, so “list issues” needs only Issues: read instead of read/write to the entire repository. (Fine-grained tokens see a sibling header, X-Accepted-GitHub-Permissions, naming the exact granular permission — but you can reason it out from the endpoint name too: /issues → Issues, /contents → Contents.)

Wire it into the server, minimally

The reference github-mcp-server reads its credential from an environment variable. So the entire security boundary is one line — make it the scoped token:

{
  "mcpServers": {
    "github": {
      "command": "github-mcp-server",
      "env": {
        "GITHUB_PERSONAL_ACCESS_TOKEN": "github_pat_XXXXXXXXXXXX"
      }
    }
  }
}

Don’t commit this file with the token in it — read it from your own environment ("$GITHUB_PERSONAL_ACCESS_TOKEN") or a secrets manager. A scoped token in a public repo is still a leaked token; it leaks less.

Prove the limit — make the agent try something it shouldn’t

A control you haven’t watched fail is a control you’re guessing about. So provoke a refusal. There are two shapes, and knowing which is which will save you an afternoon.

Shape one — in the allowlist, but missing the permission. The token can see the repo, but you didn’t grant the permission this call needs. Reading a repo’s Actions secrets needs admin; here’s a request for a repo I can read but don’t administer:

$ gh api repos/cli/cli/actions/secrets
{
  "message": "You must have repository read permissions or have the
    repository secrets fine-grained permission.",
  "status": "403"
}

A clean 403, and the error literally names “the repository secrets fine-grained permission” — GitHub is telling you which box you didn’t check. This is the good failure: loud, specific, actionable.

Shape two — outside the allowlist entirely. Now the agent tries to write to a repo the token has no access to at all. You’d expect another 403. You don’t get one:

$ gh api -X PUT repos/cli/cli/contents/pwned.txt -f message=x -f content=eA==
{
  "message": "Not Found",
  "status": "404"
}

404, not 403. This is the part that trips everyone up, so it stays in: for write access to a resource your token can’t reach, GitHub returns “Not Found” rather than “Forbidden” — on purpose, so a token can’t be used to probe which private repos exist by reading the difference between 403 and 404. Nothing was written; the file doesn’t exist afterward. But if you were watching for a 403 in your logs, you missed it.

For contrast, the same secrets read against a repo the token does administer returns 200. The refusal isn’t the API being broken — it’s the API being scoped:

$ curl -s -o /dev/null -w "%{http_code}\n" -H "Authorization: Bearer $TOKEN" \
    https://api.github.com/repos/bamr87/lifehacker.dev/actions/secrets
200

You’ll know it worked when the thing you told the agent it shouldn’t do comes back 403 or 404, and the thing it’s supposed to do comes back 200. That gap is the boundary, and now you’ve seen it hold.

When this goes wrong

  • You get a 404 and assume the endpoint is wrong. It’s almost always a permission you didn’t grant on a write-class call — GitHub masks missing write access as “Not Found.” Before you debug the URL, check whether the token’s repository allowlist and permissions actually cover this action. The 403 path is honest; the 404 path is disguised.
  • The token works for you but not the agent — on an org repo. A fine-grained token against organization repositories is pending until an org owner approves it (Settings → Personal access tokens → under review). Until then every call 403s no matter how you scoped it. Personal repos don’t need approval; org repos do.
  • A call that should work returns 403 with “must be a classic PAT.” Some endpoints — parts of the older org and enterprise admin surface — still don’t accept fine-grained tokens at all. That’s a real limitation, not a scope you forgot. Don’t “fix” it by swapping back to a classic PAT for the whole server; if one narrow admin task truly needs it, give that task its own separate credential, not your agent’s everyday token.
  • You scoped the token perfectly and committed it to the repo. Scope limits the damage; it doesn’t make a token safe to publish. Keep it in an env var or a secrets manager, set an expiration, and rotate it if it ever lands in a diff. (If it already did: it’s leaked — rotate it, don’t only delete the line.)
  • The token expired mid-task and the agent started failing mysteriously. That’s the expiration doing its job. Short-lived is correct for a robot; recognize the symptom (sudden 401s across every call) so you don’t hunt for a bug that’s really a calendar.

The uncomfortable summary: you cannot make a language model trustworthy, so don’t try. Make the token trustworthy instead — scoped to one repo, granted the two or three permissions the job needs, and expiring on a schedule. Then the worst a bad prompt can do is earn a 403 you already watched it earn.


The gh and curl output above is real, captured live against the GitHub REST API on 2026-07-14 with gh 2.96.0 — the 403 and 404 are genuine refusals against cli/cli, a repo this token can read but not administer or write to (JSON reformatted to one field per line, the token value redacted). Nothing was created by the write attempt; the 404 is the proof. The token-prefix block runs in our offline test harness on every build.