How to Audit and Secure GitHub Repository Access Permissions in 2025
Prerequisites Checklist
Before you touch a single API endpoint, make sure your environment and account permissions are in order. Auditing an org with incomplete access is worse than not auditing at all — you'll get partial results and false confidence.
- [ ] You hold the Owner or Security Manager role in the GitHub organization. Security Manager is a built-in role available on Team and Enterprise plans that grants read access to all security features without full org-owner privileges.
- [ ] GitHub CLI (
gh) is installed and authenticated:gh --versionshould return2.40.0or later. Install viabrew install ghor the official installer. - [ ] You have a Personal Access Token (PAT) or a fine-grained PAT with scopes
read:audit_log,admin:org, andrepo. Store it in an environment variable (GITHUB_TOKEN). - [ ]
jqis installed for JSON parsing:jq --version→jq-1.7or later. - [ ]
curlis available (any modern version works). On Windows, use Git Bash or WSL2. - [ ] You know your plan tier and what's available to you:
| Feature | Free | Team | Enterprise / GHES | |---|---|---|---| | Audit log (web UI) | Last 7 days | Last 90 days | Last 180 days (streaming available) | | Audit log REST API | ❌ | ✅ | ✅ | | Audit log streaming (S3, Splunk) | ❌ | ❌ | ✅ | | Secret scanning | Public repos only | ✅ | ✅ + push protection | | GitHub Advanced Security | ❌ | Add-on | Included | | Required SAML SSO | ❌ | ✅ | ✅ |
Note: If you're on the Free plan, several steps in this guide (specifically the audit log API calls) won't work. Upgrade to Team or use GitHub's web UI export as a workaround.
Some audit log events — like git.clone streaming — are only available on Enterprise with audit log streaming enabled. Know your ceiling before you start.
Estimated time: 45–90 minutes for initial audit; ~15 minutes per week once automation is in place.
Step 1 — Export and Review Your Organization Audit Log
The audit log is your ground truth. Every permission change, member addition, OAuth authorization, and repository visibility flip is recorded here. Starting with the audit log tells you what has already happened before you make any changes.
Accessing the Audit Log via GitHub Web UI
Navigate to https://github.com/organizations/{ORG}/settings/audit-log. Use the search bar to filter by events like action:repo.access or action:org.add_member. For bulk exports, click Export → Export as JSON (available on Team and Enterprise).
Exporting Audit Log Events with the GitHub REST API
The REST API gives you programmatic control and lets you filter by date range and action type — critical when you're auditing hundreds of events.
#!/usr/bin/env bash
# Export audit log events for repo.access and org.add_member actions
# within the last 30 days. Requires Team or Enterprise plan.
ORG="your-org-name"
TOKEN="${GITHUB_TOKEN}"
SINCE=$(date -u -d '30 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || \
date -u -v-30d +%Y-%m-%dT%H:%M:%SZ) # Linux / macOS compat
for ACTION in "repo.access" "org.add_member" "repo.create" "repo.destroy"; do
echo "=== Fetching events for action: ${ACTION} ==="
curl -s \
-H "Authorization: Bearer ${TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/${ORG}/audit-log?phrase=action%3A${ACTION}&include=all&per_page=100" \
| jq -r '.[] | [.actor, .repo, .action, (."@timestamp" | todate)] | @csv'
done
This pipes each event through jq and extracts actor, repo, action, and a human-readable timestamp. Redirect output to a file with >> audit_$(date +%F).csv for archiving.
Filtering for Permission-Change Events
Focus your analysis on these high-signal actions:
| Audit Log Action | What It Means |
|---|---|
| repo.access | Repository visibility or access changed |
| org.add_member | New member joined the organization |
| repo.add_member | Outside collaborator added to a repo |
| protected_branch.create | Branch protection rule applied |
| oauth_application.create | New OAuth app authorized |
Note: Pagination matters. The API returns 100 events per page. Add a
Linkheader parser or usegh api --paginate(covered in Step 2) to ensure you don't miss events on older pages.
Step 2 — Enumerate All Collaborators and Their Permission Levels
Knowing who has access to what is the foundation of any access review. GitHub distinguishes between organization members (who inherit org-level base permissions) and outside collaborators (external users granted explicit repo access). Both need to be audited.
Listing Outside Collaborators vs Organization Members
Outside collaborators are often the riskiest category — they're contractors or ex-employees who were added to one repo and never removed. You can list them in the web UI under Settings → Members → Outside collaborators, but for scale you need the API.
Using GitHub CLI to List Repo Collaborators at Scale
#!/usr/bin/env bash
# Enumerate all collaborators across all org repos
# Output: CSV with repo, username, permission, last_commit_date
# Requirements: gh CLI authenticated, jq installed
ORG="your-org-name"
OUTPUT_FILE="collaborators_$(date +%F).csv"
echo "repo,username,permission,last_commit_date" > "${OUTPUT_FILE}"
# Fetch all repos with pagination
REPOS=$(gh api --paginate "/orgs/${ORG}/repos" --jq '.[].name')
for REPO in ${REPOS}; do
echo "Processing: ${REPO}"
# Retry loop for rate limiting
for ATTEMPT in 1 2 3; do
RESPONSE=$(gh api --paginate \
"/repos/${ORG}/${REPO}/collaborators?affiliation=all" \
--jq '.[] | {login: .login, permission: .permissions}' 2>&1)
if echo "${RESPONSE}" | grep -q "rate limit"; then
RESET_TIME=$(gh api /rate_limit --jq '.rate.reset')
SLEEP_SECS=$(( RESET_TIME - $(date +%s) + 5 ))
echo "Rate limited. Sleeping ${SLEEP_SECS}s..."
sleep "${SLEEP_SECS}"
else
break
fi
done
# Get last commit date as a proxy for activity
LAST_COMMIT=$(gh api "/repos/${ORG}/${REPO}/commits?per_page=1" \
--jq '.[0].commit.author.date' 2>/dev/null || echo "unknown")
echo "${RESPONSE}" | jq -r --arg repo "${REPO}" --arg lcd "${LAST_COMMIT}" \
'[.login, (if .permission.admin then "admin" elif .permission.push then "write" elif .permission.pull then "read" else "none" end)] | @csv' \
| sed "s/^/\"${REPO}\",/" \
| sed "s/$/,\"${LAST_COMMIT}\"/" >> "${OUTPUT_FILE}"
sleep 0.5 # Gentle throttle between repos
done
echo "Done. Output: ${OUTPUT_FILE}"
Note:
affiliation=allreturns both organization members and outside collaborators. To isolate only outside collaborators, useaffiliation=outside. Thelast_commit_datehere is the repo's last commit, not the user's — use the Commits API filtered by author for per-user activity if you need precision.
Review the CSV output and flag any accounts where the username looks like a contractor pattern (e.g., jdoe-contractor) or where the associated email domain doesn't match your company domain.
Step 3 — Audit Third-Party OAuth Apps and GitHub Apps
OAuth apps and GitHub Apps are a major blind spot in most access reviews. A compromised or over-permissioned app can exfiltrate code from every repository it has access to — and it doesn't show up in your collaborator list.
Reviewing Installed GitHub Apps and Their Scope
Navigate to https://github.com/organizations/{ORG}/settings/installations to see all installed GitHub Apps. Each app shows its permission scopes and repository access (all repos vs. selected repos). Apps with contents: write on all repositories should be scrutinized.
Revoking Suspicious OAuth App Tokens via the API
#!/usr/bin/env bash
# List all GitHub App installations for an org
ORG="your-org-name"
TOKEN="${GITHUB_TOKEN}"
echo "=== GitHub App Installations for ${ORG} ==="
curl -s \
-H "Authorization: Bearer ${TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/orgs/${ORG}/installations" \
| jq '.installations[] | {id: .id, app_slug: .app_slug, repository_selection: .repository_selection, created_at: .created_at}'
# Example JSON response structure:
# {
# "id": 12345678,
# "app_slug": "some-ci-tool",
# "repository_selection": "all",
# "created_at": "2023-06-15T10:22:00Z"
# }
# To revoke (delete) a specific installation by ID:
INSTALLATION_ID="12345678" # Replace with actual ID from above
echo "=== Revoking installation ${INSTALLATION_ID} ==="
curl -s -X DELETE \
-H "Authorization: Bearer ${TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/app/installations/${INSTALLATION_ID}"
# A 204 No Content response means the installation was successfully deleted.
# The app loses access to all repositories immediately.
Note: There's a critical distinction here. OAuth App tokens are tied to a user's identity and inherit that user's permissions — revoking them lives under
Settings → Applications → Authorized OAuth Appsfor each user. GitHub App installation tokens are short-lived (1-hour TTL), scoped to the installation, and revokable org-wide via the API as shown above. GitHub Apps are the preferred model precisely because of this contained blast radius.
Detecting Anomalous App Activity
Search your audit log for action:oauth_application.create and action:integration.installation.create events that occurred outside business hours or from unfamiliar IP addresses. Cross-reference app names against your organization's approved software list.
Step 4 — Enforce Branch Protection and Repository Visibility Rules
Enumerating who has access is only half the job. Even with the right people in place, unprotected default branches mean a single compromised account can push malicious code directly to main. Enforce protection rules programmatically so they apply consistently across all repositories.
Setting Branch Protection Rules via the API
#!/usr/bin/env bash
# Apply branch protection to the default branch of every repo in an org
# Requires: repo scope + admin rights on each repo
ORG="your-org-name"
TOKEN="${GITHUB_TOKEN}"
TEAM_SLUG="platform-team" # Only this team can push to protected branch
REPOS=$(gh api --paginate "/orgs/${ORG}/repos" --jq '.[] | select(.archived == false) | .name')
for REPO in ${REPOS}; do
DEFAULT_BRANCH=$(gh api "/repos/${ORG}/${REPO}" --jq '.default_branch')
echo "Protecting ${REPO}:${DEFAULT_BRANCH}"
curl -s -X PUT \
-H "Authorization: Bearer ${TOKEN}" \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${ORG}/${REPO}/branches/${DEFAULT_BRANCH}/protection" \
-d @- << 'EOF'
{
"required_status_checks": {
"strict": true,
"contexts": ["ci/tests", "security/scan"]
},
"enforce_admins": true,
"required_pull_request_reviews": {
"dismiss_stale_reviews": true,
"require_code_owner_reviews": true,
"required_approving_review_count": 2
},
"restrictions": {
"users": [],
"teams": ["platform-team"],
"apps": []
},
"required_linear_history": true,
"allow_force_pushes": false,
"allow_deletions": false,
"block_creations": false,
"required_conversation_resolution": true
}
EOF
echo "Protected: ${REPO}"
sleep 0.3
done
Restricting Repository Visibility Changes
Go to Organization Settings → Member privileges → Repository visibility change and set it to Disabled. This prevents any member (not just owners) from making a private repo public — a common accidental data exposure vector.
Enabling Secret Scanning and Push Protection
For Enterprise orgs, enable secret scanning org-wide via Settings → Code security and analysis → Secret scanning → Enable all. Push protection (which blocks commits containing secrets before they land) requires GitHub Advanced Security. Enable it with:
curl -s -X PATCH \
-H "Authorization: Bearer ${GITHUB_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/orgs/${ORG}" \
-d '{"secret_scanning_push_protection_enabled_for_new_repositories": true}'
Step 5 — Set Up Automated Access Review with GitHub Actions
Manual audits decay. The collaborator list you reviewed last month is already stale. Automate the entire review loop: detect drift, alert your team, and create a paper trail — all within GitHub itself.
The Complete Automated Audit Workflow
# .github/workflows/access-audit.yml
name: Weekly Access Audit
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 09:00 UTC
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
issues: write
jobs:
audit-collaborators:
runs-on: ubuntu-latest
env:
ORG: ${{ github.repository_owner }}
GH_TOKEN: ${{ secrets.AUDIT_PAT }} # PAT with read:org, repo scopes
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch current outside collaborators
id: fetch
run: |
gh api --paginate "/orgs/${ORG}/outside_collaborators" \
--jq '[.[] | {login: .login, html_url: .html_url}]' \
| jq 'sort_by(.login)' > current_collaborators.json
echo "Found $(jq length current_collaborators.json) outside collaborators"
- name: Download baseline artifact
id: baseline
uses: actions/download-artifact@v4
with:
name: collaborator-baseline
path: baseline/
continue-on-error: true # First run won't have a baseline
- name: Diff against baseline
id: diff
run: |
if [ -f baseline/collaborators_baseline.json ]; then
ADDED=$(jq --slurpfile new current_collaborators.json \
--slurpfile old baseline/collaborators_baseline.json \
-rn '($new[0] | map(.login)) - ($old[0] | map(.login)) | .[]')
REMOVED=$(jq --slurpfile new current_collaborators.json \
--slurpfile old baseline/collaborators_baseline.json \
-rn '($old[0] | map(.login)) - ($new[0] | map(.login)) | .[]')
echo "added=${ADDED}" >> $GITHUB_OUTPUT
echo "removed=${REMOVED}" >> $GITHUB_OUTPUT
DRIFT=$([ -n "${ADDED}${REMOVED}" ] && echo "true" || echo "false")
echo "drift=${DRIFT}" >> $GITHUB_OUTPUT
else
echo "drift=false" >> $GITHUB_OUTPUT
echo "No baseline found — establishing baseline on this run."
fi
- name: Open GitHub Issue if drift detected
if: steps.diff.outputs.drift == 'true'
env:
ADDED: ${{ steps.diff.outputs.added }}
REMOVED: ${{ steps.diff.outputs.removed }}
run: |
BODY="## 🔐 Access Drift Detected\n\n"
BODY+="**Run date:** $(date -u +%Y-%m-%d)\n\n"
[ -n "${ADDED}" ] && BODY+="### ➕ New Outside Collaborators\n\`\`\`\n${ADDED}\n\`\`\`\n"
[ -n "${REMOVED}" ] && BODY+="### ➖ Removed Outside Collaborators\n\`\`\`\n${REMOVED}\n\`\`\`\n"
BODY+="\nPlease review and verify all changes are authorized. Close this issue once verified."
gh issue create \
--title "[Access Audit] Collaborator drift detected $(date +%Y-%m-%d)" \
--body "$(echo -e "${BODY}")" \
--label "security,access-review" \
--repo "${GITHUB_REPOSITORY}"
- name: Update baseline artifact
uses: actions/upload-artifact@v4
with:
name: collaborator-baseline
path: current_collaborators.json
retention-days: 90
overwrite: true
Note: Store
AUDIT_PATas a repository or organization secret. This PAT needsread:organdreposcopes. Do NOT useGITHUB_TOKENhere — it lacks the cross-org API access you need for/orgs/{org}/outside_collaborators. Theoverwrite: trueoption on the upload step requiresactions/upload-artifact@v4.
This workflow automatically creates a labeled issue every Monday if drift is detected, giving your security team a consistent review artifact with zero manual effort.
Common Issues & Fixes
Error: 403 Forbidden when calling audit log API
Cause: Your PAT is missing the read:audit_log scope, or your organization is on the Free plan (audit log API requires Team or Enterprise).
Fix: Generate a new classic PAT with read:audit_log, admin:org, and repo scopes. For fine-grained PATs, select the organization and enable "Read access to audit log" under Organization permissions. Verify your plan at Settings → Billing.
| Error / Symptom | Root Cause & Fix |
|---|---|
| 403 Forbidden on /orgs/{org}/audit-log | Missing read:audit_log scope or Free plan. Add scope to PAT or upgrade plan. |
| Empty results despite known events | Events older than your plan's retention window (7/90/180 days). Enable streaming on Enterprise. |
| 401 Unauthorized mid-script | Fine-grained PAT expired (max 1 year). Regenerate and update secret. |
| App installation token expires | GitHub App tokens last 1 hour. Re-authenticate before each API call in long scripts. |
| Branch protection not applying | Fork repos inherit the fork's protection, not the upstream's. Apply rules directly to the fork. |
Error: Missing events in audit log export — pagination and retention limits
Cause: The API returns 100 events per page by default, and your query didn't follow Link: rel="next" headers. Events older than your plan's retention window are permanently gone.
Fix: Use gh api --paginate or implement cursor-based pagination using the after query parameter returned in each response's Link header. For Enterprise, enable audit log streaming to a persistent store (S3, Azure Blob, Splunk) before your 180-day window closes.
Error: GitHub App installation token expiry causing script failures
Cause: GitHub App installation tokens expire after exactly 60 minutes. Long-running scripts that don't re-authenticate will start getting 401 errors mid-execution.
Fix: Wrap your API calls in a token-refresh function. Before each batch of calls, check GET /installation/token and request a fresh token if you're within 5 minutes of expiry. Alternatively, use a PAT for audit scripts where token longevity matters more than granular permissions.
Error: Branch protection rules silently not applying to forked repos
Cause: Branch protection rules are scoped to the repository they're set on. A forked repository is a separate repository object — your protection loop only covers ORG/repo, not FORKEDUSER/repo.
Fix: Add select(.fork == false) to your jq filter when iterating repos: gh api --paginate "/orgs/${ORG}/repos" --jq '.[] | select(.fork == false) | .name'. For internal forks you do control, run the protection script against them separately.
FAQ
Q: How long does GitHub retain audit log data and what plan do I need?
GitHub retains audit log data for 7 days on Free, 90 days on Team, and 180 days on Enterprise. Enterprise organizations can extend retention indefinitely by enabling audit log streaming to an external store like Amazon S3 or a SIEM. The audit log REST API (which you need for programmatic export) requires Team plan or higher.
Q: What is the difference between a GitHub App and an OAuth App for security purposes?
OAuth Apps authenticate as a user and inherit that user's full permissions — if the user is an org owner, the OAuth app effectively is too. GitHub Apps authenticate as themselves with explicitly granted, least-privilege permissions scoped to specific repositories. GitHub Apps also use short-lived installation tokens (60-minute TTL) rather than long-lived user tokens. For any integration that needs organization-level access, always prefer GitHub Apps. You can audit all authorized OAuth apps for your org members at Settings → Third-party access.
Q: How do I respond if I detect an unauthorized repository clone or access event?
Immediately revoke the compromised user's session tokens via Settings → Sessions and rotate any secrets that may have been exposed using GitHub's secret scanning alerts. If the event looks like a credential compromise, force a SAML re-authentication by enabling SAML SSO with required authorization — this invalidates all existing OAuth tokens and forces re-auth through your IdP. File a security advisory via Security → Advisories to track the incident, and review GitHub's incident response documentation for org-level containment steps.