---
title: "Preventing secret leaks in Docker Images"
description: "How secrets end up baked into Docker images through COPY and build arguments, and how multi-stage builds and BuildKit secret mounts keep them out."
date: 2025-04-05
tags: ["Docker","NPM","Secrets","Dive"]
url: https://adampie.dev/preventing-secret-leaks-in-docker-images/
---

# Preventing secret leaks in Docker Images

Over the years, I've seen multiple teams get caught out by their approach to using Docker Images. There are quite a few quirks to dockerfiles that don’t entirely make sense initially, especially around layers and how you can chain certain things together to build more efficient images. This does occasionally also mean that secrets end up in the built images, which is not great.

## COPY

The primary culprit of things existing in Docker Images that have no business being there is the use of `COPY . /app`. It’s less of an issue with multi-stage builds, but from what I’ve seen around Python and Node images, people seem to use a single stage. Without a `.dockerignore` file, many hidden files and directories will be copied to the image; which can lead to things like `.env` and `.git` being accessible if the app or web server have been mis-configured. 

Instead, always specify exactly which files and folders to copy in your Dockerfile.

## SECRETS

In the Node related images, I found teams were using internal NPM packages which require authentication to pull them down. We store the NPM token in CI as a global variable and pass it to the Dockerfile as a build argument. 

An example of what that command looks like is: `docker build -t base -f Dockerfile --build-arg NPM_TOKEN=$NPM_TOKEN .`

Then, in the dockerfile, the argument (line 2) is used to set the environment variable (line 3).

```dockerfile 
FROM node:alpine
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN"
RUN npm whoami
COPY package.json package-lock.json /app/
WORKDIR /app
RUN npm install
```

When you build it locally, I’ve noticed Docker outputs warnings for this, but that was not always the case.

>  2 warnings found (use docker --debug to expand):
> - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN") (line 5)
> - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "NPM_TOKEN") (line 6) 

Using a tool like dive, we can look into the built docker image and see what happened in each layer. On the left-hand side, we can see that the RUN command has used the `NPM_TOKEN` environment. On the right-hand side, we can see that the `npm set` command created a file under `/root/.npmrc` which we can also extract out of the layer to retrieve the token.

![Dive base](dive-base.png)

## FIX

There are a few options for preventing leaking these secrets, both as environment variables and as files.

### Multi-stage builds

Multi-stage builds offer a better way to prevent accidental inclusion of unwanted files and folders in the image.  

`docker build -t multistage -f Dockerfile.multistage --build-arg NPM_TOKEN=npm_XXXXX .`

The first half of the dockerfile is exactly the same as the original one. The difference is that we have a second stage we copy the contents of `/app/node_modules` from the previous stage into the new one.

```dockerfile
FROM node:alpine AS builder
ARG NPM_TOKEN
ENV NPM_TOKEN=$NPM_TOKEN
RUN npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN"
RUN npm whoami
COPY package.json package-lock.json /app/
WORKDIR /app
RUN npm install

FROM node:alpine AS runner
COPY --from=builder /app/node_modules /app/node_modules
WORKDIR /app
```

Though Docker also outputs warnings for this, as we are still using ARGs and ENVs for sensitive data.

> 2 warnings found (use docker --debug to expand):
> - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "NPM_TOKEN") (line 4)
> - SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "NPM_TOKEN") (line 5)

When looking through dive again, we can see that we are no longer leaking the `NPM_TOKEN` environment variable and the `/root/.npmrc`.

![Dive multi-stage](dive-multistage.png)

### Mounting a secret as an environment variable

A better solution is to use secret mounts. This allows you to pass sensitive information into your images without the risk of having it persist there.

For the environment variable approach, we can use the `--secret` flag and use the `env=` option to specify what to pass in.

`export NPM_TOKEN=npm_XXXXX`

`docker build -t secret -f Dockerfile.secret --secret id=npm,env=NPM_TOKEN .`

In our Dockerfile, we can then use `--mount=type=secret,` as part of the `RUN` command to safely pass the secret in. As we're doing `npm set`, to not have it write the `.npmrc` file were chaining a `rm` command; there might be a better way to do this though I haven't looked into it.

```dockerfile
FROM node:alpine
RUN --mount=type=secret,id=npm,env=NPM_TOKEN npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN" && npm whoami && rm -rf /root/.npmrc
COPY package.json package-lock.json /app/
WORKDIR /app
RUN --mount=type=secret,id=npm,env=NPM_TOKEN npm set "//registry.npmjs.org/:_authToken=$NPM_TOKEN" && npm install && rm -rf /root/.npmrc

```

If we look at dive, we can see that the `NPM_TOKEN` does not show up in the build layers nor do we see anything out of the ordinary in the layer contents on the right. 

![Dive secret](dive-secret.png)

### Mounting a secret as a file

In this scenario, using NPM, the simplest solution is to mount the secret as a file. Each of the previous Dockerfiles included `npm set`, which created a `.npmrc` file. But we can create the file outside of the docker build and then use the `--mount=type-secret,` option again. 

`echo "//registry.npmjs.org/:_authToken=npm_XXXXX" > .npmrc`

`docker build -t secretfile -f Dockerfile.secretfile --secret id=npm,src=.npmrc .`

This time we use `src=` in the flag option and then in the dockerfile we can specify a target of where to mount that file. In this example dockerfile, which runs as root, we mount the local `.npmrc` file to `/root/.npmrc` during the RUN step.

It also means we now run the NPM commands, such as `whoami` or `install` without having to configure the registry and credentials with an additional command.

```dockerfile
FROM node:alpine
RUN --mount=type=secret,id=npm,target=/root/.npmrc npm whoami
COPY package.json package-lock.json /app/
WORKDIR /app
RUN --mount=type=secret,id=npm,target=/root/.npmrc npm install
```

Similar to the secret environment option, we don't see the secret mounted in the layer contents when looking through dive.

![Dive secretfile](dive-secretfile.png)
