Choosing a GitHub credential is a security decision disguised as a configuration step.
The convenient choice — a personal access token with broad scopes and no expiry — works immediately and is the wrong answer almost everywhere. This lesson is about making the decision deliberately.
The options
Section titled “The options”| Credential | Identity | Scoping | Lifetime |
|---|---|---|---|
| Fine-grained PAT | A person | Per repository, per permission | Expiry required |
| Classic PAT | A person | Coarse scopes, all their repositories | Optional expiry |
| GitHub App installation token | The app | Per installation and permission | ~1 hour, auto-renewed |
| GitHub App user token | A person, via the app | The app’s permissions ∩ the user’s | Short, refreshable |
| OAuth app token | A person | Coarse scopes | Until revoked |
Actions GITHUB_TOKEN | The workflow run | The workflow’s repository | The job’s duration |
| None | — | Public read only | — |
The decision framework
Section titled “The decision framework”Four questions settle almost every case.
1. Who is acting — a person, or a system?
If a system, it should have its own identity. A GitHub App is an identity; a personal token borrowed by a system is a person’s identity being impersonated by a script.
2. Whose data is it acting on?
Their own → a personal token is reasonable. An organisation’s → an App, so access survives the person leaving.
3. How long does it need to work?
Short-lived credentials are strictly better. An App installation token lasts about an hour and renews itself, so a leaked one has a small window.
4. What is the smallest permission set that works?
Grant that. Not “repo”, which on a classic PAT means every repository the person can reach.
The scenarios
Section titled “The scenarios”| Scenario | Use | Why |
|---|---|---|
| Personal CLI use | gh auth login | Managed for you, stored in the OS keyring |
| One-off personal script | Fine-grained PAT | Scoped to the repositories it needs, expires |
| Team or organisation automation | GitHub App | Own identity, survives staff changes, bounded |
| Workflow acting on its own repository | GITHUB_TOKEN | Automatic, short-lived, correctly scoped |
| Workflow acting across repositories | GitHub App | The workflow token is repository-scoped |
| Application acting for users | App or OAuth | User consent and per-user permissions |
| Reading public data | Often none | 60 requests per hour unauthenticated |
Treat this as a starting point rather than law. The reasoning matters more than the table: the recurring answer is the narrowest credential that expires.
Scopes versus fine-grained permissions
Section titled “Scopes versus fine-grained permissions”Classic PATs use scopes — coarse, account-wide capabilities:
repo Full control of all repositories, public and privateworkflow Update GitHub Actions workflow filesadmin:org Full control of organisationsdelete_repo Delete repositoriesrepo is the problem. It cannot be narrowed to one repository, and it includes write access. A
script that only needs to read one repository’s Issues, given a classic PAT, can delete code in every
repository its owner can push to.
Fine-grained tokens replace scopes with per-repository, per-resource permissions:
Repository access: only selected repositoriesPermissions: Issues Read and write Metadata Read-only (required) Contents Read-onlyThat token can read and file Issues on two repositories and nothing else. Covered in Fine-Grained Personal Access Tokens.
The Actions workflow token
Section titled “The Actions workflow token”Inside GitHub Actions, a token is provided automatically:
- name: Comment on the pull request env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh pr comment "$PR_NUMBER" --body "Build finished."Its properties are good defaults: it exists only for the job, is scoped to the workflow’s repository, and its permissions are configurable per workflow.
Set them explicitly rather than relying on the default:
permissions: contents: read pull-requests: writeDeclaring permissions narrows the token to exactly what the job needs. Omitting it accepts whatever
the repository’s default is, which may be considerably broader.
Two limits are worth knowing. The token cannot act on other repositories — cross-repository automation needs an App. And by design, events it triggers do not start further workflows, which prevents infinite loops and occasionally surprises people expecting a chain reaction.
Using a credential
Section titled “Using a credential”export GITHUB_TOKEN="YOUR_TOKEN_HERE"
curl -sS https://api.github.com/repos/OWNER/REPO \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28"Checking what a token can do is worth doing before debugging a permissions problem:
curl -sI https://api.github.com/user \ -H "Authorization: Bearer $GITHUB_TOKEN" | grep -i '^x-oauth-scopes'What it doesPrints the OAuth scopes attached to a classic token, from the response headers.
Why we run itA 403 on an endpoint you believe you can reach is usually a missing scope rather than a repository permission. This shows what the token actually carries.
Expected resultAn x-oauth-scopes header. Fine-grained tokens return no scopes header — their permissions are not exposed this way.
Handling credentials
Section titled “Handling credentials”Where they should live:
- Locally —
gh auth login, which uses the OS keyring; or an environment variable from a password manager - In CI — the platform’s secret store, injected as an environment variable
- In production — a dedicated secret manager with audit logging and rotation
- Never — in the repository, in a Dockerfile, in a config file, or in an image layer
Rotation deserves a plan rather than an intention. Fine-grained tokens require an expiry, which forces the issue; classic tokens can live forever, which is precisely why they end up in incident reports.
A worked decision
Section titled “A worked decision”“A nightly job labels stale Issues across twenty repositories in our organisation.”
- Who acts? A system → not a personal token.
- Whose data? The organisation’s → must survive staff changes.
- How long? Ongoing → short-lived, renewable.
- What permissions? Issues read and write. Not contents, not administration.
Answer: a GitHub App, installed on those twenty repositories, with Issues write and Metadata read. Nobody’s personal access is involved, permissions are visible and auditable, tokens last an hour, and when the person who set it up leaves, nothing breaks.
The tempting alternative — a classic PAT with repo in a scheduled workflow — grants write access to
every repository that person can reach, forever, and stops working the day they leave.
Verifying what a credential can do
Section titled “Verifying what a credential can do”Before debugging a permission problem, establish what you are holding. The checks differ by credential type, which is itself diagnostic.
# Who am I? Works for user tokens; fails for installation tokens.gh api user --jq '.login'
# Classic tokens expose their scopes in a response header.curl -sI https://api.github.com/user \ -H "Authorization: Bearer $GITHUB_TOKEN" | grep -i '^x-oauth-scopes'
# Fine-grained tokens return no scopes header — permissions are per repository.# Test an endpoint instead.gh api repos/OWNER/REPO --silent && echo "can read repo"
# What rate limit applies? The number identifies the credential class.gh api rate_limit --jq '.resources.core.limit'That last one is a useful trick. A limit of 60 means you are unauthenticated — the token is not being sent, or is invalid. 5,000 means an authenticated user or App installation. 1,000 means the Actions workflow token. Establishing which of those you have takes one call and frequently ends the investigation.
Storing credentials
Section titled “Storing credentials”Where a token lives determines what happens when something goes wrong.
| Location | Acceptable? | Notes |
|---|---|---|
OS keyring via gh auth login | Yes | Best for a workstation |
| Environment variable from a password manager | Yes | Good for shells and one-off scripts |
| CI secret store | Yes | Injected per job, not persisted |
| Dedicated secret manager | Yes | Best for production; audit and rotation |
~/.netrc or a plain config file | Marginal | Readable by anything running as you |
| Committed to a repository | Never | Permanent, distributed, one setting from public |
| A command-line argument | Never | Shell history and the process list |
| Baked into a container image | Never | Persists in every layer and every copy |
The rows that people get wrong are the last two. A token in docker build --build-arg remains in the
image history even if the final layer removes it, and a token in a command is visible to any other
user on the machine via ps.
Rotation as a routine
Section titled “Rotation as a routine”Rotation fails when it is an event. Making it routine:
Prefer credentials that expire. Fine-grained tokens require it; App installation tokens expire in about an hour automatically. Forced rotation beats intended rotation.
Store centrally. A token pasted into three CI configurations must be updated in three places, and one will be missed.
Overlap. Create the replacement, update the store, verify, then revoke the old one. Revoking first means an outage.
Alert before expiry. GitHub emails; that email is easy to miss. A calendar entry or a monitoring check is more reliable.
Practise it. A rotation procedure first exercised during an incident is a procedure nobody has tested.
Responding to an exposed credential
Section titled “Responding to an exposed credential”The order matters, and the first step is the one people delay.
- Revoke it. Immediately, before investigating. A revoked token cannot be used regardless of who has it.
- Issue a replacement and update whatever depended on the old one.
- Check what it could reach. Scopes or permissions plus repository access defines the blast radius.
- Review the audit log for activity you do not recognise during the exposure window.
- Fix the leak path. A token in a repository needs the commit history dealt with; a token in a log needs the logging fixed.
- Reduce the permissions on the replacement if the original was broader than needed.
Step 1 before step 3 is the important ordering. Investigating first leaves a live credential in the open while you work out how bad it is.
Note what is not on the list: deleting the commit, editing the log, or making the repository private. None of those invalidate the credential, and treating them as remediation is how tokens stay live for months after their exposure was noticed.
Common mistakes
Section titled “Common mistakes”Classic PAT with repo for everything. The broadest possible grant for the narrowest task.
Personal token for team automation. Ties infrastructure to an individual.
No expiry. Guarantees a forgotten credential.
Not setting permissions: in workflows. Accepts a broader default.
Expecting the workflow token to reach other repositories. It cannot.
Deleting a leaked token’s file instead of revoking it. Does nothing.
Debugging a 403 as a repository permission. Check the token’s scopes first.
OAuth Apps, and why they are rarely the answer now
Section titled “OAuth Apps, and why they are rarely the answer now”OAuth Apps predate GitHub Apps and still exist. The distinction matters when you are choosing, and when you are auditing what already has access to your account.
An OAuth App acts as the user who authorised it, with coarse scopes, across everything that user can reach. There is no installation, no per-repository selection, and no way to grant it access to two repositories rather than all of them.
| OAuth App | GitHub App | |
|---|---|---|
| Identity | The authorising user | Itself |
| Access | Everything the user can reach | Only where installed |
| Permissions | Coarse scopes | Per-resource |
| Token lifetime | Until revoked | ~1 hour, renewed |
| Webhooks | Configured separately | Built in |
| Org visibility | Limited | Installed, listed, revocable |
For nearly every new integration, a GitHub App is better. The remaining case for OAuth is a product that genuinely needs to act as the user everywhere they have access — and even then, GitHub Apps support user access tokens that achieve most of it with better bounds.
The practical relevance for most readers is auditing: reviewing which OAuth Apps have been authorised on your account, and revoking the ones you no longer recognise. An OAuth authorisation from a tool you used once in 2022 still has whatever scopes you granted then.
The Actions token in detail
Section titled “The Actions token in detail”The workflow token is the credential most people use without thinking about it, and its properties are worth knowing precisely.
permissions: contents: read pull-requests: write issues: write actions: read checks: write packages: noneSetting permissions explicitly narrows the token to exactly what the job needs. Omitting it accepts
the repository or organisation default, which may be considerably broader — historically write-all,
and still write-all on some older repositories.
Declaring it also documents what the workflow does, which is useful in review: a workflow requesting
contents: write when it claims only to post a comment deserves a question.
Key properties:
Scoped to one repository. It cannot read or write another, which is the limitation that pushes cross-repository automation towards Apps.
Expires with the job. No cleanup, no rotation, no leaked long-lived credential.
Restricted for fork pull requests. Runs triggered by a pull request from a fork get a read-only token and no access to secrets. This is deliberate and it is why some checks fail on contributions in ways they never do internally.
Its events do not trigger further workflows. A commit pushed with the workflow token will not start another workflow run. This prevents infinite loops and surprises people expecting a chain reaction — if you need one, that requires a different credential, which is itself a useful piece of friction.
Auditing what has access
Section titled “Auditing what has access”Periodically worth reviewing, and easy to defer indefinitely:
- Personal access tokens — delete anything you cannot account for.
- OAuth App authorisations — revoke tools you no longer use.
- Installed GitHub Apps — on your account and on organisations you own.
- SSH keys — one per machine you still have.
- Deploy keys on repositories — scoped, but easy to forget.
- Organisation token policy — whether fine-grained tokens need approval, and what has been approved.
The question for each entry is not “is this dangerous?” but “do I know why this exists?”. Anything you cannot explain should go; if something breaks, you have found out what it was for, and re-creating a credential is cheap.
For an organisation, the audit log records credential use, and reviewing it for tokens acting outside expected hours or from unexpected sources is the closest thing to detection available.
Exercise
Section titled “Exercise”- Create a fine-grained token limited to one repository with Issues read-only, expiring in seven days.
- Use it to list that repository’s Issues successfully.
- Try to create an Issue with it and read the failure.
- Try to read a different repository and observe the 404 rather than a 403.
- Check
x-oauth-scopeswith a classic token and note that the fine-grained one returns nothing there. - Revoke the token and confirm the same call now fails.
Step 4 is the one worth dwelling on: least privilege makes unauthorised resources invisible rather than forbidden, which is why “not found” so often means “not permitted”.
What you learned
Section titled “What you learned”- The decision is who acts, on whose data, for how long, with what minimum permission.
- Classic scopes are account-wide;
repogrants write everywhere its owner can reach. - Fine-grained tokens scope per repository and per permission, and must expire.
- App installation tokens are short-lived, auto-renewing and belong to the app rather than a person.
- The Actions token is repository-scoped and cannot reach other repositories.
- Always set
permissions:explicitly in workflows. - A leaked credential must be revoked; deletion is not remediation.
Summary decision path
Section titled “Summary decision path”Four questions, in order, resolve nearly every case:
Is a person doing this themselves, right now? → gh auth login, or a fine-grained token for a
script they run.
Is a system doing it on behalf of a team or organisation? → a GitHub App. The system gets its own identity, its own rate budget, and access that survives staff changes.
Is it a workflow acting on its own repository? → the built-in GITHUB_TOKEN, with permissions:
declared explicitly.
Is it acting on behalf of other users? → the App user-token flow, or OAuth.
Everything else is a variation. The recurring answer is the narrowest credential that expires, and
the recurring mistake is a classic personal access token with repo scope, no expiry, belonging to
whoever set it up.
The reason that mistake is so common is that it works immediately and its cost is deferred. The cost arrives when the token leaks, or when that person leaves, and by then the automation depending on it is load-bearing.
What to do first
Section titled “What to do first”If you are setting this up now and want the short path:
For your own machine, run gh auth login and let it manage the credential. It stores in the OS
keyring and configures Git at the same time.
For a personal script, create a fine-grained token scoped to the specific repositories, with only the permissions the endpoints you call require, expiring in ninety days or less.
For anything in GitHub Actions acting on its own repository, use the built-in GITHUB_TOKEN and
declare permissions: explicitly.
For anything a team depends on, register a GitHub App. The setup is an afternoon and it removes the whole category of problems that arise from automation borrowing a person’s identity.
And whichever you choose: never commit it, never put it on a command line, never log it, and if it is ever exposed, revoke it — deleting the file does nothing.
The device flow
Section titled “The device flow”There is a fourth way to obtain a token that this lesson has not covered, and it is the right answer for a specific case: a tool running somewhere with no browser and no way to paste a callback URL — a server, a container, a CLI on a remote machine.
The device flow solves it by moving the browser step to a different device.
# 1. Request a device codecurl -sS https://github.com/login/device/code \ -H "Accept: application/json" \ -d "client_id=$CLIENT_ID&scope=repo"{ "device_code": "3584d83530557fdd1f46af8289938c8ef79f9dc5", "user_code": "WDJB-MJHT", "verification_uri": "https://github.com/login/device", "expires_in": 900, "interval": 5}Show the user user_code and verification_uri. They open that URL on any device — a phone is fine —
and enter the code. Meanwhile the tool polls:
# 2. Poll until the user completes itcurl -sS https://github.com/login/oauth/access_token \ -H "Accept: application/json" \ -d "client_id=$CLIENT_ID&device_code=$DEVICE_CODE&grant_type=urn:ietf:params:oauth:grant-type:device_code"Poll no faster than interval seconds; polling too fast returns slow_down and increases the
interval. authorization_pending means keep waiting, and is the normal response until the user
finishes.
This is exactly what gh auth login does when you choose the browser option — the one-time code it
prints is user_code. Knowing the mechanism is useful when building a tool with the same constraint,
and it explains why gh can authenticate a headless machine without you ever pasting a token into it.