How to pick the right GitHub credential for a job, and how to avoid the misconfiguration I run into most: a classic token carrying the repo scope.
Important
The one rule that covers most cases: reach for a fine-grained personal access token, scoped to the specific repositories and the minimum permissions you need, with the shortest expiry you can live with. Fall back to a classic token only where a feature still can't use fine-grained tokens. In practice that's just GitHub Packages.
| I need to… | Use this |
|---|---|
| Reach a few specific repos from a script, laptop, or cron job | Fine-grained PAT, those repos only, minimum permissions |
| Automate something across one or several orgs | GitHub App (installation token) |
| Read or write GitHub Packages (npm, Maven, NuGet, RubyGems, containers) outside Actions | Classic PAT scoped to only read:packages / write:packages, never repo |
| Do anything from inside a GitHub Actions workflow | The built-in GITHUB_TOKEN, permissions raised via the permissions: key |
| Give one repo's CI push/pull access | Deploy key |
Never share a single token between people or systems. A shared token is a shared password, and once it's shared you've lost the answer to two questions that matter: who holds it, and where it's used. Give each actor its own credential instead (see Stop sharing service-account tokens).
| Type | Scope model | Lifetime | Use it for |
|---|---|---|---|
| Fine-grained PAT | Per-repository plus per-permission (e.g. Contents: read) | You choose; expiry optional | The default for individual or scripted access |
| Classic PAT | Coarse scopes (repo, read:packages, …) that apply account-wide |
Optional expiry | Only where fine-grained isn't supported (Packages) |
GITHUB_TOKEN (Actions) |
Per-workflow or per-job via permissions: |
A single workflow run | Anything running inside Actions |
| GitHub App | Fine-grained, installation-scoped | Short-lived installation tokens | Multi-org automation |
GitHub made fine-grained PATs generally available on 18 March 2025 and now recommends them over classic tokens.
The classic wizard spells out what a classic PAT actually is: it functions like an ordinary OAuth access token, so it stands in for your password when you push Git over HTTPS, and it authenticates to the API over Basic Authentication. That's exactly why the coarse scopes bite. Anything that would accept your password now accepts this one string, at whatever breadth its scopes allow.
The misconfiguration I see most often is a classic token created with the repo scope.
Warning
The classic repo scope is not "access to one repository." It grants the token access to every repository the account can read, enterprise-internal repos included. Leak one token and you've leaked access to all of those repositories at once.
The name makes it sound like access to your repository. It isn't. repo hands the token everything the user can read, which pulls in three buckets people rarely think about together:
- every private repository you can see,
- every public repository you can touch, and
- every enterprise-cloud internal repository, the ones exposed across the enterprise for inner-sourcing.
A token you made to nudge one repo can, if it leaks, walk straight into your organisation's entire internal codebase. Almost nobody creating that token intends that.
A fine-grained token has a much smaller, more controlled blast radius. You pick the exact repositories it may touch, then grant the exact permission it needs, say Contents: read and nothing more. Layer on the two things GitHub keeps pushing, least privilege and a short expiration, and a leaked token drops from "the whole org" to "one repo, read-only, expires Friday."
- Resource owner: set it to the org that owns the repos. Org approval may be required.
- Repository access: select only the specific repositories. Don't reach for "All repositories."
- Permissions: grant the minimum. Reading code needs Contents: read and nothing else.
- Expiration: pick the shortest window the task allows, and re-issue when it lapses. Fine-grained tokens can be set to "No expiration," but don't: a token that never dies is a liability that outlives the reason you made it. Note that an org or enterprise policy may cap the lifetime anyway (366 days by default).
If your org gates fine-grained tokens behind approval, the token sits inert until an admin approves it. Wait for that rather than routing around it.
There's exactly one place a fine-grained token can't go: GitHub Packages.
Package registries authenticate with classic PAT scopes, or with the Actions GITHUB_TOKEN covered below:
read:packagesdownloads and installs packages,write:packagespublishes them,delete:packagesdeletes them.
Both write:packages and delete:packages pull in read:packages: check either in the "New personal access token" wizard and GitHub silently ticks read:packages for you. Easy to miss, but it means those tokens can read your packages too.
Fine-grained PATs don't work here, and haven't for years.
Note
Don't hold your breath. This is tracked as issue #558, opened 14 September 2022 and still open as of July 2026. Its last update, on 21 November 2024, quietly pulled it off GitHub's roadmap. Treat classic-only Packages auth as the status quo rather than a gap about to close, and re-check the issue before you bet anything long-term on it.
So when you must authenticate to Packages outside Actions, the right minimal choice is a classic token holding only read:packages (or write:packages), never repo. A read:packages token can't read your source; a repo token reads everything. Scope down.
Here's the pattern that keeps coming back: someone wants a "service account" whose token gets shared and "treated like a password," pasted into a wiki, a shared vault entry, a handful of pipelines. That's insecure almost by definition. Three questions expose why:
- Who has access to it? Once it's shared, you genuinely can't say.
- Where is it used? Rotating it means hunting down every last consumer first.
- What happens when it leaks? Revoking it breaks everyone at once, and because the audit log shows one faceless "service account," you can't tell who did what.
GitHub's own guidance is blunt about this. Don't share personal access tokens. Treat them like passwords, never hardcode them in source (not even in a private repo), and keep any long-lived credential in a secret manager such as 1Password, Azure Key Vault, or HashiCorp Vault instead of passing it hand to hand.
For the two reasons people usually reach for a shared token, publishing packages from CI and org-wide automation, GitHub already ships something better.
Pushing packages from a GitHub Actions workflow? You don't need a service-account token for that. Every run already gets a built-in GITHUB_TOKEN, and you grant it the extra permission with the permissions: key:
permissions:
contents: read
packages: writeSet permissions: at the workflow level to cover every job, or per job for tighter control. GitHub creates this token fresh for each run and revokes it the moment the run ends, scopes it to exactly the permissions you declared, and attributes every action to the workflow rather than to some shared identity. Nothing to store, nothing to rotate, no "who has the password" problem. Public container images on ghcr.io don't even need a token to pull.
- GitHub App: the right answer for org-level automation, when a bot needs standing access across many repos. It issues short-lived installation tokens with fine-grained permissions.
- Deploy key: an SSH key bound to a single repository. Ideal for one-repo CI/CD access, read-only or read/write, with no account-wide token anywhere.
- Secret manager: wherever a long-lived credential genuinely has to exist, park it in 1Password, Azure Key Vault, or HashiCorp Vault behind real access controls and an audit trail. Not a wiki, not a chat message.
Where you store a token matters as much as which one you pick, and the variable name is an easy trap. The GitHub CLI (gh) reads its credentials from the environment, and it treats GH_TOKEN and GITHUB_TOKEN as authentication input, in that order of precedence. Export your own classic PAT under either name and every gh command in that shell silently authenticates as that token instead of your configured login.
That's a problem in both directions:
- It hijacks
gh. Commands you meant to run as yourself now run under the token's identity and scopes, which can succeed, fail, or mutate the wrong resource in ways that are hard to spot. - It leaks into places you didn't intend.
ghis invoked by other tooling (setup-actions, editor extensions, scripts), so a token parked under the nameghlooks for can travel further than the one script you exported it for.
The same two names are also injected automatically inside GitHub Actions, so reusing them for a personal token in a workflow shadows the built-in GITHUB_TOKEN (see the Actions section above).
Pick a name that says what the token is for and that gh will never read. For the classic Packages token above, PACKAGES_READ_TOKEN (or PACKAGES_WRITE_TOKEN) states its job outright. When a specific service owns the token, prefix it: MYAPP_GITHUB_PAT. Tie it to a registry client where that's the real use, like NPM_GITHUB_TOKEN or MAVEN_GITHUB_TOKEN. The only hard constraint is the one at the top of this section: not GH_TOKEN, not GITHUB_TOKEN.
And if some tool insists on reading GH_TOKEN or GITHUB_TOKEN, set it for that one command instead of exporting it shell-wide (GH_TOKEN=… some-command), so it can't bleed into the next gh you run.
Have the plan ready before you need it. When a token is exposed:
- Generate a replacement credential.
- Roll it out everywhere the old one was used.
- Revoke the exposed token, and do it fast.
Short expirations and narrow scopes shrink every one of those steps.
Found a token in a log, a paste, or someone else's repo and don't know whose it is? You can still kill it. GitHub's POST /credentials/revoke endpoint takes up to 1,000 token strings and revokes them, and it's deliberately unauthenticated: send it a token and any authenticated request is rejected with a 403. That's the whole point, since you're reporting a credential you don't own. GitHub notifies the owner so they can rotate. Rate limit is 60 requests per hour.
Everything above comes from GitHub's own documentation. When GitHub's behaviour shifts, start here, and watch the Packages caveat in particular since it's the piece most likely to change.
- Keeping your API credentials secure: https://docs.github.com/en/rest/authentication/keeping-your-api-credentials-secure?apiVersion=2026-03-10
- Managing your personal access tokens: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens
- Fine-grained PATs are now generally available (18 March 2025): https://github.blog/changelog/2025-03-18-fine-grained-pats-are-now-generally-available/
- Permissions required for fine-grained personal access tokens: https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens
- Introduction to GitHub Packages: https://docs.github.com/en/packages/learn-github-packages/introduction-to-github-packages
- About permissions for GitHub Packages: https://docs.github.com/en/packages/learn-github-packages/about-permissions-for-github-packages
- Packages support for fine-grained PATs (roadmap #558, open as of July 2026): github/roadmap#558
- Authenticate with
GITHUB_TOKEN, granting additional permissions: https://docs.github.com/en/actions/tutorials/authenticate-with-github_token#granting-additional-permissions - Revoke a list of credentials (unauthenticated endpoint): https://docs.github.com/en/rest/credentials/revoke?apiVersion=2026-03-10
gh help environment—GH_TOKEN/GITHUB_TOKENprecedence and credential override: https://cli.github.com/manual/gh_help_environment