skip to content

A Dockerfile receives a private registry token through `ARG NPM_TOKEN` and CI passes it with `docker build --build-arg NPM_TOKEN=...`. Explain why that token can still be recovered from the published image, and how someone would extract it.

level: juniorimportance: must knowfreq 64%

answer

  1. docker history --no-trunc shows expanded RUN with args
  2. ENV lands in Config.Env forever, inherited downstream
  3. later rm = whiteout, earlier layer still has bytes
  4. docker save | tar = full recovery
  5. ARG is for versions, not credentials

basics

~20 s

Build arguments are recorded in the image's build history, and any ENV keeps the value in image config. Anyone who pulls the image can read them with docker history or docker inspect, or by unpacking the saved image — and deleting a file later does not remove earlier layers.

solid answer

~50 s

Two leaks. First, the **history**: each instruction is stored in the image config, and a `RUN` executed with a build arg records the expanded command line, so `docker history --no-trunc` shows the token. Second, **ENV**: if the value is promoted to an environment variable it lives in `Config.Env` forever — visible in `docker inspect`, inherited by every process in every container from that image, and inherited by any image built `FROM` it. There is a third, more general point: layers are immutable. If the secret was written to a file, a later `RUN rm` only adds a whiteout entry; the earlier layer still contains the bytes, and `docker save` plus `tar` recovers them. The fix is to keep the value out of layers and config: use BuildKit's `RUN --mount=type=secret`, or fetch a short-lived token in a builder stage that is discarded. And once it has been pushed, rotate it.

code

dockerfile · 6 lines
dockerfile
FROM node:22-alpine
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc \
 && npm ci \
 && rm .npmrc

go deeper

for a junior

Recall that build args and ENV end up in image metadata and can be read with docker history and docker inspect after a pull.

for a middle

Explain layer immutability and whiteouts, distinguish history leakage from Config.Env leakage, and name BuildKit secret mounts as the fix.

for a senior

Add detection at scale — scanning history and layers in CI — plus rotation and the short-lived-token alternative.

for a principal

Treat it as a control problem: prevent long-lived credentials from ever reaching builders, and enforce it with pipeline policy rather than Dockerfile review.

## Where the value actually goes An image is a stack of read-only layers plus a JSON **config** describing how it was built and how to run it. Two parts of that config carry secrets by accident. **Build history.** Every instruction produces a history entry. For a `RUN` executed with build arguments in scope, the recorded `created_by` string includes the arg assignments, e.g. `|1 NPM_TOKEN=npm_live_xxx /bin/sh -c npm ci`. It is metadata, so it survives even if the file the token was written to is deleted, and it travels with the image to the registry. `docker history --no-trunc <image>` prints it, as does any registry-browsing tool, without ever running the image. **Config.Env.** `ENV NPM_TOKEN=...`, or the pattern `ARG NPM_TOKEN` followed by `ENV NPM_TOKEN=$NPM_TOKEN`, stores the value in the image's environment block. `docker inspect` shows it, every process in a container inherits it, and any downstream `FROM` image inherits it too. ## Immutability makes cleanup impossible Layers are content-addressed and immutable. `RUN echo $TOKEN > /root/.npmrc && npm ci && rm /root/.npmrc` in one instruction does keep the file out of the final layer — but the *history entry* still contains the token, and if the deletion happens in a *later* instruction the file remains fully readable in the earlier layer, since the deletion is only a whiteout marker in the newer layer. Anyone can `docker save image | tar -x` and read the layer tarballs directly. ## How an attacker (or auditor) extracts it - `docker history --no-trunc image` — the build command lines. - `docker inspect -f '{{json .Config.Env}}' image` — environment values. - `docker save image -o img.tar`, then unpack and grep the config JSON and every layer tarball. - Layer-browsing tools do the same straight from the registry, without running the container. None of this requires privilege beyond the ability to pull the image — which, for a shared internal registry, means everyone. ## What to do instead Use **BuildKit secret mounts**, which expose the value as a file only during one `RUN` and never write it to a layer or the history. Where a secret is needed only to fetch dependencies, a **multi-stage build** is an alternative: use the credential in a builder stage and `COPY` only artifacts into the final stage — but be careful, because that only holds while the builder stage is genuinely discarded, and any tooling that exports intermediate stages or caches breaks the assumption. Best of all, avoid a long-lived secret entirely: have CI exchange its workload identity for a short-lived registry token. ## And rotate Once a credential has been in a pushed image, treat it as disclosed. Rebuilding cleanly does not un-publish the old digest, so the only reliable remediation is revoking and reissuing the credential. ## Note on legitimate ARG use `ARG` is not evil — it is the right tool for non-sensitive build parameters such as a base image tag, version number or build date. The rule is simply that its values are public metadata, so nothing confidential goes through it.

  • If the secret is only used in an earlier stage of a multi-stage build and never copied forward, is the final image safe?
    Usually yes for the published image, because only the final stage's layers and history are exported and the builder stage stays in the build cache. But the risk moves rather than disappearing: the shared build cache, exported cache backends, and any workflow that pushes intermediate stages can expose it. Treat multi-stage as an improvement, not as a secret-management mechanism.
  • Does deleting the file in the same RUN instruction fully solve the problem?
    It keeps the file out of the filesystem layers, which is genuinely better than deleting it in a later instruction. It does not remove the value from the build history when the secret arrived as a build argument, and it does nothing if the value was also set as ENV. The complete fix is a BuildKit secret mount, so the value never enters layers or metadata at all.

saying these in an interview costs you the question

  • Believing a later RUN rm removes the secret from the image
  • Thinking --build-arg is safer than ENV because it is 'build-time only'
  • Assuming only the running container's environment is at risk, not the pulled image
  • Fixing the Dockerfile and skipping credential rotation

context