Introduction
In 2016, two attackers obtained GitHub credentials for Uber engineers through means unrelated to Uber's own codebase. Using those credentials, they accessed a private Uber repository and found hardcoded AWS access keys committed directly in the source code. Those keys granted access to an S3 bucket containing personal data for 57 million riders and drivers. The attackers demanded money to stay quiet. Uber paid $100,000 through its bug bounty program. The FTC's subsequent complaint detailed how a single committed credential, in a repository that was private at the time, enabled the entire breach.
The pattern repeats constantly: not because engineers are careless, but because the gap between where a secret lives during development and where it ends up on a production asset is easy to miss. An AWS key in a webpack bundle gets shipped to every browser that loads your homepage. A password in a source map is recoverable from a file your web server serves by default. A CI/CD log that prints environment variables for debugging is public on most platforms for public repositories. None of these require an attacker to breach your infrastructure. They require a browser, a terminal, and patience.
This post covers exactly where credentials end up on public-facing assets, how automated scanners and attackers find them, what each credential type looks like in the wild, and how to detect and close the exposure before someone else does.
How secrets end up on public assets
The root cause is rarely someone deliberately committing a secret. It is the gap between where secrets are handled during development and what ends up in the deployed artifact.
- Client-side JavaScript bundles — Build tools like webpack, Vite, and Create React App bundle environment variables into the JavaScript shipped to every visitor if those variables are referenced in client-side code. A developer who writes process.env.STRIPE_SECRET_KEY in a React component, intending to use the publishable key, ships the secret key to every page load. The bundle is fully readable by anyone who opens browser developer tools.
- Source maps — A .js.map file maps minified production code back to the original unminified source, including comments and any values hardcoded during development. Source maps are regularly deployed to production for debugging convenience and left publicly accessible with no access control. A credential that was hardcoded temporarily and later removed from the current codebase can still appear verbatim in the source map.
- Exposed .git directories — When a web server's document root is the same directory as the deployed application's Git repository, every file in .git/ becomes publicly accessible. An attacker who confirms .git/HEAD returns a 200 can reconstruct the full repository, including every commit ever made, using tools like git-dumper. A secret deleted in commit 47 of 200 remains fully accessible in commit 46.
- Public repository history — Git history creates a permanent record the moment a secret is pushed. Git history is not modified by deleting a file in a new commit. The secret remains in every previous commit and is retrievable by anyone who clones the repository. GitHub's secret scanning catches many common credential formats automatically, but custom tokens, internal API keys, and less common credential patterns are not always covered.
- API documentation with real keys as examples — Swagger and OpenAPI documentation generators sometimes pull example values from environment variables used during documentation generation, or a developer copies a real API key into example request documentation intending to replace it with a placeholder later. Published API documentation pages are public by design, making any real credential included as an example immediately accessible to anyone who reads the docs.
- CI/CD build logs — Build logs that print environment variables for debugging purposes expose those variables to anyone who can view the build log. On most CI/CD platforms, build logs for public repositories are publicly visible by default, including for pull requests submitted by external contributors. This was the mechanism in the Travis CI September 2021 incident, covered in the real-world section below.
What the vulnerable configuration looks like
Two configurations account for the majority of credential exposures at the infrastructure level. The first is a secret key referenced in client-side code. The second is a .git directory left accessible from the web root.
-
Vulnerable: secret key bundled into client-side JavaScript — Next.js prefixes environment variables with NEXT_PUBLIC_ to indicate they are safe for client-side use. Variables without this prefix stay server-side. When a developer accidentally references a non-prefixed variable in client component code, the framework bundles it into the JavaScript served to every visitor.
// .env STRIPE_SECRET_KEY=sk_live_51H8xAb... NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_51H8xAb... // client-side component - VULNERABLE // STRIPE_SECRET_KEY has no NEXT_PUBLIC_ prefix but gets referenced in client code const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // What ships in the production bundle (readable at /static/chunks/main.js) // var stripeKey = "sk_live_51H8xAb..."; -
Secure: secret stays server-only, publishable key goes to the client — Secret keys are only referenced in server-side code and never imported into client bundles. Publishable keys, which are safe to expose by design, use the NEXT_PUBLIC_ naming convention.
// pages/api/charge.js - server-side only, never bundled import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // client-side component - SECURE // Only the NEXT_PUBLIC_ prefixed key is used here const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY); -
Vulnerable: .git directory accessible from web root — Web server document root configured to the same directory as the deployed application's Git repository, with no rule blocking access to dot-prefixed directories.
# nginx - VULNERABLE server { listen 80; server_name example.com; root /var/www/example.com; # Same directory as the git repo # No rule blocking .git access } # Confirmation the exposure exists $ curl -I https://example.com/.git/HEAD HTTP/1.1 200 OK -
Secure: dot-files blocked at the web server level — An explicit deny rule for any path beginning with a dot prevents access to .git, .env, and similar sensitive directories regardless of where they sit relative to the document root.
# nginx - SECURE server { listen 80; server_name example.com; root /var/www/example.com; location ~ /\. { deny all; return 404; } } # Verification after fix $ curl -I https://example.com/.git/HEAD HTTP/1.1 404 Not Found
Credential fingerprints: what each type looks like
Most credential types have a recognizable prefix or format that makes automated detection reliable without complex heuristics.
-
AWS Access Key ID — AWS access key IDs follow a consistent prefix and length, making them one of the most reliably detectable credential types. The corresponding secret access key has no recognizable prefix and requires entropy-based detection or proximity matching to the access key ID in the same file.
; AWS Access Key ID pattern AKIA[0-9A-Z]{16} ; grep for AWS keys in a codebase grep -rE 'AKIA[0-9A-Z]{16}' /path/to/codebase/ -
Stripe API keys — Stripe keys are distinguishable by prefix between live and test environments, and between secret and publishable keys. The sk_live_ prefix indicates a live secret key with full account access. The pk_live_ prefix is the publishable key, safe to expose client-side by design. Confusing sk_live_ for pk_live_ is the specific Stripe-related exposure pattern that keeps appearing in JavaScript bundles.
; Stripe live secret key - never safe to expose sk_live_[0-9a-zA-Z]{24,} ; Stripe live publishable key - safe by design pk_live_[0-9a-zA-Z]{24,} ; Stripe test secret key - lower risk but still should not be public sk_test_[0-9a-zA-Z]{24,} -
Google API key — Google API keys follow a consistent prefix and length. The risk depends on which Google APIs the key has been enabled for. An unrestricted key can incur billing charges or access sensitive Google Cloud services.
; Google API key pattern AIza[0-9A-Za-z\-_]{35} -
GitHub personal access tokens — GitHub's newer token formats include a prefix indicating the token type, which simplifies both detection and GitHub's own automated revocation of leaked tokens.
; GitHub personal access token (classic) ghp_[A-Za-z0-9]{36} ; GitHub fine-grained personal access token github_pat_[A-Za-z0-9_]{82} -
Slack tokens — Slack tokens use a prefix indicating the token category (bot, user, app-level), each with different permission scopes and risk levels.
; Slack bot token xoxb-[0-9]+-[0-9]+-[0-9a-zA-Z]+ ; Slack user token xoxp-[0-9]+-[0-9]+-[0-9]+-[0-9a-zA-Z]+ -
JWTs (JSON Web Tokens) — JWTs are base64-encoded and begin with a recognizable header pattern. A long-lived or non-expiring JWT found in a public asset can represent persistent unauthorized access if the signing key is compromised, or simply grant the bearer whatever access the token encodes.
; JWT pattern (three base64 segments separated by periods) eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+ ; Decode a found JWT to inspect its claims without the signing key echo 'eyJhbGc...' | cut -d. -f2 | base64 -d 2>/dev/null | jq .
How an attacker exploits this (step by step)
Credential discovery from public assets follows a consistent pipeline regardless of whether the attacker is targeting a specific organization or running broad automated sweeps.
-
Step 1: crawl JavaScript files across all discovered subdomains — After subdomain enumeration, every JavaScript file served by live hosts gets fetched and scanned for credential patterns using regex extraction.
; Collect all JS file URLs from live subdomains cat live_subdomains.txt | httpx -silent -mc 200 | \ while read url; do curl -s "$url" | grep -oE 'src="[^"]*\.js[^"]*"' | \ sed 's/src="//;s/"//' done | sort -u > js_files.txt ; Scan each file for credential patterns while read jsfile; do result=$(curl -s "$jsfile" | grep -oE \ 'AKIA[0-9A-Z]{16}|sk_live_[0-9a-zA-Z]{24,}|AIza[0-9A-Za-z_-]{35}|ghp_[A-Za-z0-9]{36}') if [ -n "$result" ]; then echo "$jsfile: $result" fi done < js_files.txt -
Step 2: probe for exposed .git directories on every discovered host — A single httpx probe across the full subdomain list identifies any host returning a 200 on /.git/HEAD, confirming a vulnerable exposure.
cat live_subdomains.txt | httpx -silent -path /.git/HEAD -mc 200 -o exposed_git.txt ; For confirmed exposures, dump the full repository git-dumper https://vulnerable.example.com/.git/ ./dumped_repo ; Search the full commit history for credentials cd dumped_repo git log --all -p | grep -E 'AKIA[0-9A-Z]{16}|password|api_key|secret' -
Step 3: run automated secret scanning with trufflehog — trufflehog combines regex matching for known credential formats with entropy analysis for unlabeled secrets. The --only-verified flag actively tests each found credential against its issuing API before reporting it, reducing false positives significantly.
; Scan a repository including full git history trufflehog git file://./dumped_repo --only-verified ; Scan a live public repository directly trufflehog github --repo https://github.com/target-org/target-repo --only-verified -
Step 4: validate the credential and enumerate access — A found AWS key takes 10 seconds to validate. If it is active, the attacker knows the identity it belongs to and starts enumerating what it can access.
export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE export AWS_SECRET_ACCESS_KEY=the_found_secret_key ; Confirm the key is valid and see what identity it maps to aws sts get-caller-identity ; If valid, enumerate accessible resources aws s3 ls aws iam list-attached-user-policies \ --user-name $(aws iam get-user --query 'User.UserName' --output text)
How to detect it in your environment
Detection requires checking three surfaces: your live JavaScript assets, your version control history, and your CI/CD build log visibility.
-
Scan your own live JavaScript files — Run the same JS crawling and regex extraction pipeline against your own confirmed subdomain list to find what is currently shipping to your users' browsers.
cat your_subdomains.txt | httpx -silent -mc 200 | \ while read url; do curl -s "$url" | grep -oE 'src="[^"]*\.js[^"]*"' | sed 's/src="//;s/"//' done | sort -u > your_js_files.txt while read jsfile; do curl -s "$jsfile" | grep -oE \ 'AKIA[0-9A-Z]{16}|sk_live_[0-9a-zA-Z]{24,}|AIza[0-9A-Za-z_-]{35}' done < your_js_files.txt -
Scan your Git history with gitleaks — gitleaks is purpose-built for scanning version control history, including commits that have since been amended or files that were later deleted. Run it as both a local audit tool and a pre-merge CI check.
; Scan the full history of the current repository gitleaks detect --source . --verbose ; Run as a pre-merge check in CI (exits 1 if secrets found) gitleaks detect --source . --exit-code 1 ; Create a baseline to track only new findings going forward gitleaks detect --source . --report-path gitleaks-baseline.json -
Check for exposed .git directories across all your subdomains — Run this against every confirmed live subdomain. Any result requires immediate action.
cat your_subdomains.txt | httpx -silent -path /.git/HEAD -mc 200 ; Any 200 response here is a confirmed exposure -
Review CI/CD build log visibility — Check whether your CI/CD platform makes build logs public for public repositories, then audit recent logs for any step that might print environment variables.
; Search recent GitHub Actions logs for accidental env var output gh run list --limit 50 --json databaseId -q '.[].databaseId' | \ while read id; do gh run view $id --log | grep -iE 'AKIA|sk_live|secret|password' done
Remediation: rotate first, investigate second
The remediation sequence for a credential exposure differs from most security findings. Rotation has to happen before anything else, because removing the credential from its source does not invalidate it or undo the exposure that already occurred. A credential that remains valid continues to be exploitable for as long as it stays active, regardless of whether it has been removed from the codebase.
-
Step 1: rotate the credential immediately through the issuing service — Generate a new credential and revoke the exposed one through the issuing service's console or API. This is the only action that stops the exposure from being exploitable.
; AWS: deactivate and delete the exposed key aws iam update-access-key \ --access-key-id AKIAIOSFODNN7EXAMPLE \ --status Inactive \ --user-name affected-user aws iam delete-access-key \ --access-key-id AKIAIOSFODNN7EXAMPLE \ --user-name affected-user ; GitHub: revoke a personal access token ; Settings > Developer settings > Personal access tokens > Delete ; Stripe: roll the API key ; Dashboard > Developers > API keys > Roll key -
Step 2: remove the credential from the exposed source and its git history — For a current codebase file, remove the secret and replace it with a reference to a secrets manager or environment variable. For Git history, git-filter-repo rewrites history to remove the file entirely. This does not undo the exposure but prevents future access from the repository.
; Remove a file from the entire Git history ; Coordinate with your team first, this rewrites all branch history git filter-repo --path path/to/file/with/secret --invert-paths ; Force push the rewritten history git push origin --force --all git push origin --force --tags -
Step 3: audit the exposure window in audit logs — Check the issuing service's audit logs for any activity from the compromised credential during the period between first exposure and revocation.
; AWS CloudTrail: find all API calls made with the compromised key aws cloudtrail lookup-events \ --lookup-attributes AttributeKey=AccessKeyId,AttributeValue=AKIAIOSFODNN7EXAMPLE \ --start-time 2026-01-01T00:00:00Z \ --end-time 2026-06-18T00:00:00Z -
Step 4: fix the structural cause to prevent recurrence — Add pre-commit secret scanning, fix the build configuration that allowed the credential to reach client-side code, and ensure .env files are excluded from version control.
; Add gitleaks as a pre-commit hook ; .pre-commit-config.yaml repos: - repo: https://github.com/gitleaks/gitleaks rev: v8.18.0 hooks: - id: gitleaks ; Exclude .env files from version control echo '.env' >> .gitignore echo '.env.local' >> .gitignore echo '*.env' >> .gitignore
Real-world incidents
The 2016 Uber breach, disclosed publicly in 2017, traced back to hardcoded AWS credentials in a private GitHub repository. Two attackers gained access to the repository using credentials obtained separately, found the AWS keys hardcoded in the source, and used them to access an S3 bucket containing personal data for 57 million Uber users and drivers, including 600,000 driver license numbers. Uber paid the attackers $100,000 through its bug bounty program in an attempt to have them delete the data and stay quiet, a decision that led to significant regulatory consequences once the full circumstances became public. The FTC complaint specifically cited the hardcoded credentials as the technical root cause.
In September 2021, Travis CI disclosed that environment variables, including credentials, were exposed in build logs for public repositories through pull requests submitted by external unauthorized users. Travis CI's handling of forked pull request builds allowed the forked build context to access the parent repository's secrets, and because build logs for public repositories were visible by default, those secrets were accessible to anyone who viewed the build output. Travis CI's own security bulletin recommended immediate credential rotation for all affected users. The incident was a platform-level failure rather than an individual misconfiguration: every organization using Travis CI with public repositories and secrets exposed to PR builds was affected regardless of their own configuration practices.
Both incidents confirm the same structural pattern: the credential is the entire attack surface. No CVE, no exploit technique, and no sophisticated tooling was required beyond finding the exposed value and using it. This is why detection, finding the exposure before an attacker does, is the highest-leverage control in this category.
Key takeaways
- Rotation comes before investigation. A credential found in your own audit needs to be revoked through the issuing service immediately. Removing it from the codebase does not invalidate it. Only revocation stops the exposure from being exploitable.
- Deleting a file in a new Git commit does not remove it from history. Every previous commit retains the full file. git-filter-repo is required to actually rewrite the repository's history and remove the secret from the record.
- Most credential types are machine-detectable through prefix patterns: AKIA for AWS, sk_live_ for Stripe, AIza for Google, ghp_ for GitHub PATs. Build regex-based detection around these patterns across every JavaScript bundle your application serves.
- Source maps are a commonly overlooked exposure vector. A credential removed from minified production code may still appear verbatim in the .js.map file served alongside it.
- CI/CD build logs are public on most platforms for public repositories. Any workflow step that prints environment variables for debugging broadcasts those variables to anyone who views the build output.
Frequently asked questions
- If I delete the file and push a new commit, is the secret gone from GitHub?
- No. Git history is permanent by default. The secret is still present in every commit made before the deletion. Anyone who clones the repository and runs git log --all -p can recover the file contents from history. Rotating the credential is the only immediate fix. Rewriting history with git-filter-repo removes the secret from the repository's record, but it does not undo exposure that occurred while the commit was accessible.
- Does making the GitHub repository private protect a leaked credential?
- It limits future exposure but does not undo what already happened. Any service that indexed the repository while it was public, including code search engines and automated scrapers that continuously monitor public repositories, may have already captured the credential. Treat any credential that was ever in a public repository as compromised and rotate it, regardless of the repository's current visibility.
- Are example API keys in documentation actually a risk?
- Only if they are real, valid credentials rather than genuine placeholders. Documentation generators that pull live values from environment variables during generation, or developers who copy a real key intending to replace it with a placeholder and forget, both produce real credentials in publicly accessible docs. Test any key found in documentation with aws sts get-caller-identity or equivalent before assuming it is a safe example.
- How do I know if the credential was actually used by an attacker?
- Check the issuing service's audit logs for the period between the first possible exposure and when the credential was rotated. AWS CloudTrail logs every API call made with a given access key including the source IP. Stripe's dashboard shows API request history per key. Unusual source IPs or access patterns during the exposure window are a confirmed indicator of unauthorized use and should trigger your incident response process.
Find credential exposure across your attack surface
ExternalSight scans every discovered subdomain's JavaScript files and exposed configuration paths for known credential patterns across all monitored domains. When a new deployment introduces a bundled secret or a newly discovered subdomain returns a .git/HEAD 200, it surfaces in the same scan cycle, not in the next quarterly review.