Secrets, repos, PATs, and integration risks
For developers who want to stop leaking credentials before they get exploited
Your code repository is not just source code — it is a map of your infrastructure. Secrets committed to git, overprivileged tokens, misconfigured repo settings, and third-party integrations with excessive access are among the most common causes of real-world breaches.
Automated bots continuously scrape GitHub for API keys, tokens, and credentials — both in public repos and via leaked tokens that grant access to private ones. A secret exposed for 60 seconds is enough to be discovered and abused.
This guide covers six attack surfaces: client-side secret exposure, git history leaks, repo misconfigurations, GitHub Actions risks, PAT overprovisioning, and third-party integration abuse.
Framework-specific traps that ship your secrets to every browser
Modern frontend frameworks have a concept of build-time environment variables — values that get embedded directly into your compiled JavaScript bundle and sent to every user's browser. This is by design for things like your public analytics ID. It is catastrophic for API keys, database URLs, or any secret.
AI-generated code frequently gets this wrong because it doesn't distinguish between server-side and client-side execution contexts.
Next.js — NEXT_PUBLIC_ prefix = exposed to browser
# .env NEXT_PUBLIC_STRIPE_KEY=sk_live_... ← WRONG: bundled into client JS NEXT_PUBLIC_OPENAI_KEY=sk-proj-... ← WRONG: visible in DevTools # Correct — no NEXT_PUBLIC_ prefix = server only STRIPE_SECRET_KEY=sk_live_... OPENAI_API_KEY=sk-proj-...
Vite / React — VITE_ prefix = exposed to browser
# .env VITE_API_SECRET=abc123... ← WRONG: bundled into client VITE_DB_PASSWORD=hunter2 ← WRONG: visible in page source # Private vars in Vite must NOT have the VITE_ prefix # and must only be read in server-side code
Create React App — REACT_APP_ prefix = exposed to browser
# Everything prefixed REACT_APP_ is public REACT_APP_FIREBASE_API_KEY=... ← in the bundle REACT_APP_TWILIO_AUTH_TOKEN=... ← in the bundle
The fix is always the same regardless of framework: the browser should never call a third-party API directly with a secret. Route the call through your own API endpoint, which holds the secret server-side.
Wrong — browser holds the key
// React component
const res = await fetch(
"https://api.openai.com/v1/chat",
{
headers: {
Authorization: `Bearer ${
process.env.REACT_APP_OPENAI_KEY
}` // ← in the bundle
}
}
);Correct — server proxies the request
// React component — calls YOUR api
const res = await fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt })
});
// /api/chat (server-side)
// process.env.OPENAI_API_KEY lives here
// never touches the browserAttackers extract secrets from production bundles using simple string analysis. You can do the same to audit your own build before shipping:
# Build your app first, then search the output npm run build # Search for common secret patterns in the bundle grep -r "sk-" ./dist grep -r "sk_live" ./dist grep -r "AKIA" ./dist # AWS key prefix grep -r "Bearer " ./dist grep -r "password" ./dist # Or use trufflehog on your built output trufflehog filesystem ./dist
Rule: Any env var that needs to stay secret must never have the framework's client-prefix (NEXT_PUBLIC_, VITE_, REACT_APP_). Secrets are read on the server only. The browser calls your API, your API calls theirs.
Deleting a file does not remove it from history
This is one of the most misunderstood aspects of git. When you commit a secret and then delete it in the next commit, the secret is still fully accessible in git history. Anyone with access to the repo — or anyone who cloned it before the deletion — can retrieve it with a single command.
How attackers retrieve deleted secrets
# Find all commits that touched .env or config files git log --all --full-history -- "**/.env" git log --all --full-history -- "**/config.js" # View the content of a specific past commit git show <commit-hash>:.env # Search entire history for a string pattern git log -p | grep -i "api_key|secret|password|token"
All of the following leave secrets permanently accessible in history:
# Step 1: Rotate the secret IMMEDIATELY (assume it's already compromised) # Do this before anything else — history rewriting takes time # Step 2: Remove secret from history using git-filter-repo pip install git-filter-repo git filter-repo --path .env --invert-paths # Or remove a specific string from all history git filter-repo --replace-text <(echo 'sk-proj-abc123==>REMOVED') # Step 3: Force push all branches (coordinate with team first) git push origin --force --all # Step 4: Invalidate all existing clones # Anyone who cloned before must re-clone — old clones still have the history
# Create .gitignore BEFORE git init or first commit # Minimum entries for any project: .env .env.local .env.*.local .env.production *.pem *.key secrets/ credentials.json service-account.json
Rule: If a secret was ever committed — even briefly, even in a private repo — treat it as compromised and rotate it immediately. Rewriting history is a secondary cleanup step, not the primary response. Enable GitHub Secret Scanning (Settings → Security → Secret scanning) to be alerted automatically when patterns are detected.
Default GitHub settings are not secure defaults
Without branch protection on main, any contributor — or an attacker with a compromised token — can push directly, bypass CI, and deploy malicious code.
No direct pushes to main — all changes go through a PR
Someone else must review before merge
New commits reset approval — prevents sneak changes after approval
CI must pass (tests, lint, build) before merge
PR branch must be current with main before merge
Even admins must follow the rules
Limit direct push access to a specific team or no one
Making a repo public is irreversible in terms of data exposure — if a secret was in the repo before it was made public, bots may have already scraped it. Additionally, even "private" repos at the free tier can become public accidentally or through permission misconfiguration.
trufflehog git file://.CI pipelines are a common lateral movement path
By default, workflows triggered by pull_request from forks do not have access to secrets. But pull_request_target runs in the context of the base repo and does — meaning a malicious fork PR can exfiltrate all your CI secrets if your workflow is misconfigured.
Dangerous pattern
on:
pull_request_target: # ← has secrets
types: [opened]
jobs:
build:
steps:
- uses: actions/checkout@v4
with:
# Checking out fork code with
# access to secrets = RCE risk
ref: ${{ github.event.pull_request.head.sha }}Safe for PRs from forks
on:
pull_request: # ← no secrets for forks
types: [opened, synchronize]
# For deploy workflows that need secrets,
# use workflow_run triggered after
# pull_request completes — never give
# fork code direct access to secretsThe GITHUB_TOKEN available in every workflow defaults to broad write permissions in many repos. Scope it to the minimum your workflow actually needs.
# Set default permissions to read-only for the whole workflow
permissions: read-all
jobs:
deploy:
permissions:
contents: read # only what's needed
id-token: write # for OIDC auth if required
# everything else: none (not declared = denied)uses: actions/checkout@v4 means "whatever the v4 tag points to today." Tag references can be moved by the action maintainer (or an attacker who compromises their account) to point to malicious code. Pin to a specific commit SHA instead.
Tag reference — mutable
- uses: actions/checkout@v4 - uses: actions/setup-node@v4
SHA pin — immutable
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
Rule: Default permissions: read-all on every workflow. Never use pull_request_target with fork code checkout. Pin third-party actions to commit SHAs. Enable Dependabot for Actions to get automated SHA update PRs.
A single overprivileged token can hand an attacker your entire org
Personal Access Tokens (PATs) are a major risk surface because developers tend to create them once, give them broad permissions "just in case," set no expiry, and then use them across multiple systems. When one of those systems is compromised, the attacker has a token with access to everything.
| Use Case | Minimum Permission Needed | Not Needed |
|---|---|---|
| Clone / pull a repo | Contents: Read | Write, Admin |
| Push code to a repo | Contents: Read & Write | Admin, Delete |
| Create a PR | Pull requests: Read & Write | Admin, org access |
| Read GitHub Actions logs | Actions: Read | Write, Secrets |
| Trigger a workflow dispatch | Actions: Write | Admin, repo:all |
| Read org member list | Members: Read | Admin, Write |
| Deploy via CI | Contents: Read (use OIDC instead) | repo scope on classic PAT |
Rule: Always use Fine-grained PATs over Classic PATs. Set a maximum 90-day expiry. Grant access to specific repos only, not all repos. For CI/CD, prefer OIDC (OpenID Connect) over PATs entirely — it issues short-lived tokens per-workflow-run with no long-lived secret to rotate or leak. Audit your active PATs at Settings → Developer settings → Personal access tokens and revoke any you don't recognize or haven't used recently.
Every integration is a potential breach vector
GitHub integrations — OAuth apps, GitHub Apps, and CI/CD connections — accumulate over time. Each one represents a third party that has been granted some level of access to your repos or org. When that third party is compromised (it happens regularly), the attacker inherits whatever permissions you granted them.
CI/CD tools granted repo scope org-wide
Vercel, Netlify, Railway, and others request access to all repos when you connect them. Switch to installing the GitHub App on specific repos only — most providers support this.
Code review bots with write access
Some code quality tools request write access to post comments. Read access is sufficient for most. Review what each integration actually does before approving.
Bot/service accounts with admin role
Integration accounts created as 'admin' for convenience. These accounts should have the minimum role needed — usually write or maintain, never admin.
Forgotten OAuth apps from old projects
Apps authorized years ago for a project that no longer exists, still holding live permissions. These accumulate and are rarely audited.
Dependabot with write access to protected branches
Dependabot auto-merge enabled on main without requiring review. An attacker who can influence a dependency can merge malicious code.
Where to look in GitHub
Personal integrations: Settings → Applications → Authorized OAuth Apps Settings → Applications → Authorized GitHub Apps Settings → Developer settings → Personal access tokens Organization integrations: Org Settings → Third-party access Org Settings → GitHub Apps Org Settings → OAuth application policy For each integration, ask: 1. Do we still use this? 2. What permissions did we grant? 3. Is this the minimum needed? 4. When was it last active?
Rule: Schedule a quarterly integration audit. Revoke anything you don't recognize or no longer use. When installing new integrations, always choose "Only select repositories" rather than "All repositories." Prefer GitHub Apps over OAuth Apps — they offer finer-grained permissions and better audit trails.
Work through this list for every active repository and organization.
Most of these risks accumulate slowly — a PAT created a year ago, an integration installed for a project that ended, a branch protection rule that was temporarily disabled and never re-enabled. Schedule a 10-minute calendar block every quarter to run through the checklist above. That habit alone eliminates the majority of the attack surface described in this guide.
Add a .gitignore that excludes .env files and credential files before your first commit. Install a pre-commit hook using git-secrets or Gitleaks to block commits containing API keys or passwords. Enable GitHub's built-in secret scanning (free for public repos) to catch anything that slips through. If a secret does get committed, rotate it immediately — just deleting it from history is not enough, assume it is compromised.
A fine-grained PAT (personal access token) is a GitHub token that limits access to specific repositories and specific permissions, rather than granting broad access to all your repos. Classic PATs give full read/write access to every repo your account can access — a leak exposes everything. Fine-grained PATs follow the principle of least privilege: a token for one automation can only touch one repo and only perform the operations it needs.
Pin all third-party GitHub Actions to a specific commit SHA instead of a mutable tag like @v3 — a SHA cannot be silently replaced. Set permissions: write-all to the minimum your workflow needs. Never use pull_request_target for untrusted forks without carefully scoping what it can access. Use OIDC (OpenID Connect) instead of storing long-lived cloud credentials as secrets.
Essential branch protection rules for main/master: require pull requests with at least one approval, require status checks to pass before merging, require branches to be up to date, prohibit force pushes, and do not allow deletion. For sensitive repos, also enable required signed commits and restrict who can push directly.
Go to GitHub Settings > Applications > Authorized OAuth Apps and Installed GitHub Apps. Revoke access for any app you no longer use. For each remaining app, check what permissions it has and what repos it can access — restrict to specific repos where possible instead of granting access to all repositories. Review this list quarterly, as integrations accumulate over time.