Skip to content

Remove DNS provider credentials from disk after certbot runs - #5814

Open
shawnhank wants to merge 2 commits into
NginxProxyManager:developfrom
shawnhank:fix/dns-credentials-lifetime
Open

Remove DNS provider credentials from disk after certbot runs#5814
shawnhank wants to merge 2 commits into
NginxProxyManager:developfrom
shawnhank:fix/dns-credentials-lifetime

Conversation

@shawnhank

@shawnhank shawnhank commented Aug 28, 2026

Copy link
Copy Markdown

The problem

I run NPM at home with 14 proxy hosts, all on Let's Encrypt via the Cloudflare DNS-01
challenge. None of those services are publicly reachable, so HTTP-01 isn't an option —
DNS-01 is the only way to get certificates for them.

I hit this while opening a single internal service to the internet through a Cloudflare
Tunnel. Putting one thing on the public side made me go through what an attacker would
actually get if that host were compromised, rather than what I assumed they'd get. Working
down that list I got to the DNS-01 credential and went looking for where it lives:

/etc/letsencrypt/credentials/
-rw------- 1 root root 88 Jan 26  2026 credentials-10
-rw------- 1 root root 88 Jun 19 18:37 credentials-17
-rw------- 1 root root 65 Jun 19 21:27 credentials-18
...  14 files, one per certificate

Fourteen files, each holding a live Cloudflare API token in plaintext, the oldest from
January. Permissions are right — 0600, root-owned — but they are permanent.

In requestLetsEncryptSslWithDnsChallenge, cleanup only runs when certbot fails:

} catch (err) {
    // Don't fail if file does not exist, so no need for action in the callback
    fs.unlink(credentialsLocation, () => {});
    throw err;
}

The success path returns without unlinking, so the file survives for the life of the
certificate. The only other cleanup is in the revoke path, which runs when a certificate
is deleted.

This matters more than "a secret is on disk." Anything running as root — a compromised
process, a script, malware — can read that token and use it to issue valid Let's
Encrypt certificates for the domain. Those certificates are genuinely trusted, so traffic
presented with them passes TLS inspection, IDS/IPS and DLP that would otherwise flag it.
An exfiltration path built on them looks like ordinary HTTPS. With a Cloudflare
Zone:DNS:Edit token the same holder can also repoint every hostname in the zone.

Why the one-line fix doesn't work

Moving that unlink into a finally breaks renewals. certbot records the credentials
path in its renewal config at issuance:

# /etc/letsencrypt/renewal/npm-27.conf
authenticator = dns-cloudflare
dns_cloudflare_credentials = /etc/letsencrypt/credentials/credentials-27

and renewLetsEncryptSslWithDnsChallenge shells out to certbot renew, which reads that
path back. Delete the file and every DNS-01 renewal fails — silently, until certificates
start expiring.

What this changes

The renew path now writes the credentials file immediately before invoking certbot, and
both paths remove it in a finally. The file exists for the duration of a certbot run
instead of permanently.

There was a third writer. setupCertbotPlugins() in backend/setup.js wrote a credentials
file for every DNS-01 certificate on every backend start (flag: "wx", so it only filled
in missing ones). That existed precisely because the renew path did not write the file itself
— something had to put it back before certbot renew went looking. Now that renew writes its
own, that boot-time write is the only thing restoring credentials to disk, and it would undo
the cleanup on the next container restart. Removing it leaves one writer, immediately before
use. The fs import and promises array become unused; the "Added Certbot plugins" log line
is kept, now gated on plugins.length rather than on a promise array that only ever held
credential writes.

One wrinkle worth explaining: the credentials aren't on the certificate object the renew
function receives. renew() sources it from internalCertificate.get(), which pipes the
row through utils.omitRow(omissions()) — and omissions() lists
meta.dns_provider_credentials precisely so it can't travel out over the API. The renew
path therefore reads the row from the model directly.

What it does not fix

The token still lives in the certificates table. It has to — otherwise there'd be
nothing to write the file from. Anyone with a backup of NPM's data volume still has it.
This narrows one of the two copies, not both.

Behaviour changes worth flagging

  • Running certbot renew by hand inside the container will no longer work for DNS-01
    certificates, since the credentials file won't be sitting there between runs. Renewals
    need to go through NPM, which is already how the scheduler drives them.
  • Certificates issued before this change keep their existing file; the renew path
    overwrites it with the stored value. If there is no stored value, it logs a warning and
    leaves whatever is on disk alone, so an older certificate can't be broken by this.
  • No schema, API, or config changes.

Testing

Validated on a throwaway NPM 2.15.1 container against Let's Encrypt staging, using the
dns-cloudflare plugin with a real Cloudflare Zone:DNS:Edit token. The 2.15.1 image ships
backend/internal/certificate.js byte-identical to develop, so the patched file was mounted
straight over /app/internal/certificate.js — no version skew between what was tested and what
this PR changes.

Baseline, stock 2.15.1:

  • issue a DNS-01 certificate → succeeds, credentials-2 left on disk holding the live token
  • force renew → succeeds, file still there with its mtime unchanged, confirming the renew
    path only ever read it

Patched:

Scenario Result
Renew a certificate whose credentials file predates the change renewed OK — file rewritten, then removed
Renew again with no file on disk (the new steady state) renewed OK — 0 files before, 0 after
Issue a brand new certificate issued OK — nothing left behind
Restart the container with only certificate.js patched file reappears — this is what surfaced the setup.js writer
Restart the container with both files patched file stays gone
Renew after a restart, with no file on disk renewed OK — proves the renew path is self-sufficient

The restart case is worth calling out: with only the certificate.js change, issuance left 0
files but a restart brought them straight back — 0 after issuance, 1 after docker restart.
With both changes it stays at 0 across restart, and a renewal afterwards still succeeds. The
Added Certbot plugins log line still fires once per start.

The file's whole lifecycle, captured by polling once a second across a renewal:

67 bytes  mtime 23:26:34    pre-existing file from the stock run
67 bytes  mtime 23:28:33    rewritten by the patched renew path
ABSENT                      removed after certbot returned

certbot reported Congratulations, all renewals succeeded on every run, and both test
certificates ended up valid with fresh expiry dates.

Not exercised: route53, whose credentials path is passed through AWS_CONFIG_FILE rather
than a --credentials argument — same file lifecycle, but a different code path — and DNS
providers other than Cloudflare.

The credentials file written for a DNS-01 challenge was only cleaned up when
certbot failed - the unlink sat in a catch block. On success the file stayed in
/etc/letsencrypt/credentials for the entire life of the certificate, holding a
live DNS provider API token in plaintext.

The file cannot simply be deleted at issuance, because certbot records its path
in the renewal config and reads it back on every `certbot renew`. So the renew
path now writes the file itself immediately before invoking certbot, and both
paths remove it in a finally block.

Net effect: the credentials exist on disk for the duration of a certbot run
rather than permanently. The value still lives in the certificates table, which
is unavoidable - it has to come from somewhere to be written at all.

renewLetsEncryptSslWithDnsChallenge reads the row directly from the model
because renew() sources its certificate from internalCertificate.get(), which
strips meta.dns_provider_credentials via omissions().
setupCertbotPlugins() wrote a credentials file for every DNS-01 certificate
each time the backend started, using flag "wx" so it only filled in missing
ones. That existed because the renew path did not write the file itself, so
something had to put it back before `certbot renew` looked for it.

With the previous commit the renew path writes the file immediately before
invoking certbot, so this is now the only thing putting those credentials back
on disk - and it does so for every certificate on every restart, which undoes
the cleanup entirely.

Removing the write leaves the `fs` import and the `promises` array unused. The
"Added Certbot plugins" log line is kept but now gates on plugins.length, since
it was previously gated on a promise array that only ever held credential
writes.
@jc21 jc21 added the requires-verification Waiting for one or more people to confirm the fix label Aug 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

requires-verification Waiting for one or more people to confirm the fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants