Secrets Scanning: How to Find Exposed API Keys, Tokens, and Credentials Before Attackers Do
A developer commits a .env file to a public GitHub repo at 2 PM. By 2:04 PM, automated bots have already cloned the repository, extracted the AWS access key, and spun up EC2 instances for cryptocurrency mining. The developer finds out when a $47,000 AWS bill arrives at the end of the month.
This is not a hypothetical. It happens daily, at every scale of organization. The credentials that leak are rarely the result of negligence — they are the result of workflows and tooling that make committing secrets easier than not committing them. Secrets scanning is the systematic practice of catching those leaks before they become incidents.
Why Exposed Credentials Are Such High-Impact Findings
Secrets are different from most vulnerability classes. A SQL injection finding requires chaining: you need to find the injection point, craft a working payload, and extract data step by step. An exposed API key is a fully functional credential — the attacker already has authenticated access. The blast radius depends entirely on what that key can do.
Real incidents illustrate this clearly:
- Samsung (2023): Researchers found Samsung's GitLab instance contained repositories with hardcoded credentials for internal AWS services, Samsung Pay secrets, and internal source code. The exposure was discovered by external security researchers — not Samsung's own team.
- Uber (2022): The attacker compromised an Uber contractor's credentials via MFA fatigue, then moved laterally through internal systems. Once inside, they found a PowerShell script on a network share containing hardcoded admin credentials for Thycotic (Uber's PAM solution), granting near-total access to internal systems including HackerOne reports.
- GitHub Token Leaks: In 2022, attackers used stolen OAuth tokens from Heroku and Travis CI — tokens that developers had authorized against their GitHub accounts — to access private repositories of dozens of organizations including npm. The tokens themselves had never been exposed; it was the platforms holding them that were compromised. This shows that the attack surface extends beyond what you commit.
The common thread: secrets exposure collapses the attacker's path from zero to authenticated access. That is why it warrants dedicated tooling rather than hoping developers remember to check.
Where Credentials Actually Get Exposed
Understanding the exposure surface is the prerequisite for scanning it effectively.
Git History
The most common vector. A developer adds a secret to a config file to test something locally, commits it, realizes the mistake, removes it in a follow-up commit, and considers the problem solved. The secret remains in git history indefinitely. Anyone with access to the repository — now or in the future, public or private — can retrieve it with git log -p or by checking out the old commit.
Deleting a file does not remove it from git history. Amending a commit does not rewrite history on remotes unless you force-push. A .gitignore entry does not retroactively remove tracked files. History rewrites with git filter-repo are the correct remediation, but they are destructive operations that break clones and require coordination across every contributor.
Environment Files Committed Accidentally
.env, .env.local, .env.production, config/secrets.yml, application.properties, appsettings.json — these files are designed to hold secrets and are routinely committed when .gitignore is misconfigured or when a new developer sets up a project without reviewing the ignore rules. Monorepo structures compound this: a new service added under a subdirectory may not inherit the root-level .gitignore.
JavaScript Bundles
Frontend applications that call third-party APIs directly must embed credentials in client-side code. This is sometimes intentional (a Stripe publishable key, a PostHog project token) but is often accidental — a developer uses a server-side SDK in a client-side bundle, or copies config from a backend service without realizing what gets included in the webpack output. Bundled secrets are visible to any user who opens DevTools and inspects the network tab or views source.
Error Pages and Debug Output
Stack traces returned in production error responses can include environment variable values, database connection strings, and internal hostnames. Django's DEBUG=True in production is the textbook example, but the same issue appears in Express apps that pass err objects directly to res.json(), Spring Boot actuator endpoints left exposed, and PHP applications with display_errors enabled.
API Responses
Internal API endpoints sometimes return more data than intended — user objects that include hashed or even plaintext passwords, tokens embedded in response payloads, or internal service credentials surfaced through poorly scoped serializers. GraphQL introspection can expose field names and types that reveal how credentials are stored. /api/v1/me returning "api_key": "sk_live_..." is a real class of finding.
Config Files in Web Roots
Files like .git/config, web.config, phpinfo.php, wp-config.php.bak, and docker-compose.yml are often accessible via direct HTTP requests if the web server is misconfigured or if the deploy process copies them into the document root. The .git directory being web-accessible is a particularly severe case — it allows full source code reconstruction.
Secrets Scanning Tools
There is no single tool that covers every scenario. A mature program uses multiple layers.
TruffleHog
TruffleHog scans git repositories, S3 buckets, filesystems, and CI/CD pipelines for secrets using both regex patterns and entropy analysis. Version 3 introduced verified detectors — TruffleHog attempts to call the corresponding API to confirm whether a discovered credential is still active, which significantly reduces triage noise.
# Scan a local repo including full git history
trufflehog git file://. --only-verified
# Scan a remote repo
trufflehog github --repo https://github.com/org/repo --only-verified
# Scan a Docker image
trufflehog docker --image myapp:latest
The --only-verified flag is worth the tradeoff in most CI contexts: you lose some coverage of inactive credentials, but the findings that do surface require immediate action with no manual triage step.
Gitleaks
Gitleaks is fast, written in Go, and ships as a single binary. It is well-suited for pre-commit hooks and CI pipelines where startup time matters. It ships with a large default ruleset covering AWS, GCP, GitHub, Slack, Stripe, Twilio, and dozens of other providers, and supports custom rules via TOML configuration.
# Scan working directory and git history
gitleaks detect --source . --report-format json --report-path gitleaks-report.json
# Protect: scan staged changes only (for pre-commit hooks)
gitleaks protect --staged
# Custom rules
gitleaks detect --config .gitleaks.toml
Example custom rule for an internal token format:
[[rules]]
id = "internal-service-token"
description = "Internal service authentication token"
regex = '''IST-[a-zA-Z0-9]{32}'''
tags = ["secret", "internal"]
[rules.allowlist]
paths = ['''test/fixtures''']
detect-secrets
Yelp's detect-secrets takes a different approach: rather than failing builds on every detected secret, it maintains a baseline file (.secrets.baseline) that tracks known, reviewed findings. New secrets trigger failures; previously audited secrets do not. This makes it practical for teams onboarding secrets scanning onto existing repositories with existing technical debt.
# Create initial baseline (audit all current findings)
detect-secrets scan > .secrets.baseline
detect-secrets audit .secrets.baseline
# In CI: fail only on new secrets not in baseline
detect-secrets scan --baseline .secrets.baseline
Semgrep Secrets Rules
Semgrep's secrets rules combine pattern matching with dataflow analysis. This catches cases that pure regex misses — a secret assigned to a variable in one file and passed to an HTTP client in another, or a secret read from an environment variable and then logged. The Semgrep Registry includes a maintained secrets ruleset:
# Run secrets rules from registry
semgrep --config p/secrets .
# Include in a broader scan
semgrep --config p/secrets --config p/owasp-top-ten .
GitHub Secret Scanning
GitHub's native secret scanning runs automatically on public repositories and is available for private repositories on GitHub Advanced Security. It covers 200+ token patterns from major providers and supports push protection — blocking pushes that contain recognized secrets before they land in the repository.
Push protection is the highest-leverage control GitHub offers here. Enable it at the organization level:
Settings → Code security and analysis → Secret scanning → Push protection → Enable for all repositories
GitHub also notifies service providers directly when their token format is detected, allowing providers like AWS, Stripe, and GitHub itself to automatically revoke compromised credentials — a response time that no human-driven process can match.
Integrating Secrets Scanning into CI/CD
The most effective integration runs at two points: pre-commit (developer workstation) and CI pipeline (enforcement gate).
Pre-commit Hook with Gitleaks
Using the pre-commit framework to install gitleaks as a hook across the team:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
Install and activate:
pip install pre-commit
pre-commit install
This catches secrets at the point of commit, before they reach the remote. The tradeoff is that developers can bypass hooks with git commit --no-verify — which is why the CI gate is non-optional.
GitHub Actions Pipeline Gate
name: Secrets Scan
on:
push:
branches: ["**"]
pull_request:
branches: [main, develop]
jobs:
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Full history required for git scanning
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEAD
extra_args: --only-verified
- name: Gitleaks scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Set branch protection rules to require this check to pass before merging. This makes it impossible to merge a PR containing secrets without an explicit security team override.
Scanning Existing Repositories
For repositories that have never been scanned, run a full history scan before enabling CI gates. This surfaces existing debt and allows you to triage it before creating noise in the pipeline:
# Full history scan with TruffleHog, output as JSON for triage
trufflehog git file://. --json 2>/dev/null | jq -r \
'[.SourceMetadata.Data.Git.commit, .DetectorName, .Raw[:40]] | @tsv' \
| sort -u
What to Do When You Find an Exposed Secret
The response sequence matters as much as the detection. The instinct to quietly fix the code and move on is wrong — the secret must be treated as compromised regardless of how long it was exposed or whether you see evidence of misuse.
- Rotate immediately. Generate a new credential and deploy it before doing anything else. Do not wait to assess the scope of exposure. Do not wait for a change window. Rotate first.
- Treat the old credential as fully compromised. Assume an attacker has it and has been using it since it was first committed. This assumption shapes everything that follows.
- Audit access logs. Check API call logs, CloudTrail, VPC flow logs, or whatever audit trail the service provides. Look for activity from unfamiliar IPs, unusual times, or unexpected operations. If the credential had write access anywhere, treat any unfamiliar operations as potential attacker activity and investigate accordingly.
- Rewrite git history. Use
git filter-repo(not the deprecatedgit filter-branch) to remove the secret from all commits. Force-push to the remote. Notify all contributors to re-clone — their local copies still contain the history. - Assess blast radius. What did the credential have access to? Other systems, downstream APIs, cloud resources? Map the access and check each one.
- File an incident report. Even if no unauthorized access is found, the exposure is a security event. Document what was exposed, for how long, what access it had, what was found in the audit, and what was done to remediate. This feeds into future process improvements.
The rotation-first rule is non-negotiable. Rotating a credential that was never compromised costs minutes. Failing to rotate a credential that was actively exploited can cost the organization significantly more.
Secrets Management Best Practices
Detection is a backstop. The real goal is a development workflow where secrets never reach source control in the first place.
Use a Secrets Manager
HashiCorp Vault is the most flexible option for self-hosted environments. It provides dynamic secrets (short-lived credentials generated on demand and automatically revoked), audit logging of every access, fine-grained policies, and integrations with Kubernetes, AWS, and most CI/CD systems. Dynamic secrets are particularly valuable for databases — rather than a shared password that lives forever, each service gets a unique credential valid for hours that Vault automatically revokes.
AWS Secrets Manager and GCP Secret Manager are the natural choice for workloads running in those clouds. They integrate with IAM for access control, support automatic rotation for RDS and other managed services, and emit CloudTrail / Cloud Audit Logs for every access.
Environment Variable Best Practices
- Never hardcode secrets in source files, even in test code. Use environment variables or a secrets manager SDK everywhere.
- Use
.env.examplefiles with placeholder values (not real secrets) to document required variables. Commit the example, never the actual.env. - Scope credentials to their minimum required permissions. An API key that only needs read access should not have write permissions.
- Set expiry on credentials wherever the provider supports it. Short-lived tokens limit the window of exposure.
- Rotate credentials on a schedule, not just after incidents. Quarterly rotation of long-lived secrets is a reasonable baseline.
- Use separate credentials per environment. Production, staging, and development should never share credentials — this limits lateral movement and makes rotation tractable.
CI/CD Secret Injection
Store pipeline secrets in the CI/CD platform's native secret store (GitHub Actions Secrets, GitLab CI Variables, CircleCI Context variables) rather than in repository files. Inject them as environment variables at runtime, not at build time — secrets should not be baked into container images or build artifacts.
# Good: secret injected at runtime from CI secret store
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}
run: ./deploy.sh
# Bad: secret in a committed file that gets copied into the image
COPY .env.production /app/.env
How Web Scanning Surfaces Exposed Secrets in Running Applications
Static analysis of source code and git history catches secrets before deployment. But some exposure only manifests at runtime — in HTTP responses, error pages, and API endpoints that behave differently under specific conditions.
Web application scanners can detect this class of exposure by probing running applications the same way an attacker would:
- Error condition probing: Sending malformed requests, boundary values, and unexpected input types to trigger verbose error responses. Stack traces, exception messages, and environment dumps that appear only on error are invisible to static analysis.
- Exposed debug and admin endpoints: Scanning for known paths like
/actuator,/actuator/env,/_ah/admin,/phpinfo.php,/.env,/.git/config,/api/debug. These endpoints are frequently left exposed in production despite not being linked from the application. - API response analysis: Examining response bodies for patterns matching known secret formats — AWS key prefixes (
AKIA), JWT tokens, private key headers, connection string formats. - JavaScript bundle analysis: Parsing and searching client-side JavaScript for embedded credentials, which are not visible to a backend-only code review.
Ironimo's scanning engine runs these checks as part of its standard scan workflow. When a scan finds a 200 OK response on /.env or a stack trace in a 500 response containing a database connection string, it surfaces that as a finding with the request and response that triggered it — the same evidence a pentester would document in a report.
The distinction from static analysis tooling is runtime context. A .env file that exists on the filesystem but is correctly blocked by the web server produces no finding. A .env file that the web server serves as a static file produces a critical finding with the actual credential values in the response body. That difference only becomes visible when you actually make the HTTP request.
Putting It Together: A Layered Approach
| Layer | Tool | What It Catches |
|---|---|---|
| Pre-commit | Gitleaks protect | Secrets in staged files before commit |
| CI pipeline | TruffleHog + Gitleaks | Secrets in commits and full git history |
| Source code | Semgrep secrets rules | Hardcoded secrets with dataflow context |
| Repository platform | GitHub Secret Scanning + Push Protection | Known token formats, blocks on push |
| Running application | Web app scanner (Ironimo) | Runtime exposure via HTTP responses, error pages, exposed files |
Each layer catches things the others miss. Pre-commit hooks catch secrets before they ever hit the remote. CI gates enforce the policy for anyone who bypasses the hook. Source code scanning adds semantic context. GitHub's native scanning handles the cases your custom tooling doesn't cover yet. And runtime scanning finds the exposure that only exists when the application is actually running and serving HTTP traffic.
The goal is not to make any single layer perfect. It is to make the total cost of a secret slipping through all layers high enough that it becomes a rare incident rather than a routine occurrence.
Ironimo scans your running web application for exposed secrets, credentials, and sensitive data in HTTP responses — including error pages that leak stack traces, exposed .env files, debug endpoints, and API responses containing embedded tokens. It surfaces the findings an attacker would find, with the full HTTP request and response as evidence.