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.
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):
| Prefix | Type | Acts 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 token | You, within the app's granted scopes |
ghu_… | GitHub App user-to-server token | You, via an App's identity |
ghs_… | GitHub App installation token | The App installation, on its installed repositories |
ghr_… | Refresh token | Renews 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:
repogrants 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 disclosedrepo-scoped token equals wholesale access to everything you can reach, which for most senior developers means years of private organizational history.workflowscope permits modifying GitHub Actions workflows. Combined with the fact that workflows routinely hold secrets in context, this makesworkflow-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:orgacross 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):
| Axis | Classic | Fine-grained |
|---|---|---|
| Repository scope | All reachable repos | Explicitly selected repos (or all, deliberately chosen) |
| Permissions | Coarse OAuth scopes | Granular per-capability: contents, issues, pull requests, secrets, workflows, administration… |
| Read/write split | Per scope family | Per permission (e.g., contents read-only) |
| Organization gate | Membership suffices | Org must allow fine-grained access; approval workflows optional |
| Expiration | Optional/extendable | Required, 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 -vprints 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-hoccurlcommands 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:
- Default to fine-grained, read-only, repo-selected. Make the generous token the deliberate exception requiring justification, not the default copied from a quickstart.
- 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.
- 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.
- 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.
- Enable org-side governance. Admin approval for fine-grained access to org repos plus periodic OAuth-app access reviews closes the third-party path.
- 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:
| Consumer | Right token shape | Lifetime |
|---|---|---|
| Local development | Fine-grained, contents-read, your active repos | 90 days, calendar-renewed |
| CI workflows | GITHUB_TOKEN (built-in), OIDC for clouds | Per-run |
| Third-party SaaS | Fine-grained via installation/OAuth, narrowest grant | Vendor-dependent; reviewed quarterly |
| Long-running automation | Machine account's fine-grained PAT or a GitHub App | App 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):
| Scope | What it grants | Incident significance |
|---|---|---|
repo | Full read/write across all personal and org repos, private included | Whole-account disclosure in one string; no read-only variant exists |
workflow | Modify GitHub Actions workflows | Secrets-exfiltration primitive: alter a workflow to print its environment, trigger it, read the log |
read:org / admin:org | Org membership/settings visibility or control | Admin variants enable policy changes, member management paths |
read:packages / write:packages | Registry access | Exfiltrate private artifacts; publish malicious package versions |
delete_repo | Repository deletion | Destructive capability rarely justified in automation tokens |
gist | All gists | Gists are routinely used as scratch storage of real material |
user, notifications, … | Personal data surfaces | Lower 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
storehelper writes credentials plaintext to~/.git-credentials;cacheholds 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 statusreports what's live, and token scopes appear alongside. - Remote-embedded credentials. Cloning as
https://<token>@github.com/...writes the token into.git/configin plain text, surviving as long as the clone does — grep remotes across workspaces during audits (the audit routine above). .netrcand 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.