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.
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
ARGand use it in aRUN.
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:
- Revoked the token immediately and issued a new one with read-only scope (the automation account never needed publish rights).
- Checked the npm audit log for the two publishable packages: no versions had been published by that token other than our own CI releases.
- Deleted the affected image tags and the build cache images from the registry, and ran garbage collection.
- 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=secretso secrets exist only during oneRUNand 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
Embed this post
<iframe src="https://hibug.me/secret-baked-into-docker-image-layer" width="700" height="420" frameborder="0" loading="lazy"></iframe>
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.
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.