DevOps & CI/CD Bug Report

We deleted the npm token in the Dockerfile, and it was still in the image history

A routine image scan flagged a private registry token inside a production image. The Dockerfile passed it as a build argument and removed the .npmrc in a later step, which does nothing for earlier layers or the build history. How we confirmed exposure, rotated, and rebuilt with BuildKit secret mounts.

Leila Farahani Leila Farahani @leilafarahani · November 06, 2026 · 7 min read · 52 Views
Banner: a secret still visible in an earlier Docker image layer

Our registry runs a secret scanner on every pushed image. One morning it flagged an image of the web frontend already running in production:

SECRET  npm-access-token  layer sha256:8c41...  path /app/.npmrc  match "npm_************************3f9a"
SECRET  npm-access-token  image config  history[7].created_by  match "npm_************************3f9a"

Two hits: one in a layer's filesystem, one in the image configuration's build history. The token belonged to an automation account that could read our private npm packages and, it turned out, also publish to two of them.

The Dockerfile looked like it handled the token carefully:

FROM node:22-alpine AS build
ARG NPM_TOKEN
WORKDIR /app
COPY package.json package-lock.json ./
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
RUN npm ci
RUN rm -f .npmrc
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY --from=build /app /app
CMD ["node", "server.js"]

The token was passed with --build-arg NPM_TOKEN=..., written to .npmrc, used by npm ci, and deleted.

Confirming it

The build history shows the commands for each layer, with build arguments expanded:

docker history --no-trunc registry.example.com/web:4.18.2 | grep -i npmrc
<missing>  ...  RUN |1 NPM_TOKEN=npm_Xk...3f9a /bin/sh -c echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc

In this image the multi-stage build should have left that history behind in the build stage, but the final stage did COPY --from=build /app /app, and our CI also pushed the build stage as a cache image to the same registry. That cache image carried the full history.

For the final image, the token was in the filesystem. The rm -f .npmrc ran, but the very next step, COPY . ., copied the whole repository into /app, and the repository contained an .npmrc with the same token, committed weeks earlier by a developer debugging an install problem. .npmrc was not in .dockerignore:

docker run --rm --entrypoint cat registry.example.com/web:4.18.2 /app/.npmrc
//registry.npmjs.org/:_authToken=npm_Xk...3f9a

Layers are additive

Each RUN, COPY and ADD instruction creates a new filesystem layer containing only the changes it made. An image is the stack of all its layers. Deleting a file in a later layer adds a "whiteout" entry that hides the file from the final view, but the earlier layer still contains the file. Anyone who pulls the image can extract the individual layers (docker save, or any registry client) and read it.

So RUN rm -f .npmrc in a separate step does not remove the secret from the image; it only hides it. Creating and deleting it in the same RUN would keep it out of the layer, but not out of the history.

Build arguments are recorded

ARG values used in a RUN instruction are recorded in the image's build history, which is part of the image configuration and readable by anyone who can pull the image. Docker's own documentation warns against using build arguments for secrets for exactly this reason.

Multi-stage builds help, but not automatically

The final stage of a multi-stage build only contains what is copied into it, so layers and history of earlier stages are not in the final image. That protection disappears if you:

  • copy a directory that contains the secret file into the final stage, as we did;
  • push intermediate stages to a registry, for example as a build cache;
  • run the final stage's build with the same ARG and use it in a RUN.

Environment

Item Value
Build Docker 27 with BuildKit, GitHub Actions
Image Node 22 frontend, multi-stage
Exposure token in final image filesystem and in pushed build-stage cache history
Token scope read private packages, publish to 2 packages

1. Treat the token as compromised and rotate it

Anything in an image that has been pushed is exposed to everyone with pull access to that registry, and possibly beyond if images were ever made public or copied. We:

  1. Revoked the token immediately and issued a new one with read-only scope (the automation account never needed publish rights).
  2. Checked the npm audit log for the two publishable packages: no versions had been published by that token other than our own CI releases.
  3. Deleted the affected image tags and the build cache images from the registry, and ran garbage collection.
  4. Listed everyone and everything with pull access to that registry during the exposure window.

Rotation comes first. Removing the image does not undo a copy someone already pulled.

2. Use BuildKit secret mounts

BuildKit can make a secret available to a single RUN instruction as a temporary file that is never written to a layer and never recorded in the history:

# syntax=docker/dockerfile:1
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=secret,id=npmrc,target=/app/.npmrc \
    npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/server.js ./
USER node
CMD ["node", "server.js"]

The build command passes the secret from a file or environment variable:

docker buildx build --secret id=npmrc,src=$HOME/.npmrc -t registry.example.com/web:4.18.3 .

In GitHub Actions with docker/build-push-action:

- uses: docker/build-push-action@v6
  with:
    push: true
    tags: registry.example.com/web:${{ github.sha }}
    secrets: |
      npmrc=//registry.npmjs.org/:_authToken=${{ secrets.NPM_READ_TOKEN }}

The final stage now copies only the build output, the installed dependencies and the server entry point, not the whole working directory.

3. A .dockerignore that excludes credentials

.git
.env*
.npmrc
**/*.pem
**/id_rsa*
node_modules

The build context should never contain credential files, even if no instruction copies them today.

4. Do not push intermediate stages with history

Build caching now uses the registry cache exporter (--cache-to type=registry,mode=max), which stores cache layers built with secret mounts that contain no secrets, instead of pushing the build stage as a normal tagged image.

1. The new image has no token in its history or filesystem.

docker history --no-trunc registry.example.com/web:4.18.3 | grep -ci "authToken\|npm_" || true
docker run --rm --entrypoint sh registry.example.com/web:4.18.3 -c 'ls -la /app/.npmrc 2>&1'
0
ls: /app/.npmrc: No such file or directory

2. Scan every layer, not just the final filesystem. We run a scanner that inspects each layer and the image configuration in CI, before push:

trivy image --scanners secret --exit-code 1 registry.example.com/web:4.18.3

On a test build of the old Dockerfile, the same command failed with both findings; on the new one, it passes.

3. Policy in CI. A Dockerfile lint step (hadolint) fails on ARG names matching *TOKEN*, *SECRET*, *PASSWORD* or *KEY* used in RUN, with a message pointing to secret mounts.

4. The old token is dead. A request with it to the registry now returns 401, and an alert fires if any request uses a revoked token, which would indicate someone had copied it.

5. Security implications. The token's publish rights were the real danger: with them, anyone who extracted it could have pushed a malicious version of an internal package that our other services install. Scoping automation tokens to the minimum (read-only for builds) limits the damage of any future leak, and our private packages now require provenance attestations from our CI for publishing.

Takeaways

  • Deleting a file in a later layer only hides it; earlier layers still contain it.
  • Build arguments are recorded in image history. Never pass secrets with ARG.
  • Use BuildKit --mount=type=secret so secrets exist only during one RUN and never in a layer.
  • If a secret reaches a pushed image, rotate it first, then clean up, and scan every layer before push.

References: Docker build secrets, Dockerfile reference, ARG, Docker multi-stage builds, Trivy secret scanning.

Add a label

#docker #buildkit #supply-chain #secrets-management #container-security
Written by
Leila Farahani @leilafarahani

I lead a small team responsible for observability and quality tooling, and I spend a fair share of my time on application security reviews. That mix means I see many incidents twice: once when they happen and again when we ask why nothing warned us. The posts here come from that second look. An alert that never fired because the metric disappeared, sampling that dropped exactly the traces we needed, a catch-all that reported success for weeks, a secret recoverable from an image layer, tests that passed against mocks while production failed. Each one covers how the gap was found, what we changed in code or configuration, and how we verified the fix with a test or a drill. Security topics stay defensive and practical: controls you can add to your own code and a way to test each one.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.