Skip to content

Fine-Grained Personal Access Tokens: Least Privilege in Practice

Lesson 4 of 10Intermediate12 min readGitHub Engineering · GitHub APIVerified: GitHub.com, August 2026

A fine-grained personal access token is a credential that acts as you, restricted to specific repositories with specific permissions and a required expiry date.

Each of those three restrictions is absent from classic tokens, and each closes a real failure mode.

Classic PATFine-grained PAT
Repository scopeEverything the owner can reachRepositories you select
PermissionsCoarse scopesPer-resource read / write
ExpiryOptionalRequired
Organisation controlLimitedOwner can require approval
Visibility to the ownerPoorAccess is enumerable

The first row is the important one. A classic token with repo can write to every repository its owner can push to — work, personal, and every organisation they belong to. A fine-grained token reaches only what you listed.

Four decisions, in order.

Resource owner. Your own account, or an organisation you belong to. This is the most consequential field: it decides whose repositories the token can reach, and once created it cannot be changed. A token owned by your personal account cannot access organisation repositories, even ones you can push to, unless the organisation permits it.

Expiry. Required. Shorter is better; a scheduled job’s token should be rotated on a schedule you have actually planned, not set to the maximum and forgotten.

Repository access. All repositories, public repositories only, or — the correct answer almost always — only select repositories, named individually.

Permissions. Per resource, each read-only, read-and-write, or unset. Common ones:

Contents Read — clone, read files
Contents Write — push commits
Issues Write — create, label, close
Pull requests Write — create, review, merge
Metadata Read — required for almost everything
Actions Read — read workflow runs
Administration Write — change settings; rarely needed

Metadata: Read is granted automatically as a dependency of most other permissions.

When the resource owner is an organisation, the organisation can require an owner to approve the token before it works.

This is a genuinely useful control: it means organisation administrators can see which tokens exist, what they can reach, and revoke them centrally. It also means a token may sit pending until someone approves it, which is worth knowing before assuming your script is broken.

Terminal window
export GITHUB_TOKEN="YOUR_TOKEN_HERE"
curl -sS https://api.github.com/repos/OWNER/REPO/issues \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28"
Terminal window
gh api user --jq '.login'

What it doesConfirms which account the token authenticates as.

Why we run itThe fastest check that a token is live and belongs to the identity you expect. A 401 means invalid, revoked or expired.

Expected resultA JSON object with the account's login.

Fine-grained tokens do not return an x-oauth-scopes header — they have no scopes. Verifying what one can do means testing an endpoint, or reading the token’s configuration in account settings.

Expiry is mandatory, which means rotation is mandatory. Making it routine rather than an emergency:

Note the expiry date somewhere you will see it. GitHub warns by email; that email is easy to miss.

Rotate before expiry, not after. Create the replacement, update the secret store, verify, then revoke the old one. Overlapping avoids an outage.

Store centrally. A token pasted into three CI configurations must be updated in three places, and one will be forgotten.

Prefer shorter lifetimes where rotation is automated. The reason people choose long expiry is manual rotation being painful, which is an argument for automating it rather than for long-lived credentials.

When a fine-grained PAT is the wrong choice

Section titled “When a fine-grained PAT is the wrong choice”

For team automation. It belongs to a person. When they leave, the automation stops — and while they are there, it acts with their identity, which makes audit logs misleading. Use a GitHub App.

For anything acting on behalf of other users. A PAT is one identity. User-facing applications need the App or OAuth model.

Inside Actions on the workflow’s own repository. The GITHUB_TOKEN is already there, already scoped, and expires with the job.

For very high request volumes. App installation tokens can carry higher limits that scale with installation size.

The rule of thumb: a PAT is for a person doing something themselves. As soon as a system is acting on behalf of a team, it should have its own identity.

The reliable method is to start from the endpoints rather than from a guess.

  1. List every endpoint your script calls.
  2. Look up each one’s documented fine-grained permission.
  3. Take the union, at the weakest level each requires — read where read suffices.
  4. Create the token with exactly that set.
  5. Run the script and fix any 403 or 404 by adding the specific permission the failing endpoint documents.

Common endpoint-to-permission mappings:

DoingPermission
Read repository metadataMetadata: read
Read or write files, clone, pushContents: read / write
List, create or update IssuesIssues: write
Create or merge pull requestsPull requests: write
Read workflow runsActions: read
Trigger workflowsActions: write
Read or set repository settingsAdministration: read / write
Manage secretsSecrets: write
Read or write rulesetsAdministration: write

Metadata: read is required by almost everything and is added automatically as a dependency.

The one to be careful with is Administration: write. It covers repository settings, and on a personal-account token that includes deletion. A script that only needs to update a description does not need it — check whether the specific setting is covered by something narrower first.

Tokens accumulate. Auditing them periodically is worth a calendar entry.

Personal access tokens are listed in account settings rather than through an API you can query for your own, so this is a manual review. What to look for:

  • Tokens you cannot account for. Delete them. An unexplained credential is an unmonitored way in.
  • Classic tokens that could be fine-grained. Most can.
  • Broad repository access. “All repositories” on a token created for one project.
  • Long expiry. A year is rarely justified.
  • Tokens belonging to work you no longer do. The script was decommissioned; the token was not.

For organisation owners, the token policy page shows fine-grained tokens with access to organisation resources, which is the closest thing to a central inventory — and a good argument for requiring approval, since it makes the list exist at all.

A fine-grained token is a reasonable choice for CI on a personal project, and a poor one for a team.

The reason is ownership rather than capability. The token acts as you: audit logs show your name, its permissions are bounded by yours, and it stops working when your account does. For a personal repository that is fine. For anything a team depends on, it makes one person a single point of failure and misattributes every automated action to them.

- name: Run the report
env:
GITHUB_TOKEN: ${{ secrets.REPORTING_TOKEN }}
run: ./scripts/report.sh

Two improvements, in order. If the workflow acts only on its own repository, use the built-in GITHUB_TOKEN instead — it is scoped correctly, expires with the job, and belongs to no one. If it crosses repositories, use a GitHub App.

Reach for a personal token in CI only when neither applies, and set a short expiry so the rotation question is forced rather than forgotten.

Choosing the wrong resource owner. Cannot be changed; the token simply cannot see the repositories you meant.

Granting “all repositories” for convenience. Discards the main benefit.

Maximum expiry to avoid rotation. Recreates the classic-token problem.

Debugging a 404 as a wrong URL. It is usually missing access.

Using one for team automation. Ties infrastructure to an individual.

Expecting an x-oauth-scopes header. Fine-grained tokens have no scopes.

Forgetting organisation approval. The token exists and does nothing until approved.

There is no endpoint that returns “what can this token do”, which surprises people coming from other platforms. The available checks are indirect and worth knowing.

Terminal window
# The identity it acts as
gh api user --jq '{login, id}'
# The rate limit class — 60 means unauthenticated, 5000 authenticated
gh api rate_limit --jq '.resources.core.limit'
# Test a specific capability
gh api "repos/OWNER/REPO" --silent >/dev/null 2>&1 \
&& echo "can read repository" || echo "cannot read repository"
gh api "repos/OWNER/REPO/issues?per_page=1" --silent >/dev/null 2>&1 \
&& echo "can read issues" || echo "cannot read issues"

The rate-limit trick is the fastest first check: a limit of 60 means the token is not being sent at all, which is a different problem from a token lacking a permission and is frequently the actual cause.

For a permission problem, the diagnostic sequence is: confirm the identity, confirm the repository is in the token’s list, then check the endpoint’s documented permission requirement. Skipping to the third is how people end up granting broadly.

Both exist and classic tokens have not disappeared. Choosing:

ScenarioChooseWhy
Script touching two known repositoriesFine-grainedScope to exactly those
CI on a personal projectFine-grainedNarrow, expires
Something needing an endpoint fine-grained tokens do not supportClassicCoverage gap
Organisation automationNeither — use an AppOwnership and lifetime
Local git push conveniencegh auth loginManaged for you
Cross-organisation toolingAppPer-installation scoping

The third row is the honest caveat. Fine-grained token support has been extended steadily but is not universal, and some endpoints — particularly older organisation and administrative ones — still require a classic token. When you hit one, the endpoint’s documentation says so.

When a classic token is genuinely required, minimise the damage: request the narrowest scope that works, set an expiry even though it is optional, and record why it exists so a future audit does not have to guess.

A fine-grained token must expire, which forces rotation and creates a failure mode worth planning for.

When a token expires, every call returns 401 immediately. There is no grace period and no partial degradation. Whatever depended on it stops.

Practical consequences:

Expiry dates cluster. Tokens created during a setup session all expire on the same day, which means several things break simultaneously. Staggering them costs nothing at creation.

Scheduled jobs fail silently. A weekly report that does not run produces no output, and no output looks like no news. Any scheduled automation should alert on failure rather than only on findings.

The failure is not obviously about expiry. A 401 reads as a credential problem generally. Logging the token’s identity and expiry at start-up turns a confusing failure into an obvious one:

Terminal window
if ! gh api user --jq '.login' >/dev/null 2>&1; then
echo "authentication failed — token may have expired" >&2
exit 78
fi

Exiting 78 (EX_CONFIG) rather than 1 distinguishes a configuration problem from a runtime one, which matters to whatever is calling.

  1. Create a fine-grained token for one repository with Issues read-only, expiring in seven days.
  2. Confirm the identity with gh api user --jq '.login'.
  3. List that repository’s Issues successfully.
  4. Attempt to create an Issue and read the failure.
  5. Request a repository not in the token’s list and note the 404.
  6. Add Issues write, retry step 4, and confirm it now works.
  7. Revoke the token and confirm the calls fail.

Steps 5 and 6 together are the lesson: unauthorised resources are invisible, and permissions take effect immediately once granted.

The repository selector has three settings and they behave differently in ways worth knowing.

All repositories means all repositories the resource owner currently has and any created later. That last part is the surprise: a token created today covers a repository created next month, without any action from you. It is convenient and it is the opposite of least privilege.

Public repositories only grants read access to public repositories and nothing else. Useful for a token that only reads open-source metadata.

Only select repositories names them individually. New repositories are not included, which is correct for least privilege and means a script covering a growing set needs the token updating.

That last constraint is a genuine argument for a GitHub App once the set grows: an App installation can be set to all repositories in an organisation with per-repository permissions still bounded by the App’s declaration, which a PAT cannot express.

Organisations control how fine-grained tokens interact with their resources, and the settings determine whether your token works at all:

PolicyEffect
Fine-grained tokens permittedWhether they may access the organisation at all
Approval requiredTokens wait for an owner to approve before working
Classic tokens permittedSome organisations disable them entirely

The approval requirement produces a distinctive symptom: a token that exists, looks correct, and returns 404 on everything in that organisation. It is pending, not broken. Checking the token’s status in account settings is the diagnostic, and “waiting for approval” is not obvious from the API’s response.

For organisation owners, requiring approval is worth the friction. It is the only mechanism that produces a list of which personal tokens can reach your repositories — without it, that set is unknowable.

Written down so it is not improvised under time pressure:

  1. Create the replacement with identical permissions and repository access. Note the expiry.
  2. Update the secret store. Not the three places the value was pasted — the store.
  3. Verify with a read-only call using the new token.
  4. Deploy or restart whatever consumes it.
  5. Confirm the consumer is working, using the new credential.
  6. Revoke the old token.
  7. Record the new expiry somewhere that will remind you.

Step 6 last is what avoids an outage: both tokens are valid during the overlap, so a mistake at step 4 is recoverable.

Step 7 is the one that decides whether the next rotation is routine or an incident. A token expiring unnoticed is the most common cause of a scheduled job that silently stopped weeks ago.

  • Fine-grained tokens restrict by repository, by permission, and by a required expiry.
  • Resource owner is chosen at creation and cannot be changed.
  • Organisations may require approval, and can restrict token types entirely.
  • Permissions should be derived from the endpoints you call, not guessed.
  • Unauthorised access returns 404, so “not found” usually means “not permitted”.
  • Rotation should overlap: create, update, verify, then revoke.
  • A PAT represents a person; team automation should have its own identity.

What a token cannot do, regardless of permissions

Section titled “What a token cannot do, regardless of permissions”

Permissions are not the only constraint. Three organisation-level controls override them entirely, and each produces a failure that looks like a permissions problem and is not.

SAML single sign-on. In an organisation enforcing SAML, a token must be authorised for that organisation in addition to having permissions. An unauthorised token returns 403 with a distinctive message:

Terminal window
gh api "repos/ORG/REPO" --include 2>&1 | grep -i 'x-github-sso'
x-github-sso: required; url=https://github.com/orgs/ORG/sso?authorization_request=...

That header is the fix — visiting the URL authorises the token. Without reading headers, this looks like a token with insufficient permissions, and no amount of adding permissions will resolve it.

IP allowlists. An organisation can restrict API access to named IP ranges. A token used from outside them fails regardless of what it can do, which is a common surprise when a script moves from a laptop to a cloud runner.

Token policy. An organisation can disallow fine-grained tokens entirely, or require approval. A pending token behaves exactly like one with no access.

The diagnostic order for any unexpected 403 or 404 against an organisation’s resources: check for an x-github-sso header, then check whether the token is approved, then check the permission. Starting at the permission is what makes these take an afternoon.

Check your understanding

4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

You create a fine-grained token under your personal account, then realise the repositories belong to an organisation. What can you do?
Show answer

Nothing — resource owner is fixed at creation; create a new token — The resource owner is chosen once and cannot be changed. The token exists but simply cannot see the repositories you meant.

A fine-grained token returns 404 on a repository you know exists. The most likely cause is:
Show answer

The token was not granted access to that repository — Unauthorised access to a private resource is reported as 404. With fine-grained tokens the usual reason is that the repository is not in the token's access list.

Which is the recommended rotation sequence for a token that is still working?
Show answer

Create, update, verify, then revoke the old one — Overlapping rotation keeps automation running: the new token is in place and proven before the old one is revoked. Only an exposure justifies revoke-first.

Why should team automation not run on one person's fine-grained token?
Show answer

A PAT represents a person: it leaves when they do and its actions are attributed to them — Infrastructure tied to an individual breaks on departure and misattributes every action. A GitHub App gives automation its own identity.

Professional ToolkitThe permissions-by-task matrix for fine-grained tokens — 24 jobs, the exact permission each needs — is in the Professional Toolkit.