GitHub token hygiene for AI agents: PATs, fine-grained tokens, GitHub Apps, and OAuth

GitHub offers four ways to authenticate an AI agent and they are not interchangeable. A ranked deep-dive on scope, lifetime, revocation, and audit attribution, with copy-pasteable examples.

May 30, 202614 min read

GitHub token hygiene for AI agents: PATs, fine-grained tokens, GitHub Apps, and OAuth.

You wired an AI agent into GitHub. Maybe it is Claude Code running an overnight refactor loop, a Cursor background agent that opens PRs while you sleep, an MCP server, or a CrewAI worker that triages issues. You started with the path of least resistance: a classic personal access token, the kind that begins with ghp_. You pasted it into .env, the agent worked, you moved on.

Then one evening you read the scope description more carefully. That repo checkbox you ticked grants the agent full read and write on every repository you can see, public and private, across every organization you belong to. The agent that was supposed to open a PR on myproject can now read your employer's monorepo. It can read every private fork you ever pushed. It can git push --force to anything.

So you go looking for the "right" answer and GitHub hands you four parallel options without ranking them: classic PATs, fine-grained PATs, GitHub Apps, and OAuth user tokens. The docs treat them as a menu. They are not a menu. They have very different blast radii, very different lifetimes, and very different audit footprints. This post ranks them for agent use, shows the exact tokens and flows, and is honest about the trade-off most agent posts skip.

The four options at a glance

Before we dig in, here is the matrix. We will spend the rest of the post justifying every cell.

OptionToken prefixDefault lifetimeScope granularityAudit attributionRevocationAgent fit
Classic PATghp_No expiry by defaultCoarse OAuth scopes (repo, workflow, admin:org)UserManual, all-or-nothingBad
Fine-grained PATgithub_pat_Up to 366 days for org-scoped50+ permissions, per-repo selectionUserManual, all-or-nothingOK for one developer, one repo
GitHub App installation tokenghs_1 hour, non-configurablePer-install permissions, narrowable per requestApp + installRevoke install or rotate keyBest for unattended automation
GitHub App user-to-server tokenghu_8 hours, refreshable for 6 monthsIntersection of app grant and user's permissionsUser, tagged as appRevoke install or user can unauthorizeBest when the agent acts as a real human

Every claim in that table is sourced below.

Option 1: Classic PATs. Why every "just paste this token" tutorial is wrong for agents

The classic PAT is the original GitHub credential and it shows its age. Scopes are coarse OAuth-style buckets: repo (full control of all repositories the user can see), workflow, admin:org, gist, notifications, and a handful of others. There is no per-repo selector. If you grant repo to your agent, the agent can read every private repo that you can read, in every org you belong to. (Scopes for OAuth apps)

Worse, the default expiry option when you create a classic PAT is "No expiration." That is a credential the agent can hold forever, sitting in .env waiting for a tool call, a log scrape, or a prompt-injection payload to exfiltrate it. (Managing your personal access tokens)

Here is what a classic PAT call looks like, and why you should treat the response as a warning:

bash
curl -H "Authorization: Bearer ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
     -H "Accept: application/vnd.github+json" \
     -H "X-GitHub-Api-Version: 2026-03-10" \
     https://api.github.com/user/repos
# Returns every repo the user can see, public + private, across every org.

If your agent's job is "open a PR on one repo," that response is orders of magnitude more access than the agent needs. GitHub's own documentation recommends against classic PATs and points users at fine-grained PATs or GitHub Apps instead. (Managing your personal access tokens)

There are a handful of features that still require a classic PAT because no fine-grained equivalent has shipped yet: Gists, notifications, some GitHub Packages operations. If you are building an agent that only does those things, you are stuck. For everything else, stop using ghp_ tokens.

For more on why long-lived environment variables are the wrong place for agent credentials in general, see stop putting API keys in environment variables.

Warning

GitHub has no announced sunset date for classic PATs as of mid-2026. They still work. "Still works" is not "should be used." Treat any agent stack that requires a classic PAT as a stack that needs rework.

Option 2: Fine-grained PATs. The pragmatic developer compromise

Fine-grained PATs were introduced to fix the blast-radius problem of classic PATs. Instead of coarse OAuth scopes, you get 50+ granular permissions, each settable to no access, read, or read-write. You also get a "Only select repositories" selector, so the token can be scoped to one repo instead of "everything I can see." (Permissions required for fine-grained personal access tokens, Introducing fine-grained personal access tokens)

The minted token starts with github_pat_. Creating one for an agent that opens PRs looks like this:

  1. Settings, Developer settings, Personal access tokens, Fine-grained tokens, Generate new token
  2. Resource owner: yourself or your org
  3. Repository access: "Only select repositories" and pick exactly the one your agent needs
  4. Permissions: Contents: Read-only, Pull requests: Read and write. Nothing else.
  5. Expiration: as short as you can tolerate
bash
curl -H "Authorization: Bearer github_pat_xxxxxxxxxxxxxxxxxxxxxx_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
     -H "Accept: application/vnd.github+json" \
     -H "X-GitHub-Api-Version: 2026-03-10" \
     https://api.github.com/repos/OWNER/REPO/pulls

That call now returns only what the agent needs, and a leak only burns one repo's worth of access. This is the right answer for the lone developer who wants their agent to do one thing on one repo.

It is not the right answer for unattended automation at scale, and here is why. The default maximum lifetime for an org-scoped fine-grained PAT is 366 days. (Setting a personal access token policy for your organization) That is a credential the agent will hold on disk for up to a year. If your prompt-injection threat model includes the agent being tricked into reading and exfiltrating its own .env (and it should, see how prompt injection becomes credential exfiltration), a 366-day token is a long window for the attacker to keep working.

Org owners can require approval before any fine-grained PAT touches org resources, and you should turn that on if you administer an org. (Setting a personal access token policy for your organization) It does not solve the lifetime problem but it adds a human in the loop at creation time.

Option 3: GitHub Apps. The right answer if you can afford the setup cost

The GitHub App model is structurally different. You register an App once. The App has its own identity, its own private key, and its own permission grant per installation. To call the API, you mint a short-lived JSON Web Token signed with the App's private key, exchange that JWT for an installation access token, and use the installation token for the actual calls.

Installation tokens start with ghs_ and expire after exactly one hour. You cannot configure a longer lifetime. If the agent leaks one, the attacker has at most 60 minutes before the token is dead. (Generating an installation access token for a GitHub App)

The most useful feature for agent use, and one almost no tutorial covers, is per-request permission narrowing. When you mint the installation token, you can pass repositories (or repository_ids) and permissions in the body. The minted token cannot exceed what the App was granted, but it can be made strictly smaller for one task. (Generating an installation access token)

bash
# Step 1: generate a JWT signed with your App's private key (RS256).
# claims: iat (now-60), exp (now+600), iss (App's client ID)
node -e '
const jwt=require("jsonwebtoken"), fs=require("fs");
const pk=fs.readFileSync("app-private-key.pem");
console.log(jwt.sign(
  { iat: Math.floor(Date.now()/1000)-60,
    exp: Math.floor(Date.now()/1000)+600,
    iss: "Iv23liAbCdEfGhIjKlMn" },
  pk, { algorithm: "RS256" }));'

# Step 2: exchange the JWT for an installation token,
# narrowing to one repo and only the permissions this task needs.
curl -X POST \
  -H "Authorization: Bearer $APP_JWT" \
  -H "Accept: application/vnd.github+json" \
  -H "X-GitHub-Api-Version: 2026-03-10" \
  -d '{
        "repository_ids": [123456789],
        "permissions": { "contents": "read", "pull_requests": "write" }
      }' \
  https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens
# -> { "token": "ghs_...", "expires_at": "2026-05-30T13:00:00Z", ... }

# Step 3: use the ghs_ token like any bearer.
curl -H "Authorization: Bearer $GHS_TOKEN" \
     -H "Accept: application/vnd.github+json" \
     -H "X-GitHub-Api-Version: 2026-03-10" \
     https://api.github.com/repos/OWNER/REPO/pulls

That pattern, "mint a one-hour token scoped to exactly this task", is what you actually want for an agent. The blast radius is one repo for at most one hour, every API call is attributed to the App in the audit log, and revocation is as simple as suspending the installation.

GitHub has also been iterating on the installation-token format itself. Watch the GitHub changelog for current rollouts that affect token shape and per-request override headers. The 1-hour expiration has been stable across these changes.

The catch: GitHub Apps are infrastructure. You register them in a settings page, generate and store a private key, write JWT-signing code, manage the installation lifecycle, and re-mint tokens before every batch of calls. That is not what a developer wants to do at 11 p.m. to let Claude Code open a PR.

Option 4: OAuth user-to-server tokens. When the agent should act as you

The fourth option is the GitHub App user-to-server flow. The App is still the central identity, but the user authorizes the App against their own account and the resulting token (ghu_…) carries the user's identity. The effective permission set is the intersection of what the App was granted and what the user can do. (Generating a user access token for a GitHub App)

If the App has user-token expiration enabled, user tokens last 8 hours and come with refresh tokens valid for 6 months. API requests are attributed to the user in the audit log and tagged with the App, so reviewers can tell a human click from an agent call. (Authenticating with a GitHub App on behalf of a user, Refreshing user access tokens)

For headless agents (CLI tools, MCP servers, anything without a browser), GitHub supports OAuth Device Flow. The agent shows a short code, the user types it at github.com/login/device, and the agent polls for the token. No client secret is sent.

bash
# 1. Ask GitHub for a device + user code. Only client_id required.
curl -X POST https://github.com/login/device/code \
     -H "Accept: application/json" \
     -d "client_id=Iv23liAbCdEfGhIjKlMn&scope=repo,read:org"
# -> { "device_code": "...", "user_code": "WDJB-MJHT",
#      "verification_uri": "https://github.com/login/device",
#      "interval": 5, "expires_in": 900 }

# 2. Show the user_code to the user; they enter it at the verification_uri.
# 3. Poll for the token.
curl -X POST https://github.com/login/oauth/access_token \
     -H "Accept: application/json" \
     -d "client_id=Iv23liAbCdEfGhIjKlMn&device_code=$DEVICE_CODE&grant_type=urn:ietf:params:oauth:grant-type:device_code"
# -> { "access_token": "gho_...", "token_type": "bearer", "scope": "repo,read:org" }

This flow is the canonical pattern for "agent that runs on a server but represents a real human." If you are building an MCP server or a CLI-driven agent and you want the audit log to show the human, not a bot, this is the path. There is more on the Device Flow in general at headless agent OAuth: the device code flow explained.

Why GitHub's token prefixes matter for leak detection

A small detail that becomes important the first time you almost leak a token. Every GitHub-issued credential has a recognizable prefix: ghp_ (classic PAT), github_pat_ (fine-grained PAT), gho_ (OAuth app), ghu_ (App user-to-server), ghs_ (App installation), ghr_ (App refresh). The prefix scheme exists specifically to drive secret-scanning detection. GitHub has published the rationale and reports a very low false-positive rate. (Behind GitHub's new authentication token formats)

Push protection is on by default for many token types and will block a git push containing a recognized secret. (About push protection, Supported secret scanning patterns)

What you see when your agent leaks a token into a commit and tries to push:

code
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: - GITHUB PUSH PROTECTION
remote:   ===========================================
remote:    Resolve the following violations before pushing again
remote:    - Push cannot contain secrets
remote:    - Secret type: GitHub Personal Access Token
remote:    - Location: src/agent/.env:3

Push protection is a real safety net and it catches a lot of leaks in practice. But it is the last line of defense, not the first. An agent that exfiltrates a token through a tool call, a webhook, or a paste into an LLM context never touches a git push. Push protection will not save you from that.

How the four options actually rank for agent use

The honest ranking, given everything above:

  1. GitHub App with installation tokens, narrowed per request, for any unattended automation that does not need to act as a specific human. One-hour tokens, per-request permission narrowing, clean audit attribution. Worth the setup if you are building anything beyond a one-off script.
  2. GitHub App with user-to-server tokens, via Device Flow for headless agents, when the agent acts on behalf of a real person and the audit log should reflect that. 8-hour rotation, refresh tokens, identity preserved.
  3. Fine-grained PAT, scoped to one repo with the minimum permissions, for solo developer use where standing up a full App is overkill. Accept the long-lived secret on disk as a known trade-off.
  4. Classic PAT, only if a feature you absolutely need has no fine-grained or App equivalent. Treat it as a known accept-the-risk.

Most agent docs stop at "use fine-grained PATs." That is fine advice for a hobbyist with one repo. It is bad advice for anything you want to run for more than a quarter without rotating credentials. If you can spare the afternoon to register an App and write the JWT-mint dance, do it.

The trade-off most agent posts skip

The matrix forces an awkward conclusion. GitHub Apps are the right answer for unattended automation: one-hour rotating tokens, per-request permission narrowing, clean audit attribution. But Apps are infrastructure. The PAT is the developer's compromise: it is pasteable like a classic PAT, but it is now a 366-day broadly-scoped credential in your .env.

You can keep the PAT compromise (paste it into .env and move on) or you can take the App pain (write JWT code, store a private key, manage minting). There is a third option that is worth knowing about, which is to push the credential out of the agent's process entirely. A local credential broker like authsome runs the OAuth flow once per provider, stores the token in an encrypted local SQLite vault, and injects it transparently at a local HTTPS proxy when the agent's request goes out to api.github.com. The agent's environment holds only a placeholder. The real github_pat_… or ghs_… never enters the agent's process memory.

For the multi-account case (one human, personal and work GitHub accounts on the same machine, see also managing multiple GitHub accounts for AI agents), that becomes a config flag instead of a wrapper script:

bash
authsome login github --connection work
authsome login github --connection personal
authsome set-default github work
authsome run -- claude

This does not replace GitHub Apps. If your use case demands one-hour rotating tokens with per-request narrowing, you still register an App. What a broker does is make the user side of OAuth and PAT handling disappear, so you stop choosing between "do the heavy thing" and "leave a long-lived secret in a dotfile."

A short checklist before you ship

A few defaults to set today, in order of how much they reduce blast radius for how little work:

  • Audit every classic PAT your agents hold. If any of them have "No expiration" or repo scope, replace them this week.
  • If you stay on PATs, make them fine-grained and scope each one to a single repo. Set the shortest expiry you can tolerate.
  • If you run an org, require approval for fine-grained PATs accessing org resources. (Org PAT policy)
  • For any agent that runs unattended for more than a few hours a week, register a GitHub App. Mint installation tokens per-task and pass permissions to narrow them.
  • Turn on push protection at the org level. (About push protection)
  • Keep the real ghp_, github_pat_, ghs_, and ghu_ strings out of the agent's process memory if you can. Either mint installation tokens in a small sidecar that the agent calls, or use a local broker that injects credentials at the proxy boundary.

None of this is fancy. All of it is much less work than the post-incident review you avoid by doing it.

Tip

The single highest-value change for most agent setups is replacing a ghp_ classic PAT with a github_pat_ fine-grained PAT scoped to one repo. Even if you do nothing else from this post, do that.

Priyansh Khodiyar

Priyansh Khodiyar

Maintainer

Works on authsome and the agentr.dev tooling.