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:

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.

  1. 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.
  2. 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.
  3. 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.
  4. Rewrite git history. Use git filter-repo (not the deprecated git 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.
  5. Assess blast radius. What did the credential have access to? Other systems, downstream APIs, cloud resources? Map the access and check each one.
  6. 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

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:

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.

Start free scan
← Back to Blog