Skip to content

Why Secrets Never Live in Code

The single most common way a secret leaks isn't a sophisticated attack — it's a value typed straight into a file that then gets committed, pushed, and built into an image. Understanding exactly why that's permanent, not just risky, is what makes the discipline of keeping secrets out of code stick.

TL;DR — in 30 seconds:

  • Git history keeps every version of every file forever by default — deleting a secret in a later commit does not remove it from the commits before that.
  • A value COPY-ed or baked into a container image layer persists in that layer even if a later layer deletes the file — the earlier layer is still part of the image.
  • The fix isn't "be more careful typing" — it's never letting the real value reach a file that's committed or built in the first place.

1. What "committed to git" actually means

Git doesn't store a file's current state only — it stores every commit as a snapshot, and by default every snapshot is kept forever, reachable by anyone who can clone the repository (or, for a public repo, anyone who cloned or forked it before you noticed).

flowchart LR
    A["Commit 1<br/>secret added"] --> B["Commit 2<br/>secret 'removed'"]
    B --> C["Working tree today<br/>looks clean"]
    A -.->|"still reachable via git log / git show"| D["Anyone with clone access"]
  • Deleting a line and committing that deletion creates a new snapshot — it does not erase the old one.
  • git log, git show <commit>, or simply checking out an old commit all still reveal the original value.
  • Rewriting history (git filter-repo, BFG) can scrub a secret from a repo's history, but that only helps if every clone and fork is also rewritten — in practice, the only reliable fix is to treat the secret as compromised and rotate it (issue a new value, revoke the old one).

2. What "baked into an image" actually means

A container image is a stack of layers, and each instruction in a Dockerfile/Containerfile typically creates one. If a secret is written to a file in one layer and deleted in a later layer, the file is gone from the final filesystem view — but the earlier layer, containing the original file, is still part of the image and can be extracted by anyone who can pull it.

  • COPY .env . followed later by RUN rm .env still leaves the secret recoverable from the intermediate layer.
  • A secret passed as a build ARG can also end up cached in the image's build history/metadata, depending on how it's used — treat build args the same as any other "don't put a secret here" case unless the tooling explicitly documents it as safe (e.g. Docker's BuildKit --secret mount, which is designed not to persist in a layer).
  • The safe pattern: never write the secret into the image at all — inject it at runtime (an environment variable, a mounted file, a fetch from a secret store) so it never becomes part of any layer.

3. The blast radius of "just this once"

A secret typed into a file — a .env committed "temporarily," a password hardcoded "just for local testing" — has a way of outliving the intention behind it: the commit gets pushed before anyone remembers, the image gets built and pushed to a registry, or the file gets copied into a second project. Each of those is a separate place the secret now has to be found and rotated, not just one.

Common gotchas

  • Believing .gitignore protects a file that's already committed. Fix: .gitignore only prevents git from tracking a file going forward — it has no effect on a file that's already in history. Add secrets to .gitignore before the first commit that would include them.
  • Assuming a private repo makes committed secrets safe. Fix: "private" controls who can currently clone it, not who has already cloned it, forked it, or will gain access later (a new hire, a contractor, an acquisition). Treat a committed secret as exposed regardless of repo visibility.
  • Deleting the file and considering the incident closed. Fix: the only action that actually closes the exposure is rotating the secret — issuing a new value and revoking the old one — because the old value remains readable in history or in an already-built image.
Check yourself — a Dockerfile writes a secret to a file, then a later RUN rm deletes it before the final COPY. Is the secret in the final image?

Yes, if the write and the delete are in different layers (different RUN/COPY instructions) — the layer containing the original write still exists in the image history and can be extracted, even though the final filesystem view (after all layers are applied) shows the file as gone.

Check yourself — what's the one action that actually closes a secret-in-git-history exposure?

Rotating the secret (issuing a new value and revoking the old one). Removing it from the latest commit, or even rewriting history, doesn't undo the fact that the original value was already readable by anyone with access at the time — only a new value closes the exposure going forward.

You can defend this when you can explain, for both git history and image layers, exactly why deleting a secret later doesn't undo the exposure — and why rotation, not deletion, is the fix.