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.
What makes it different
Section titled “What makes it different”| Classic PAT | Fine-grained PAT | |
|---|---|---|
| Repository scope | Everything the owner can reach | Repositories you select |
| Permissions | Coarse scopes | Per-resource read / write |
| Expiry | Optional | Required |
| Organisation control | Limited | Owner can require approval |
| Visibility to the owner | Poor | Access 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.
Creating one
Section titled “Creating one”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 filesContents Write — push commitsIssues Write — create, label, closePull requests Write — create, review, mergeMetadata Read — required for almost everythingActions Read — read workflow runsAdministration Write — change settings; rarely neededMetadata: Read is granted automatically as a dependency of most other permissions.
Organisation approval
Section titled “Organisation approval”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.
Using and verifying
Section titled “Using and verifying”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"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.
Rotation
Section titled “Rotation”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.
Working out the permissions you need
Section titled “Working out the permissions you need”The reliable method is to start from the endpoints rather than from a guess.
- List every endpoint your script calls.
- Look up each one’s documented fine-grained permission.
- Take the union, at the weakest level each requires — read where read suffices.
- Create the token with exactly that set.
- Run the script and fix any 403 or 404 by adding the specific permission the failing endpoint documents.
Common endpoint-to-permission mappings:
| Doing | Permission |
|---|---|
| Read repository metadata | Metadata: read |
| Read or write files, clone, push | Contents: read / write |
| List, create or update Issues | Issues: write |
| Create or merge pull requests | Pull requests: write |
| Read workflow runs | Actions: read |
| Trigger workflows | Actions: write |
| Read or set repository settings | Administration: read / write |
| Manage secrets | Secrets: write |
| Read or write rulesets | Administration: 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.
Token inventory
Section titled “Token inventory”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.
Fine-grained tokens in CI
Section titled “Fine-grained tokens in CI”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.shTwo 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.
Common mistakes
Section titled “Common mistakes”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.
Reading a token’s own configuration
Section titled “Reading a token’s own configuration”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.
# The identity it acts asgh api user --jq '{login, id}'
# The rate limit class — 60 means unauthenticated, 5000 authenticatedgh api rate_limit --jq '.resources.core.limit'
# Test a specific capabilitygh 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.
Fine-grained versus classic, by scenario
Section titled “Fine-grained versus classic, by scenario”Both exist and classic tokens have not disappeared. Choosing:
| Scenario | Choose | Why |
|---|---|---|
| Script touching two known repositories | Fine-grained | Scope to exactly those |
| CI on a personal project | Fine-grained | Narrow, expires |
| Something needing an endpoint fine-grained tokens do not support | Classic | Coverage gap |
| Organisation automation | Neither — use an App | Ownership and lifetime |
Local git push convenience | gh auth login | Managed for you |
| Cross-organisation tooling | App | Per-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.
Expiry and what breaks
Section titled “Expiry and what breaks”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:
if ! gh api user --jq '.login' >/dev/null 2>&1; then echo "authentication failed — token may have expired" >&2 exit 78fiExiting 78 (EX_CONFIG) rather than 1 distinguishes a configuration problem from a runtime one,
which matters to whatever is calling.
Exercise
Section titled “Exercise”- Create a fine-grained token for one repository with Issues read-only, expiring in seven days.
- Confirm the identity with
gh api user --jq '.login'. - List that repository’s Issues successfully.
- Attempt to create an Issue and read the failure.
- Request a repository not in the token’s list and note the 404.
- Add Issues write, retry step 4, and confirm it now works.
- 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.
Repository access, in detail
Section titled “Repository access, in detail”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.
Organisation policy
Section titled “Organisation policy”Organisations control how fine-grained tokens interact with their resources, and the settings determine whether your token works at all:
| Policy | Effect |
|---|---|
| Fine-grained tokens permitted | Whether they may access the organisation at all |
| Approval required | Tokens wait for an owner to approve before working |
| Classic tokens permitted | Some 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.
A rotation procedure
Section titled “A rotation procedure”Written down so it is not improvised under time pressure:
- Create the replacement with identical permissions and repository access. Note the expiry.
- Update the secret store. Not the three places the value was pasted — the store.
- Verify with a read-only call using the new token.
- Deploy or restart whatever consumes it.
- Confirm the consumer is working, using the new credential.
- Revoke the old token.
- 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.
What you learned
Section titled “What you learned”- 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:
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.
Related lessons
Section titled “Related lessons”Check your understanding
4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.