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 --version should return 2.40.0 or later. Install via brew install gh or the official installer.
  • [ ] You have a Personal Access Token (PAT) or a fine-grained PAT with scopes read:audit_log, admin:org, and repo. Store it in an environment variable (GITHUB_TOKEN).
  • [ ] jq is installed for JSON parsing: jq --versionjq-1.7 or later.
  • [ ] curl is 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 ExportExport 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 Link header parser or use gh 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=all returns both organization members and outside collaborators. To isolate only outside collaborators, use affiliation=outside. The last_commit_date here 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 Apps for 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_PAT as a repository or organization secret. This PAT needs read:org and repo scopes. Do NOT use GITHUB_TOKEN here — it lacks the cross-org API access you need for /orgs/{org}/outside_collaborators. The overwrite: true option on the upload step requires actions/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.