threatfound
Attack Surface & Exposure
Attack Surface & Exposure · lesson 01

Leaked secrets: API keys in code & git history

How API keys, tokens, and database credentials leak through code and git history, why deleting them doesn't help, and how to detect, rotate, and prevent exposure.

10 min read

A leaked secret is any credential — an API key, access token, or database password — that ends up somewhere an attacker can read it. Secrets are bearer credentials: whoever holds the string gets the access it grants, no questions asked. Once a live key is public, the attacker doesn't need to break anything. They just use it.

Where secrets actually leak

The leak is almost never a dramatic breach. It's a credential put somewhere convenient that turned out to be readable. The common paths:

  • Hardcoded in client code. A key baked into browser JavaScript or a mobile app bundle ships to every user. Anyone can open devtools or unzip the .apk/.ipa and read it — client-side code is never secret.
  • Committed to a repo. A key pasted into a config file and committed, then the repo goes public (or the private repo is later shared, forked, or breached).
  • Left in git history. The key was removed in a later commit, but the commit that added it is still in the history.
  • Printed in CI logs. Build and deploy pipelines echo environment variables or run commands that dump secrets into logs that are often world-readable.
  • A `.env` served by the webserver. A misconfigured server exposes /.env or /config.php.bak directly over HTTP, handing out every credential in one request.

Why "delete the commit" does nothing

The instinct on discovering a committed key is to delete it and push a fix. That removes it from the current files, not from history. Git stores every version of every file. The commit that introduced the secret still exists, reachable by its hash:

# The key is 'gone' from the working tree...
git rm config.js && git commit -m "remove secret"

# ...but it's trivially recoverable from history
git log -p -- config.js              # shows the diff that added it
git show <old-commit>:config.js      # prints that file's full old contents

Even a full history rewrite (git filter-repo, force-push) doesn't undo exposure. If the repo was ever public, the secret is likely in forks you don't control, in GitHub's cached views and pull-request data, in clones on other machines, and in third-party mirrors and search indexes. You cannot recall a string that has already left your control.

How attackers find them

Finding leaked keys is automated and cheap. Secrets have recognizable shapes, and scanners look for them two ways:

  • Pattern matching. Regexes for known prefixes — AKIA... for AWS access keys, sk_live_ for Stripe secret keys, ghp_ for GitHub personal tokens, xoxb- for Slack. A prefix plus the right length and character set is a high-confidence hit.
  • Entropy analysis. Even without a known prefix, a long random-looking string stands out from normal code by its high entropy, flagging generic tokens and passwords.
  • Code search. GitHub's search and its API let anyone scan public commits continuously. Tools like trufflehog and gitleaks run the same logic across a repo's entire history, not just its latest state.
# Scan a repo's full history for committed secrets
gitleaks detect --source . --verbose

# Deep-scan history + verify which findings are live
trufflehog git file://. --only-verified

Live vs. dead, and the verification line

A dead key — already rotated or revoked — is harmless clutter. A live key is a working credential to your systems. Attackers prioritize live ones, so many tools offer to "verify" a found key by calling the provider's API with it (for example, hitting AWS STS GetCallerIdentity or a Stripe read endpoint) and checking whether it authenticates.

careful

Verifying a found key is an active authentication attempt against someone's account, not passive analysis. Only verify keys for assets you own or are explicitly authorised to test. Using a key you found against a system you don't control is unauthorised access — a crime in most jurisdictions, regardless of how you obtained the key.

What a live key gets an attacker

The blast radius is whatever the key can do. A leaked cloud key can read and delete data, spin up expensive resources, or pivot deeper into the account. A payment-provider key can move money or exfiltrate customer records. A database credential is a direct line to your data. Because it's a valid credential, the activity looks like legitimate use — it often doesn't trip alarms until the bill or the breach surfaces.

tip

Assume any secret that touches a repo, a log, or a client bundle is already compromised. Design so that a single leaked credential is low-value: scope keys to the minimum permissions they need, and prefer short-lived, auto-expiring tokens over long-lived static keys so a leak has a built-in shelf life.

How to prevent it

  • Never commit secrets. Keep them out of code entirely — load from environment variables or a secrets manager (Vault, AWS Secrets Manager, GCP Secret Manager). Add .env to .gitignore before the first commit, and never send a .env file to a client.
  • Scan before it lands. Run gitleaks or trufflehog as a pre-commit hook and in CI so a secret is blocked before it reaches history, not discovered after.
  • On a leak, rotate — don't just delete. Deleting the string leaves the live credential valid. Immediately revoke and reissue the key at the provider, then update your systems. Rotation is the only thing that actually closes the exposure.
  • Prefer short-lived credentials. Use temporary tokens, workload identity, and OIDC-based CI auth instead of static long-lived keys, so exposure windows are minutes, not years.
  • Watch for exposure continuously. Providers and monitoring services scan public code for key patterns around the clock. Because leaks surface in forks, caches, and paste sites you'll never see, continuous external monitoring is what catches a key that's already loose — the sooner you know, the sooner you rotate.