Skip to content

GitHub API Authentication: Choosing the Right Credential

Lesson 3 of 10Intermediate13 min readGitHub Engineering · GitHub APIVerified: GitHub.com, August 2026

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.

CredentialIdentityScopingLifetime
Fine-grained PATA personPer repository, per permissionExpiry required
Classic PATA personCoarse scopes, all their repositoriesOptional expiry
GitHub App installation tokenThe appPer installation and permission~1 hour, auto-renewed
GitHub App user tokenA person, via the appThe app’s permissions ∩ the user’sShort, refreshable
OAuth app tokenA personCoarse scopesUntil revoked
Actions GITHUB_TOKENThe workflow runThe workflow’s repositoryThe job’s duration
NonePublic read only

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.

ScenarioUseWhy
Personal CLI usegh auth loginManaged for you, stored in the OS keyring
One-off personal scriptFine-grained PATScoped to the repositories it needs, expires
Team or organisation automationGitHub AppOwn identity, survives staff changes, bounded
Workflow acting on its own repositoryGITHUB_TOKENAutomatic, short-lived, correctly scoped
Workflow acting across repositoriesGitHub AppThe workflow token is repository-scoped
Application acting for usersApp or OAuthUser consent and per-user permissions
Reading public dataOften none60 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.

Classic PATs use scopes — coarse, account-wide capabilities:

repo Full control of all repositories, public and private
workflow Update GitHub Actions workflow files
admin:org Full control of organisations
delete_repo Delete repositories

repo 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 repositories
Permissions:
Issues Read and write
Metadata Read-only (required)
Contents Read-only

That token can read and file Issues on two repositories and nothing else. Covered in Fine-Grained Personal Access Tokens.

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: write

Declaring 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.

Terminal window
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:

Terminal window
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.

Where they should live:

  • Locallygh 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 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.

Before debugging a permission problem, establish what you are holding. The checks differ by credential type, which is itself diagnostic.

Terminal window
# 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.

Where a token lives determines what happens when something goes wrong.

LocationAcceptable?Notes
OS keyring via gh auth loginYesBest for a workstation
Environment variable from a password managerYesGood for shells and one-off scripts
CI secret storeYesInjected per job, not persisted
Dedicated secret managerYesBest for production; audit and rotation
~/.netrc or a plain config fileMarginalReadable by anything running as you
Committed to a repositoryNeverPermanent, distributed, one setting from public
A command-line argumentNeverShell history and the process list
Baked into a container imageNeverPersists 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 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.

The order matters, and the first step is the one people delay.

  1. Revoke it. Immediately, before investigating. A revoked token cannot be used regardless of who has it.
  2. Issue a replacement and update whatever depended on the old one.
  3. Check what it could reach. Scopes or permissions plus repository access defines the blast radius.
  4. Review the audit log for activity you do not recognise during the exposure window.
  5. Fix the leak path. A token in a repository needs the commit history dealt with; a token in a log needs the logging fixed.
  6. 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.

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 AppGitHub App
IdentityThe authorising userItself
AccessEverything the user can reachOnly where installed
PermissionsCoarse scopesPer-resource
Token lifetimeUntil revoked~1 hour, renewed
WebhooksConfigured separatelyBuilt in
Org visibilityLimitedInstalled, 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 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: none

Setting 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.

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.

  1. Create a fine-grained token limited to one repository with Issues read-only, expiring in seven days.
  2. Use it to list that repository’s Issues successfully.
  3. Try to create an Issue with it and read the failure.
  4. Try to read a different repository and observe the 404 rather than a 403.
  5. Check x-oauth-scopes with a classic token and note that the fine-grained one returns nothing there.
  6. 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”.

  • The decision is who acts, on whose data, for how long, with what minimum permission.
  • Classic scopes are account-wide; repo grants 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.

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.

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.

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.

Terminal window
# 1. Request a device code
curl -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:

Terminal window
# 2. Poll until the user completes it
curl -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.

Professional ToolkitThe GitHub App vs personal access token decision guide, with a tested installation-token recipe, is in the Professional Toolkit.