← Posts
May 20, 2026githubsecuritydevtools

GitHub Fine-Grained Tokens: A Practical Permission Guide

Classic tokens are powerful but dangerous. Fine-grained tokens let you define exactly what a token can do and where — here's how to set them up for common dev workflows.

GitHub Fine-Grained Tokens: A Practical Permission Guide

If you've used GitHub's classic personal access tokens, you know the drill: you pick a broad scope like repo, and suddenly that token has read/write access to every repository you own. It works, but it's not great from a security standpoint. A leaked token has a large blast radius.

Fine-grained tokens fix this. You pick specific repositories, then grant granular permissions per resource type. Here's a practical breakdown of how to configure them for real workflows.


Classic vs. Fine-Grained: What's the Difference?

FeatureClassicFine-Grained
Repo accessAll reposSpecific repos only
Permission granularityBroad scopesPer-resource control
ExpirationOptionalRequired (max 1 year)
Org admin approvalNot supportedSupported
Security postureLowerHigher

Classic tokens are still needed for some older integrations, but for anything new, fine-grained is the better choice.


Workflow: Clone a Repo, Push Changes, Open a PR

This is the most common developer workflow. Here's the minimal permission set you need:

PermissionLevelWhy
MetadataRead-onlyAlways required — can't be removed
ContentsRead and writeClone the repo + push to branches
Pull requestsRead and writeCreate PRs via API or UI

That's it. Everything else can stay at No access.

Cloning with a Fine-Grained Token

Cloning is a git operation, not a REST API call, but it still authenticates through the token. With Contents: Read in place, you can clone over HTTPS:

git clone https://<your-token>@github.com/owner/repo.git

Restricting the Token to New Branches Only

You might want a token that can push branches and open PRs, but can't merge into main. This is where a lot of people get tripped up: token permissions alone can't enforce this.

The distinction matters:

  • Token permissions — what the token is capable of doing in general
  • Branch protection rules — what GitHub will allow on specific branches, regardless of token capability

To properly enforce this, keep the token permissions as-is and add branch protection rules on main under Settings → Branches:

  • ✅ Require a pull request before merging
  • ✅ Require approvals
  • ✅ Restrict who can push to matching branches
  • ✅ Do not allow bypassing the above settings

With these in place, the token can push to any new branch and open PRs — but GitHub will reject any direct push to main at the server level.

ActionAllowed?
Clone the repo
Create a new branch
Push commits to that branch
Open a PR from that branch
Push directly to main
Merge a PR into main

Adding Issues Access

A quick note on how GitHub Issues works: issues are per repository. Each repo has its own independent tracker, and issues share a number sequence with pull requests — so if issue #5 exists, the next PR opened will be #6.

To let a token read issues, add:

PermissionLevel
IssuesRead-only

For creating issues too, bump it to Read and write.


The Issue Approval Problem

One thing GitHub doesn't support natively: approving issues before they go live. When a token holder creates an issue, it's immediately visible. There's no review queue.

The closest workaround is a GitHub Action that automatically closes and labels new issues as pending-review the moment they're created:

# .github/workflows/issue-moderation.yml
on:
  issues:
    types: [opened]

jobs:
  hold-issue:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              labels: ['pending-review']
            });
            await github.rest.issues.update({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              state: 'closed'
            });

The workflow becomes: token holder creates issue → Action closes it instantly with a pending-review label → you review and reopen it manually. The issue is still visible in the repo (just closed), so this isn't airtight — but it gives you a clear review gate without leaving GitHub.

If you need proper issue moderation, tools like Linear or Jira have this built in natively.


Full Permission Reference for This Workflow

Here's the complete token setup for clone + push + PR + issue review/creation:

PermissionLevel
MetadataRead-only (auto-selected)
ContentsRead and write
Pull requestsRead and write
IssuesRead and write

Pair this with branch protection rules on main and you have a solid, least-privilege setup for a contributor token.