Skip to content
KeyDrift
Scan for free
All posts

GitHub tokens: classic versus fine-grained, and what each leaks

Classic versus fine-grained token models compared from GitHub documentation: what each exposes, org controls, and the hygiene setup that lasts.

KeyDrift13 min read

GitHub tokens: classic versus fine-grained, and what each leaks

For developers and teams automating against GitHub: how the two token generations differ structurally, what a disclosed token of each type exposes, and the configuration that minimizes damage without breaking daily work.

GitHub tokens deserve closer analysis than most credentials because their scope models differ categorically, not incrementally. A classic token operates on an all-or-nothing model across your entire account; fine-grained tokens invert that into per-repository, per-permission granularity. Choosing between them — and configuring org policies around them — determines whether a single disclosed string means "one workflow broke" or "every private repository I can access is readable."

Formats, scopes, and behaviors below follow GitHub's token documentation and related pages, cited inline. Example tokens are synthetic placeholders matching documented prefixes.

The token families

GitHub issues several token types, distinguishable by prefix (token security guidelines):

PrefixTypeActs as
ghp_…Personal access token (classic)You, everywhere you have access
github_pat_…Personal access token (fine-grained)You, on selected repositories, with selected permissions
gho_…OAuth app tokenYou, within the app's granted scopes
ghu_…GitHub App user-to-server tokenYou, via an App's identity
ghs_…GitHub App installation tokenThe App installation, on its installed repositories
ghr_…Refresh tokenRenews ghu_… tokens

Synthetic example: ghp_SYNTHETICplaceholderNOTreal0000000000. The prefixes matter operationally — scanners key on them, and responders triage differently knowing a ghs_… token is installation-scoped while ghp_… is account-wide.

Classic tokens: the all-or-nothing model

Classic PATs carry OAuth-style scopes (available scopes), and their granularity stops there. The load-bearing scope is repo:

  • repo grants full read/write on all your repositories — private included — plus org repositories you belong to. There is no "read-only my-current-project" classic token. One disclosed repo-scoped token equals wholesale access to everything you can reach, which for most senior developers means years of private organizational history.
  • workflow scope permits modifying GitHub Actions workflows. Combined with the fact that workflows routinely hold secrets in context, this makes workflow-scoped tokens a secrets-exfiltration primitive: modify a workflow to print its environment, trigger it, read the log. This escalation path is why GitHub's guidance treats workflow-scope distribution carefully.
  • Other scopes extend outward: admin:org across organization settings, delete_repo, package scopes, and so on.

Expiration behavior compounds the model: classic tokens support extended or no-expiration lifetimes, so decade-old tokens with repo scope genuinely exist in the wild — discoverable in old CI configs, shell histories, and documentation pages.

Fine-grained tokens: scoping as structure

Fine-grained PATs restructure the problem (managing fine-grained tokens):

AxisClassicFine-grained
Repository scopeAll reachable reposExplicitly selected repos (or all, deliberately chosen)
PermissionsCoarse OAuth scopesGranular per-capability: contents, issues, pull requests, secrets, workflows, administration…
Read/write splitPer scope familyPer permission (e.g., contents read-only)
Organization gateMembership sufficesOrg must allow fine-grained access; approval workflows optional
ExpirationOptional/extendableRequired, bounded

The structural consequences: a fine-grained token granting Contents: Read on two selected repositories discloses, at worst, those repositories' files. Metadata permissions are mandatory-minimal; everything else is opt-in. Organization administrators control whether member-created fine-grained tokens can touch org repositories at all, with request/approval flows available — moving token governance from individual discretion into admin-visible process.

One caveat preserves honesty: fine-grained tokens are strictly better as designed, but "All repositories" remains selectable during creation, and permission checkboxes still permit generous combinations. The model constrains accidents; it doesn't prevent intent.

The Action-token special case

Workflows receive a generated GITHUB_TOKEN — effectively an installation token scoped to the triggering repository, with permissions configurable per workflow or defaulted at org/repo level (GITHUB_TOKEN documentation). Two properties matter for leak analysis:

  • Default it restrictive: setting the default to read-only contents and elevating per-job keeps routine workflows from holding write broadly.
  • The known sharp edge is privileged triggers like pull_request_target, where workflow logic runs with secrets available while processing untrusted input — a documented hardening topic in GitHub's security-hardening guide, adjacent to this article but worth flagging wherever tokens and automation meet.

Where tokens actually leak

Token leakage concentrates in a handful of reproducible places, each worth a standing check:

  • Git remotes with embedded credentials: cloning over HTTPS with a token in the URL persists it in .git/config; git remote -v prints it forever after. Prefer credential managers or SSH.
  • CI logs: tokens passed through environment interpolation into logged commands — subject to the masking limits covered in the CI-artifacts guide.
  • Shell history and dotfiles: exported tokens in .bashrc, ad-hoc curl commands with -H "Authorization: token …" recorded by history mechanisms.
  • Client bundles: GitHub tokens occasionally surface in web bundles when frontend code calls the API directly — always a finding; the API deserves a server-side proxy holding the token.
  • Documentation and fixtures: README quickstarts and test suites carrying real tokens alongside synthetic ones teaches everyone downstream that real-looking tokens in files are normal (the placeholder paradox).

Detection-wise, GitHub runs secret scanning on its own platform with push protection blocking many token pushes at commit time — coverage detailed honestly in the GitHub secret-scanning analysis. That system watches GitHub's git layer; it says nothing about tokens that escaped into bundles, binaries, or logs, which is where artifact-level scanning (free scan here) picks up.

The hygiene setup that survives real workflows

Configuration that holds up under deadline pressure, in descending order of impact:

  1. Default to fine-grained, read-only, repo-selected. Make the generous token the deliberate exception requiring justification, not the default copied from a quickstart.
  2. Bound expirations tightly. Ninety-day expirations with calendar-driven renewal beat "no expiration" by construction; rotation becomes scheduled maintenance (the rotation playbook) rather than incident response.
  3. Separate identities for automation. Machine accounts holding only the access they need make both scoping and auditing tractable — and make revocation painless when a tool is decommissioned.
  4. Store tokens in secret stores, never in files that sync. Environment injection from CI secret contexts, password-manager-backed local retrieval — anything but plaintext in repos, dotfile backups, and shared drives.
  5. Enable org-side governance. Admin approval for fine-grained access to org repos plus periodic OAuth-app access reviews closes the third-party path.
  6. Audit with the tools GitHub provides. The security log enumerates token usage events; org settings enumerate outstanding accesses. Quarterly review, calendar entry, done.

Mapping consumer types to token shapes makes the defaults concrete:

ConsumerRight token shapeLifetime
Local developmentFine-grained, contents-read, your active repos90 days, calendar-renewed
CI workflowsGITHUB_TOKEN (built-in), OIDC for cloudsPer-run
Third-party SaaSFine-grained via installation/OAuth, narrowest grantVendor-dependent; reviewed quarterly
Long-running automationMachine account's fine-grained PAT or a GitHub AppApp tokens rotate automatically

The table's discipline: every consumer gets the narrowest shape that works, and nothing human-held lives longer than a quarter without explicit renewal — which converts silent persistence into visible decisions (the audit routine verifies the conversion actually happened).

Reading the classic scope list like an incident

The classic scope menu deserves line-by-line reading, because each entry encodes a specific escalation story (scopes reference):

ScopeWhat it grantsIncident significance
repoFull read/write across all personal and org repos, private includedWhole-account disclosure in one string; no read-only variant exists
workflowModify GitHub Actions workflowsSecrets-exfiltration primitive: alter a workflow to print its environment, trigger it, read the log
read:org / admin:orgOrg membership/settings visibility or controlAdmin variants enable policy changes, member management paths
read:packages / write:packagesRegistry accessExfiltrate private artifacts; publish malicious package versions
delete_repoRepository deletionDestructive capability rarely justified in automation tokens
gistAll gistsGists are routinely used as scratch storage of real material
user, notifications, …Personal data surfacesLower stakes individually; broaden social-engineering surface

Fine-grained permissions slice the same territory differently: Contents: Read on selected repositories approximates the useful core of repo without its blast radius; the workflows permission can simply be withheld from integrations that never touch CI; administration splits into per-capability grants instead of admin:* monoliths. When auditing any token you find — in configs, CI stores, vendor dashboards, old clones — translate its model through this table before judging severity: a repo token is categorically different from even a generous fine-grained grant, regardless of how "scoped" its description sounds.

The translation habit also sharpens detection triage. A leaked gist-only token warrants a shrug; a leaked repo token warrants the full first-hour runbook; a leaked workflow token deserves both plus immediate workflow-file review, since modification is its primary abuse path. Severity routing that respects these differences keeps response capacity pointed where blast radius actually lives.

An organization-level audit routine

Individual hygiene collapses without organizational visibility, because tokens outlive teams and vendor integrations accumulate silently. GitHub exposes the pieces for a standing audit:

# Audit-log events touching tokens and OAuth (admin):
gh api "/orgs/{owner}/audit-log?include=all&per_page=100" \
  -q '.[] | select(.event | test("token|oauth")) | {created_at, actor, event}'

# Pending fine-grained PAT access requests awaiting review:
gh api "/orgs/{owner}/personal-access-token-requests" -q '.[].reason'

(Check current API paths against your plan; the audit-log surface in particular has grown steadily.)

Quarterly, walk three lists against reality. Member-created fine-grained requests pending approval — approve narrowly or reject with guidance toward narrower scopes. OAuth application grants — revoke anything nobody can name an owner for, because abandoned SaaS integrations holding repo-equivalent access are dormant incidents wearing integration costumes. Machine accounts — verify each still maps to living automation, since orphaned machine identities hold their grants forever and answer no questions about why.

Feed findings into a standing inventory rather than treating audits as resets. Trend lines motivate governance better than snapshots: "vendor grants grew forty percent year over year" moves budget conversations that "we did an audit" never does. Close the loop with defaults so future tokens arrive pre-scoped — org policies requiring approval for fine-grained org-repo access, expiration requirements enforced centrally, and request templates that make asking for a narrow token easier than borrowing someone's broad one. Governance that smooths the safe path compounds quietly; governance that taxes it gets routed around.

Where tokens live on disk

Tokens leak through storage as often as through code, and the storage layer has its own documented mechanics worth knowing:

  • Git credential helpers. The store helper writes credentials plaintext to ~/.git-credentials; cache holds them in memory with a timeout; platform-appropriate helpers (macOS Keychain, Windows Credential Manager, libsecret) encrypt at rest. Verify which helper your machines use (git config --get credential.helper) — plaintext store files are a standing finding.
  • GitHub CLI. Sessions persist under ~/.config/gh/, using OS keyrings where available and falling back to readable files otherwise; gh auth status reports what's live, and token scopes appear alongside.
  • Remote-embedded credentials. Cloning as https://<token>@github.com/... writes the token into .git/config in plain text, surviving as long as the clone does — grep remotes across workspaces during audits (the audit routine above).
  • .netrc and environment exports. Legacy curl workflows and CI-era habits leave tokens in home-directory files and shell profiles that backup tools sync happily.

Each storage location implies its mitigation: manager-backed helpers over plaintext stores, SSH or OAuth-over-HTTPS remotes instead of URL-embedded tokens, and secret-store injection for anything automation needs. The unifying principle matches everything else in this series — storage choices determine blast radius before any incident occurs, and auditing them takes minutes against incidents taking days (the first-hour runbook).

Expiration deserves enforcement at the policy layer wherever possible: organization settings can require expiration for member-created tokens, converting the strongest hygiene habit from personal discipline into default reality. Individuals running older accounts should check their own inventory once — gh auth status locally, the tokens settings page globally — and retire anything without an expiration date. Tokens that cannot expire become someone else's archaeology project eventually; the only question is whether that someone is you on a calm Tuesday or a responder during an incident.

Renewal becomes routine when anchored to existing rhythms rather than memory: expiration dates chosen to land inside quarterly review cycles mean tokens renew during audits that happen anyway, and gh auth refresh flows handle personal sessions in seconds. Teams that let expiration land randomly — or worse, disable expiration to stop the annoyance — reconvert defaults back into the archaeology problem this entire article exists to prevent.

One migration note completes the picture: classic tokens cannot be converted in place, so fine-grained adoption happens token-by-token at renewal moments — each expiry event becomes the decision point where the replacement arrives scoped correctly or the broad status quo gets consciously renewed. Framing expiration as the forcing function reframes it from annoyance to architecture: the calendar does the enforcement that policies alone rarely achieve.

And when audits surface tokens nobody can attribute, treat unattributability itself as the finding: credentials without owners cannot be rotated confidently, scoped correctly, or retired safely. The remediation is always the same shape — revoke, wait for something to break, reissue narrowly to whatever surfaces. Ownership records are what make rotation boring; their absence makes everything else in this article harder.

FAQ

A ghp_ token with repo scope appeared in a public bundle for hours. Response? Treat as full account-repository disclosure for that window: revoke the token immediately, review the audit/security log for its usage during exposure, and verify no new keys, collaborators, or workflow modifications were added — the workflow-scope escalation applies to anything holding it.

Are fine-grained tokens usable with everything yet? Coverage across APIs and org features has gaps that vary by feature area; check current documentation for your specific endpoints before migrating critical automation. The safe pattern: migrate incrementally, keeping the classic token only where a gap forces it.

Do App installation tokens (ghs_) change the calculus? Favorably, where automation lives in one place: installations carry their own permission sets and repo selections, renewable short-lived tokens included. For team-wide automation, a GitHub App is often the cleanest identity — better isolation than any PAT.

Our tokens only ever appear in CI env vars. Done? That covers the happy path. The remaining questions are derivation (encoded tokens bypass literal masking — see the CI guide) and lifespan: a token valid for a year that leaked yesterday is still valid tomorrow without action.


Tokens leak where they're convenient, not where you expect. Run the free scan at keydrift.dev/scan and find out what your deployed artifacts are serving — no signup required.

KeyDrift reads the JavaScript your app actually serves and finds the Supabase, Stripe, OpenAI and AWS keys that should never have left your server. Run a free scan.

KeyDrift is a Veristria product. More about KeyDrift.