If you pass an npm token, pip index password, or SSH key into docker build with ARG, ENV, or COPY, it can be recovered from the image afterward, even if a later step deletes it. The fix is a BuildKit secret mount (RUN --mount=type=secret), which exposes the secret to one RUN step as a temporary file and never writes it into a layer. Below: how to prove the leak on your own image, the replacement pattern, and the traps that remain.
How do I check whether my image already leaks a secret?
Start with the evidence, not the theory. Take a Dockerfile that looks reasonable:
FROM node:22-slim WORKDIR /app ARG NPM_TOKEN COPY package*.json ./ RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \ && npm ci \ && rm .npmrc COPY . . The author deleted .npmrc in the same step, so it feels safe. Build it and inspect:
docker build --build-arg NPM_TOKEN=npm_fake_example_token -t leaky . docker history --no-trunc leaky | grep -i npm_token The ARG value appears in the recorded command of that layer in the history output, because the expanded RUN instruction is part of the image metadata. Anyone who can pull the image can run docker history or docker inspect on it.
Recent Docker versions also warn at build time with a check named SecretsUsedInArgOrEnv ("Do not use ARG or ENV instructions for sensitive data"). It is name-based, so it catches NPM_TOKEN or PASSWORD but will not catch a secret in a variable called X. Treat it as a smoke alarm, not an audit.
The same applies to COPY .npmrc . followed by RUN rm .npmrc: the COPY layer is a tarball of the file, and deleting it in a later layer only hides it from the merged filesystem. Extract the layers with docker save and the file is there.
Takeaway: deleting a file in a later layer does not remove it from the image; it only masks it.
What does the BuildKit secret mount do differently?
A secret mount makes a file (or env value) available only while one RUN instruction executes. It is mounted on a tmpfs-style path, not committed into the layer, and not recorded as a build argument.
# syntax=docker/dockerfile:1 FROM node:22-slim WORKDIR /app COPY package*.json ./ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ npm ci COPY . . Build by pointing the id at a source on the host:
docker build --secret id=npmrc,src=$HOME/.npmrc -t safe . If the token is in an environment variable instead of a file, BuildKit can read it from the environment of the build command:
export NPM_TOKEN=npm_fake_example_token docker build --secret id=NPM_TOKEN,env=NPM_TOKEN -t safe . RUN --mount=type=secret,id=NPM_TOKEN \ echo "//registry.npmjs.org/:_authToken=$(cat /run/secrets/NPM_TOKEN)" > /tmp/.npmrc \ && NPM_CONFIG_USERCONFIG=/tmp/.npmrc npm ci \ && rm /tmp/.npmrc By default a secret mounted without target appears at /run/secrets/<id>. Note the second form writes a temp file inside the layer filesystem, but it is removed within the same RUN, and the token was never in the instruction text, so neither docker history nor the committed layer contains it. If you can use the first form with a real file at target, prefer it: there is nothing to clean up.
Takeaway: a secret mount keeps the value out of both the instruction text and the layer contents, which is the two places ARG and COPY leak.
Which method leaks what?
| Method | In docker history | In a layer tarball | Survives to final image | Use for secrets? |
|---|---|---|---|---|
ARG + --build-arg | Yes, when used in RUN | Possibly, if written to a file | Metadata yes | No |
ENV | Yes | n/a | Yes, and in running containers | No |
COPY then rm | No | Yes | Masked, recoverable | No |
| Multi-stage, secret only in builder stage | In builder only | In builder only | No, if final stage does not copy it | Acceptable, with caveats |
RUN --mount=type=secret | No | No | No | Yes |
RUN --mount=type=ssh | No | No | No | Yes, for git over SSH |
The multi-stage row deserves the caveat: the final image is clean, but the builder stage's layers still exist in your local build cache, and if you export cache with --cache-to to a registry, those layers can go with it. Which is why "multi-stage" alone is not a secrets strategy.
What about private git dependencies and CI?
For git clone or pip install git+ssh://... from a private repo, use the SSH mount rather than copying a key:
# syntax=docker/dockerfile:1 RUN --mount=type=ssh \ pip install "git+ssh://git@github.com/your-org/private-lib.git" docker build --ssh default -t app . This forwards your local SSH agent for that step only, so no private key lands in a layer. You will also need github.com in known_hosts inside the build, which is the usual first failure: Host key verification failed.
In GitHub Actions with docker/build-push-action, the secrets input takes the same id=value pairs:
- uses: docker/build-push-action@v6 with: context: . push: true tags: ghcr.io/your-org/app:latest secrets: | npmrc=${{ secrets.NPMRC }} As of November 2026 this works with BuildKit-based builds, which is the default builder in current Docker releases. Pin the action to the major version you have tested.
Takeaway: the pattern is identical locally and in CI; only the source of the secret changes.
What still goes wrong after you switch?
- A secret echoed into build output. If a step fails verbosely (
npm ciwith debug logging,pip -vvv), the token can appear in CI logs. Mask it in your CI system and keep verbosity off. - Secrets written to a path the tool caches. Some package managers copy credentials into a cache directory inside the layer. Check with
docker run --rm safe sh -c 'find / -name ".npmrc" 2>/dev/null'after the build. - Old images are already published. Switching Dockerfiles does not un-leak what is in your registry. If an image with a real token was ever pushed, rotate the token first; deleting the tag is secondary.
- Inspecting tools have limits.
diveis useful for browsing layer contents interactively, but it is a manual check and will not scan for credentials. A dedicated secret scanner run against the saved image tarball is a better gate in CI.
If you want automated detection rather than eyeballing layers, TruffleHog is the open-source scanner that can check container images for verified credentials; its limitation is that it finds what looks like a known credential format, so an internal password with no recognizable pattern slips through.
FAQ
Does deleting a file in a later Dockerfile step remove it from the image?
No. Each instruction creates a new layer, and a delete only adds a "whiteout" marker in the new layer. The earlier layer still contains the file and can be extracted from the image.
Is multi-stage build enough to keep secrets out of the final image?
It keeps them out of the final image's layers if the final stage never copies them, but the builder layers remain in your build cache and possibly in exported cache. Use a secret mount so the value never enters a layer at all.
How do I use a secret in docker build without BuildKit?
You can't do it safely with the legacy builder. Enable BuildKit (the default in current Docker Engine and Docker Desktop) and use --secret with RUN --mount=type=secret.
Bottom line
If a credential is needed only to install dependencies during the build, use RUN --mount=type=secret for it, and --mount=type=ssh for private git over SSH. Stop using ARG and ENV for anything you would not paste into a public chat. If an image with a real secret was ever pushed, rotate the secret before you fix the Dockerfile.