GitHub Authentication
Every credential type below carries a different permission model, a different
blast radius, and a different failure message. Picking the wrong one is the most
common reason a working script fails in CI.
The credential types
| Type |
Identity |
Expiry |
Scope model |
Use for |
| Classic PAT |
The user |
Optional |
Coarse OAuth scopes (repo = all repos, read+write) |
Legacy, and the few APIs fine-grained tokens still cannot reach |
| Fine-grained PAT |
The user |
Required, max 1 year |
Per-repo + per-permission (contents: read) |
The default for a human or a local agent |
| GitHub App installation token |
The app, on an installation |
1 hour |
Per-permission, per-installation |
Automation, bots, anything long-lived |
| GitHub App user access token |
The user, via the app |
8 hours (refreshable) |
Intersection of app permissions and user access |
Acting as the user through an app |
| OAuth App token |
The user |
None by default |
Coarse OAuth scopes |
Legacy third-party integrations |
GITHUB_TOKEN |
github-actions[bot] |
The job |
permissions: in the workflow |
Anything inside Actions |
OIDC (id-token) |
The workflow, cryptographically |
Per-request |
Trust policy on the cloud side |
Cloud auth from Actions — no stored secret |
| SSH key |
The user |
None |
Full git access as the user |
Git transport only, never the API |
| Deploy key |
One repository |
None |
Read or read/write, one repo |
CI clone of a single repo |
Choosing
Running inside GitHub Actions?
├── Talking to GitHub? → GITHUB_TOKEN with a narrowed permissions: block
├── Talking to a cloud provider? → OIDC (id-token: write), never a stored cloud key
└── Need cross-repo access? → GitHub App installation token, not a PAT
Running outside Actions?
├── Long-lived automation / bot → GitHub App installation token
├── Acting as a specific human → GitHub App user access token
└── Local dev or a local agent → fine-grained PAT, minimum permissions
Prefer a GitHub App over a PAT for anything that outlives a session. An
installation token expires in an hour, is scoped per repository, is revocable
without touching a human account, and does not die when its creator leaves the
organization. A PAT in a shared secret is an outage waiting for someone's
offboarding.
GITHUB_TOKEN and permissions:
The default token scope is far wider than any single workflow needs. Declare
permissions: explicitly — a workflow-level floor, narrowed per job.
permissions:
contents: read # floor for the whole workflow
jobs:
release:
permissions:
contents: write # only this job can write
id-token: write # OIDC
Known behaviors that cause confusion:
GITHUB_TOKEN cannot trigger further workflows. A push or PR made with it
does not fire push or pull_request events. This is deliberate loop
protection. If you need the follow-on workflow to run, use a GitHub App token
or a PAT — and understand you have re-armed the loop.
- Fork PRs get a read-only token and no secrets on
pull_request. That is
the security boundary, not a bug.
pull_request_target runs with the base repo's token and secrets. Never
combine it with a checkout of the PR head — that hands a fork write-scoped
secrets. This is the single most exploited Actions misconfiguration.
Fine-grained tokens: the gaps
Fine-grained PATs are the right default, but they are not a superset of classic:
- Some endpoints still require a classic token — notably parts of the Projects
API for user-owned projects, which reject fine-grained PATs, App user
tokens, and installation tokens outright.
- Organization resources require the org to have enabled fine-grained tokens,
and may require per-token approval by an owner.
- A fine-grained token must be explicitly granted each repository. "It works on
my repo" and "it works on the org's repo" are different questions.
Reading the failure
| Error |
Almost always means |
401 Bad credentials |
Token is wrong, revoked, or expired |
403 Resource not accessible by integration |
The App/token lacks the permission, not the scope. Check the App's permission set |
403 with X-Accepted-GitHub-Permissions header |
The header names exactly what was needed — read it |
404 on a repo you know exists |
Almost never missing — it is unauthorized. GitHub 404s private resources rather than confirming they exist |
403 ... SAML enforcement |
The token needs SSO authorization for that org, done in token settings |
422 on a write |
Payload problem, not auth |
| Works locally, 403 in Actions |
permissions: block, or the default token, not a credential problem |
The 404-means-403 behavior matters for agents. Never conclude a repository
does not exist from a 404 on an unauthenticated or under-scoped request. Attach
the repo properly and retry before reporting it missing.
SAML SSO and IP allow lists
In an org with SAML enforced, a valid token still fails until it is
authorized for that org. In an org with an IP allow list, a token from an
unlisted address fails regardless of permissions — GitHub App installations can
be configured to bypass the list, which is one more reason to prefer them for
automation.
Git transport vs API
They are separate. An SSH key clones and pushes; it cannot call the API. A PAT
can do both (over HTTPS). A deploy key is scoped to one repository and is the
right answer for a CI clone that should not be able to reach anything else.
Never use a personal SSH key or PAT as a shared CI credential — the audit trail
becomes useless and revocation breaks a human.
Storage
- Never in source, config, or a PR body. The guard hook blocks credential-shaped
strings for this reason.
- Actions: repository/environment secrets, or OIDC so there is no secret at all.
- Local: the OS credential helper or
gh auth login, not .netrc in plaintext.
- A secret that reaches a remote is compromised — rotate first, clean up
second. See
supply-chain-security.
See also
gh-mcp — how the MCP server authenticates and what it can reach
actions-authoring — permissions:, OIDC, and injection
../commands/setup.md — detection and degraded-mode reporting
1---2name: github-auth3description: This skill should be used when choosing, diagnosing, or configuring how something authenticates to GitHub — personal access tokens, GitHub Apps, OAuth, SSH and deploy keys, GITHUB_TOKEN in Actions, OIDC, and the permission model each one carries.4---5
6# GitHub Authentication
7
8Every credential type below carries a different permission model, a different
9blast radius, and a different failure message. Picking the wrong one is the most
10common reason a working script fails in CI.
11
12## The credential types
13
14| Type | Identity | Expiry | Scope model | Use for |
15| --- | --- | --- | --- | --- |
16| **Classic PAT** | The user | Optional | Coarse OAuth scopes (`repo` = all repos, read+write) | Legacy, and the few APIs fine-grained tokens still cannot reach |
17| **Fine-grained PAT** | The user | Required, max 1 year | Per-repo + per-permission (`contents: read`) | The default for a human or a local agent |
18| **GitHub App installation token** | The app, on an installation | 1 hour | Per-permission, per-installation | Automation, bots, anything long-lived |
19| **GitHub App user access token** | The user, via the app | 8 hours (refreshable) | Intersection of app permissions and user access | Acting *as the user* through an app |
20| **OAuth App token** | The user | None by default | Coarse OAuth scopes | Legacy third-party integrations |
21| **`GITHUB_TOKEN`** | `github-actions[bot]` | The job | `permissions:` in the workflow | Anything inside Actions |
22| **OIDC (`id-token`)** | The workflow, cryptographically | Per-request | Trust policy on the cloud side | Cloud auth from Actions — no stored secret |
23| **SSH key** | The user | None | Full git access as the user | Git transport only, never the API |
24| **Deploy key** | One repository | None | Read or read/write, one repo | CI clone of a single repo |
25
26## Choosing
27
28```
29Running inside GitHub Actions?
30├── Talking to GitHub? → GITHUB_TOKEN with a narrowed permissions: block
31├── Talking to a cloud provider? → OIDC (id-token: write), never a stored cloud key
32└── Need cross-repo access? → GitHub App installation token, not a PAT
33
34Running outside Actions?
35├── Long-lived automation / bot → GitHub App installation token
36├── Acting as a specific human → GitHub App user access token
37└── Local dev or a local agent → fine-grained PAT, minimum permissions
38```
39
40**Prefer a GitHub App over a PAT for anything that outlives a session.** An
41installation token expires in an hour, is scoped per repository, is revocable
42without touching a human account, and does not die when its creator leaves the
43organization. A PAT in a shared secret is an outage waiting for someone's
44offboarding.
45
46## `GITHUB_TOKEN` and `permissions:`
47
48The default token scope is far wider than any single workflow needs. Declare
49`permissions:` explicitly — a workflow-level floor, narrowed per job.
50
51```yaml
52permissions:
53 contents: read # floor for the whole workflow
54
55jobs:
56 release:
57 permissions:
58 contents: write # only this job can write
59 id-token: write # OIDC
60```
61
62Known behaviors that cause confusion:
63
64- **`GITHUB_TOKEN` cannot trigger further workflows.** A push or PR made with it
65 does not fire `push` or `pull_request` events. This is deliberate loop
66 protection. If you need the follow-on workflow to run, use a GitHub App token
67 or a PAT — and understand you have re-armed the loop.
68- **Fork PRs get a read-only token and no secrets** on `pull_request`. That is
69 the security boundary, not a bug.
70- **`pull_request_target` runs with the base repo's token and secrets.** Never
71 combine it with a checkout of the PR head — that hands a fork write-scoped
72 secrets. This is the single most exploited Actions misconfiguration.
73
74## Fine-grained tokens: the gaps
75
76Fine-grained PATs are the right default, but they are not a superset of classic:
77
78- Some endpoints still require a classic token — notably parts of the **Projects
79 API for user-owned projects**, which reject fine-grained PATs, App user
80 tokens, *and* installation tokens outright.
81- Organization resources require the org to have **enabled** fine-grained tokens,
82 and may require per-token approval by an owner.
83- A fine-grained token must be explicitly granted each repository. "It works on
84 my repo" and "it works on the org's repo" are different questions.
85
86## Reading the failure
87
88| Error | Almost always means |
89| --- | --- |
90| `401 Bad credentials` | Token is wrong, revoked, or expired |
91| `403 Resource not accessible by integration` | The App/token lacks the *permission*, not the scope. Check the App's permission set |
92| `403` with `X-Accepted-GitHub-Permissions` header | The header names exactly what was needed — read it |
93| `404` on a repo you know exists | Almost never missing — it is **unauthorized**. GitHub 404s private resources rather than confirming they exist |
94| `403 ... SAML enforcement` | The token needs SSO authorization for that org, done in token settings |
95| `422` on a write | Payload problem, not auth |
96| Works locally, 403 in Actions | `permissions:` block, or the default token, not a credential problem |
97
98**The 404-means-403 behavior matters for agents.** Never conclude a repository
99does not exist from a 404 on an unauthenticated or under-scoped request. Attach
100the repo properly and retry before reporting it missing.
101
102## SAML SSO and IP allow lists
103
104In an org with SAML enforced, a valid token still fails until it is
105**authorized** for that org. In an org with an IP allow list, a token from an
106unlisted address fails regardless of permissions — GitHub App installations can
107be configured to bypass the list, which is one more reason to prefer them for
108automation.
109
110## Git transport vs API
111
112They are separate. An SSH key clones and pushes; it cannot call the API. A PAT
113can do both (over HTTPS). A deploy key is scoped to one repository and is the
114right answer for a CI clone that should not be able to reach anything else.
115
116Never use a personal SSH key or PAT as a shared CI credential — the audit trail
117becomes useless and revocation breaks a human.
118
119## Storage
120
121- Never in source, config, or a PR body. The guard hook blocks credential-shaped
122 strings for this reason.
123- Actions: repository/environment secrets, or OIDC so there is no secret at all.
124- Local: the OS credential helper or `gh auth login`, not `.netrc` in plaintext.
125- **A secret that reaches a remote is compromised** — rotate first, clean up
126 second. See `supply-chain-security`.
127
128## See also
129
130- `gh-mcp` — how the MCP server authenticates and what it can reach
131- `actions-authoring` — `permissions:`, OIDC, and injection
132- `../commands/setup.md` — detection and degraded-mode reporting