401, 403, or 404 — what each one is telling you
A 401 means the credentials are missing, malformed, or wrong — GitHub returns 401 Unauthorized for invalid credentials (Authenticating to the REST API). A 403 means you authenticated but are forbidden: insufficient token permission, a rate limit, SAML SSO not authorized, or an IP allow list. And a 404 on a repository you know exists is, counterintuitively, usually an auth problem: GitHub returns 404 rather than 403 for private resources a token cannot see, "to avoid confirming the existence of private repositories" (Troubleshooting the REST API). Read the status code first; it routes the whole investigation.
One nuance that surprises people: repeated bad-credential requests escalate. GitHub initially returns 401 Unauthorized, but after several invalid attempts in a short window "the API will temporarily reject all authentication attempts for that user (including ones with valid credentials) with a 403 Forbidden response" (Authenticating to the REST API). So a 403 can be a consequence of an earlier 401 loop — fix the bad credential and back off, don't hammer.
Diagnose it
Check who you are and what the token can do before assuming the worst:
# 1. Confirm the token authenticates at all (401 here = bad credential).
gh api user --jq '.login'
# 2. Read the current rate-limit window. If remaining is 0, that is your 403.
gh api rate_limit --jq '.resources.core'
# 3. Inspect the response headers on the failing call.
gh api -i repos/{owner}/{repo}/pulls/{number} 2>&1 | grep -i \
-e 'x-ratelimit' -e 'x-github-sso' -e 'retry-after'
Interpretation:
x-ratelimit-remaining: 0on a 403/429 → rate limited. Do not retry beforex-ratelimit-reset(a Unix timestamp) (Rate limits for the REST API).- "Resource not accessible by integration" / "by personal access token" → the token is missing a permission (Troubleshooting the REST API).
- An
X-GitHub-SSOheader on a 403 → the token is not authorized for that org's SAML SSO; the header carries a URL to authorize it (Authenticating to the REST API). - A
retry-afterheader → you hit a secondary rate limit; wait that many seconds.
Root causes
Bad / expired credential (401). The token is wrong, revoked, or expired. Fine-grained PATs require an expiration date, so a working integration can 401 overnight when the token lapses.
Insufficient permission (403 / 404). The token authenticates but lacks the scope or fine-grained permission for the resource — 403 with "not accessible," or 404 on a private repo.
Rate limited (403 or 429). You exhausted the primary limit (5,000/hour authenticated, 60/hour unauthenticated per IP) or tripped a secondary limit (Rate limits for the REST API).
SAML SSO not authorized (403). Classic PATs must be SSO-authorized per org before they can read its private resources (Authorizing a PAT for SSO).
IP allow list (403). With enterprise SSO and an IP allow list, "your IP must also be allowed at the enterprise level," or access is denied (Authorizing a PAT for SSO).
Rate limits, exactly
For a read-heavy integration the numbers decide your architecture (Rate limits for the REST API):
- User / PAT (authenticated): 5,000 requests/hour — a flat ceiling.
- GitHub App installation token: at least 5,000/hour; 15,000/hour on a GitHub Enterprise Cloud org. For non-Enterprise installs it scales by
+50/hour per repositorybeyond 20 repos and+50/hour per userbeyond 20 users, "cannot increase beyond 12,500 requests per hour." - Secondary (abuse) limits: no more than 100 concurrent requests, no more than 900 points/minute for REST endpoints; honour
retry-afteron exceed.
The relevant headers are x-ratelimit-limit, x-ratelimit-remaining, x-ratelimit-used, x-ratelimit-reset, and x-ratelimit-resource; on exhaustion you get a 403 or 429 with x-ratelimit-remaining: 0 (Rate limits for the REST API).
Resolution options
For 401: rotate or re-issue the credential. For fine-grained PATs, check the expiration; for classic PATs used against an SSO org, authorize the token (below).
For 403 "not accessible": grant the missing permission. For a read-only platform that reads PRs, commits, and checks, the least-privilege fine-grained permissions are Contents: Read (repository contents and commits), Pull requests: Read, and Commit statuses: Read (Permissions required for fine-grained PATs) — plus Metadata: Read, which GitHub auto-selects as the baseline for repository access.
For 403 rate-limited: stop, read x-ratelimit-reset, and wait. Add conditional requests (ETags) and caching so you are not re-fetching unchanged data — those do not count against the limit when they return 304.
For 403 SSO: authorize the token for the org. Follow the URL in the X-GitHub-SSO header (Authorizing a PAT for SSO).
Recommended approach (with tradeoffs)
For any service-to-service integration, prefer a GitHub App over a personal access token. The App gets the higher, scaling installation rate limit instead of a flat 5,000/hour (Rate limits for the REST API), is not tied to one human's account (so it survives that person leaving), and grants granular per-install permissions (Managing your personal access tokens). The tradeoff is real setup cost: you implement App authentication (JWT → installation token exchange) and token caching, which is more work than pasting a PAT. For a quick script or a single repo, a fine-grained PAT scoped to least privilege is fine. For a platform reading many repos continuously, the App pays for itself the first time you would have throttled at 5,000/hour.
One known gap to plan around: fine-grained PATs have historically struggled to read the Checks API, so if you need check-run data, a GitHub App with Checks: Read is the safer choice. Treat this as "verify in your console" rather than a guarantee — but do not assume a fine-grained PAT can read checks without testing it.
Validation steps
# Token reads a private PR you expect access to (200, not 404/403):
gh api repos/{owner}/{repo}/pulls/{number} --jq '.number'
# You have headroom in the window:
gh api rate_limit --jq '.resources.core | {remaining, reset}'
A clean .number and a non-zero remaining mean the credential authenticates, has the permission, and is not throttled — the three things this page is about.
Prevention strategies
- Use least-privilege read scopes (Metadata, Contents, Pull requests, Commit statuses — all Read) so a leaked token cannot write.
- Cache and use conditional requests so steady-state reads return 304 and do not burn the limit.
- Alert on
x-ratelimit-remainingtrending toward 0, not just on the eventual 403 — by the time you are throttled, you are already failing requests. - Track token expiry for fine-grained PATs; a silent overnight 401 is avoidable with a renewal reminder, or by switching to an App whose installation tokens are minted on demand.
Operational lessons
The expensive mistake here is reading the message and ignoring the status code path: a 404 that is really a permissions problem sends people hunting for a deleted repo, and a 403 that is really the invalid-credential lockout sends people rotating a token that was fine. Status code first, header second, message last.
For a platform that continuously reads from GitHub to correlate code changes with downstream failures, auth and rate limits are not edge cases — they are the steady state. A throttled or under-scoped token does not crash loudly; it returns partial data, and partial change history means an incident timeline with gaps. The durable answer is a properly-scoped GitHub App with caching and conditional requests, so the read path stays complete and within limits even when the repo count grows.