Personal Access Tokens vs GitHub Apps
Conceptual Overview
When automating interactions with the GitHub API, you need to authenticate your requests. GitHub offers two primary mechanisms for programmatic access: Personal Access Tokens (PATs) and GitHub Apps. While both grant a bearer token you can send in an Authorization header, they serve fundamentally different purposes and operate under different trust models.
- Personal Access Token (PAT): A token tied to a specific user account. It inherits that user's permissions and acts on their behalf. Fine-grained PATs (recommended) limit access to specific repositories and scopes; classic PATs grant broader, coarse-grained access.
- GitHub App: A standalone entity that you install on a user or organization account. It has its own identity, permissions defined in the app manifest, and generates short-lived installation tokens scoped precisely to the repositories it has been granted access to. The app authenticates first with a signed JWT, then exchanges it for an installation access token.
Why the distinction matters:
- Security and blast radius: A PAT has long-lived access (up to 1 year for fine-grained, indefinite for classic) and if leaked, an attacker can impersonate that user until the token is revoked. A GitHub App installation token is short-lived (1 hour) and scoped to only the repos where the app is installed, dramatically limiting exposure.
- Rate limits: GitHub App installations get their own rate limit (5,000+ requests/hour), independent of any user. PATs consume the user's personal rate limit (5,000/hour), which can bottleneck CI/CD pipelines.
- Organisational governance: GitHub Apps can be centrally managed by org admins, suspended or uninstalled, and can request granular permissions via the marketplace. PATs are individually created and often lack auditability.
- Automation identity: For bots, CI/CD services, or internal tooling, a GitHub App provides a non-human identity that doesn't leave the org when an employee departs.
When Not to Use Each
Avoid a PAT when:
- The token needs to outlive a specific person's employment or be shared across a team – use a GitHub App.
- You require granular, per-repository permissions that can be centrally revoked – use a GitHub App.
- The workload is high-frequency and would exhaust a single user's rate limit – use a GitHub App.
- You need to access resources across multiple organizations without adding a user to each – a GitHub App can be installed on multiple orgs with a single identity.
Avoid a GitHub App when:
- You're building a personal script or a quick prototype – a fine-grained PAT is simpler to create and doesn't require generating a private key and JWT.
- The interaction is ad-hoc and will not be used by others – the overhead of setting up an app isn't justified.
- You need to act as a specific user (user-to-server flows) – GitHub Apps always act as themselves, not as a user; for user impersonation consider an OAuth app or a PAT.
Rule of thumb: Use a fine-grained PAT for personal scripting. Use a GitHub App for any production automation, CI/CD, or team-wide tooling.
Prerequisites
- A GitHub account with access to the repositories or organizations you intend to interact with.
curland/or Python 3.8+ installed.- For Python, install the required libraries:
pip install requests pyjwt cryptography - Security note: Never hardcode tokens, keys, or app private keys in source files. Use environment variables or a secrets manager:
Hardcoding credentials in committed code is the most common cause of leaked secrets.export GITHUB_PAT="github_pat_..." export GITHUB_APP_ID="123456" export GITHUB_APP_PRIVATE_KEY_PATH="/path/to/app-private-key.pem" export GITHUB_INSTALLATION_ID="12345678"
Step-by-Step Guide
1. Authenticating with a Personal Access Token
A PAT is the simplest way to get started. Create a fine-grained PAT at github.com/settings/tokens (select the repositories and permissions you need).
Using curl
curl -H "Authorization: Bearer $GITHUB_PAT" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/user
If the token is valid, you'll receive your user profile JSON.
Using Python
import os
import requests
token = os.environ["GITHUB_PAT"]
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
response = requests.get("https://api.github.com/user", headers=headers)
response.raise_for_status()
print(response.json()["login"])
2. Authenticating as a GitHub App
A GitHub App requires two steps: generate a time-limited JWT signed with your app's private key, then exchange it for an installation access token.
import os
import time
import jwt
import requests
# --- Generate JWT ---
app_id = os.environ["GITHUB_APP_ID"]
private_key_path = os.environ["GITHUB_APP_PRIVATE_KEY_PATH"]
with open(private_key_path, "rb") as f:
private_key = f.read()
payload = {
"iat": int(time.time()) - 60,
"exp": int(time.time()) + 600, # 10 minutes max lifetime
"iss": app_id,
}
jwt_token = jwt.encode(payload, private_key, algorithm="RS256")
# --- Exchange JWT for installation token ---
installation_id = os.environ["GITHUB_INSTALLATION_ID"]
headers = {
"Authorization": f"Bearer {jwt_token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
resp = requests.post(
f"https://api.github.com/app/installations/{installation_id}/access_tokens",
headers=headers,
)
resp.raise_for_status()
installation_token = resp.json()["token"]
# --- Use the installation token ---
headers["Authorization"] = f"Bearer {installation_token}"
resp = requests.get("https://api.github.com/repos/octocat/hello-world", headers=headers)
print(resp.json()["full_name"])
Complete Examples
PAT: Full Executable Script
"""
github_pat_example.py
Demonstrates a fine-grained PAT for a simple GitHub API call.
Usage:
export GITHUB_PAT="github_pat_..."
python github_pat_example.py
"""
import os
import sys
import requests
def main():
token = os.environ.get("GITHUB_PAT")
if not token:
print("Error: GITHUB_PAT environment variable not set.")
sys.exit(1)
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
try:
response = requests.get("https://api.github.com/user", headers=headers)
response.raise_for_status()
user_data = response.json()
print(f"Authenticated as: {user_data['login']}")
print(f"Name: {user_data.get('name', 'N/A')}")
except requests.HTTPError as e:
print(f"Request failed: {e.response.status_code}")
if e.response.status_code == 401:
print("Hint: The PAT may be expired, revoked, or lacks the required scopes.")
sys.exit(1)
except Exception as e:
print(f"Unexpected error: {e}")
sys.exit(1)
if __name__ == "__main__":
main()
GitHub App: Full Executable Script
"""
github_app_example.py
Authenticates as a GitHub App, fetches an installation token,
and lists open issues from a repository the app is installed on.
Usage:
export GITHUB_APP_ID="123456"
export GITHUB_APP_PRIVATE_KEY_PATH="/path/to/key.pem"
export GITHUB_INSTALLATION_ID="12345678"
python github_app_example.py
"""
import os
import sys
import time
import jwt
import requests
def main():
app_id = os.environ.get("GITHUB_APP_ID")
private_key_path = os.environ.get("GITHUB_APP_PRIVATE_KEY_PATH")
installation_id = os.environ.get("GITHUB_INSTALLATION_ID")
missing = []
if not app_id:
missing.append("GITHUB_APP_ID")
if not private_key_path:
missing.append("GITHUB_APP_PRIVATE_KEY_PATH")
if not installation_id:
missing.append("GITHUB_INSTALLATION_ID")
if missing:
print(f"Error: Missing environment variables: {', '.join(missing)}")
sys.exit(1)
try:
with open(private_key_path, "rb") as f:
private_key = f.read()
except FileNotFoundError:
print(f"Error: Private key file not found at {private_key_path}")
sys.exit(1)
now = int(time.time())
payload = {
"iat": now - 60,
"exp": now + 600,
"iss": app_id,
}
try:
jwt_token = jwt.encode(payload, private_key, algorithm="RS256")
except Exception as e:
print(f"Error creating JWT: {e}")
sys.exit(1)
headers = {
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
headers["Authorization"] = f"Bearer {jwt_token}"
token_url = f"https://api.github.com/app/installations/{installation_id}/access_tokens"
try:
resp = requests.post(token_url, headers=headers)
resp.raise_for_status()
except requests.HTTPError as e:
print(f"Failed to obtain installation token: {e.response.status_code}")
sys.exit(1)
token_data = resp.json()
installation_token = token_data["token"]
print(f"Got installation token (expires: {token_data['expires_at']})")
repo_owner = "octocat"
repo_name = "hello-world"
issues_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/issues"
headers["Authorization"] = f"Bearer {installation_token}"
try:
issues_resp = requests.get(issues_url, headers=headers)
issues_resp.raise_for_status()
issues = issues_resp.json()
print(f"\nOpen issues in {repo_owner}/{repo_name}:")
for issue in issues:
print(f" #{issue['number']}: {issue['title']}")
except requests.HTTPError as e:
print(f"Failed to fetch issues: {e.response.status_code}")
sys.exit(1)
if __name__ == "__main__":
main()
Reference
Token Types and Properties
| Property | Fine-grained PAT | Classic PAT | GitHub App installation token |
|---|---|---|---|
| Lifetime | Up to 1 year (7 days–custom) | Indefinite (no expiry) | 1 hour |
| Scope | Specific repositories & permissions | Broad scopes (repo, admin, …) |
Repositories the app is installed on |
| Rate limit | User's limit (5,000 req/h) | User's limit | App's own limit (5,000+ req/h) |
| Identity | The user who created it | The user who created it | The GitHub App itself |
| Best for | Personal scripts, ad-hoc CLI use | Legacy integrations | Production automation, CI/CD, bots |
| Revocation | Via GitHub settings, by the user | Via GitHub settings, by the user | Via app management, org admin |
Common API Headers
| Header | Value | Required |
|---|---|---|
Authorization |
Bearer <token> |
Yes (PAT or installation token) |
Accept |
application/vnd.github+json |
Recommended (explicit API versioning) |
X-GitHub-Api-Version |
2022-11-28 |
Optional (pins API version) |
Common Errors and Troubleshooting
1. 401 Bad credentials – PAT
- Symptom:
{"message": "Bad credentials"} - What to do: Verify the token is still valid. Confirm the token is not revoked or restricted. Fine-grained PATs start with
github_pat_; classic PATs start withghp_.
2. 401 Bad credentials – GitHub App JWT
- What to do: Ensure the JWT's
issmatches exactly the App ID. The JWT must be signed with the correct private key. Check that the JWT hasn't expired.
3. 403 Resource not accessible by integration
- What to do: Confirm the GitHub App is installed on the account/org that owns the repository. Check the app's permissions.
4. Rate limit exceeded (HTTP 403 / 429)
- What to do: PAT: You're consuming the user's personal rate limit. Wait for the reset time or use a GitHub App. GitHub App: Check the
X-RateLimit-Remainingheader and implement backoff.
5. Expired installation token
- What to do: Installation tokens live exactly 1 hour. Automate renewal. Use the
expires_atfield to proactively refresh.
6. Hardcoded secret leaked in version control
- What to do (immediately): Revoke the PAT or app private key. Store secrets in environment variables. Use a secrets scanning tool. Never write raw credentials inside a source file that could be committed.
7. App not installed / installation ID unknown
- What to do: Call
GET /app/installationsusing the JWT to discover installations. Ensure the app is installed on at least one account.
8. Mismatch between PAT type and expected permissions
- What to do: Fine-grained PATs have repository-specific permissions. Verify the token has access to the exact repository and required scope.
For additional details, refer to GitHub Docs: PATs, Creating a GitHub App, and Authenticating as a GitHub App.