Blog// 15 MIN READ

The Control Was a String: Renaming a Config Key Turned Authentication Off

A config rename turned authentication off and the dashboard came up open. The rename is not the bug: a security control that fails open is.

EVIDENCE → INTERPRETATION → DECISION

On 14 September 2026 a critical advisory was published against ESPHome Device Builder: CVSS 9.8, CWE-306, missing authentication for a critical function. The dashboard came up with authentication switched off, and the reason is one line of engineering that almost everyone would have signed off.

Two environment variables were renamed. The old names were removed without a fallback. A deployment that had set only the old names resolved to no credentials, and a dashboard with no credentials decided that it required none.

The rename is not the interesting part, and it is not the bug. The interesting part is that an authentication control was keyed on the presence of a string, and it had no way to report that it was not active. That is a design property, and it outlives this advisory.

What happened, precisely

The dashboard resolved credentials in its settings parsing. It read $ESPHOME_USERNAME and $ESPHOME_PASSWORD. Earlier versions, and the legacy dashboard, read the bare $USERNAME and $PASSWORD instead, and the older getting-started guidance documented those bare names.

A change renamed the variables to the namespaced form and dropped the bare names. There was no fallback. So for an operator who followed the old guidance, the result was not a wrong username or a rejected password. It was an absence: no credential material resolved at all.

Six-step chain showing the configuration key rename, the deliberate reason for removing the old names, the resulting absence of credentials, both authentication gates being disabled, the startup warning that a detached container never surfaces, and the changelog that never carried the change
Original model. Every step is defensible on its own. The last one is the design decision.

From there the failure is mechanical. With nothing resolved, the internal flag that says a password is in use became false, and both enforcement points followed it: the REST authentication middleware and the WebSocket login gate were disabled together. The dashboard started and served normally, to anyone who could reach its port.

The impact is not abstract for a device-management surface. An unauthenticated client with network access can manage devices, edit configurations and flash firmware. That is the CWE-306 shape: the function is critical, and the authentication for it is missing rather than weak.

The scope, stated carefully because it matters for anyone triaging this. The affected path is the standalone container, ghcr.io/esphome/esphome running the dashboard subcommand without --ha-addon, on esphome-device-builder below 1.0.12. Home Assistant add-on installs are not affected, because they pass --ha-addon and authenticate through the supervisor ingress proxy rather than these variables. The fix is 1.0.12.

Why the rename was the right call

It would be easy and wrong to write this up as carelessness. The removal of the bare names had a real security justification, and it is worth stating because it is good reasoning.

$USERNAME is not a safe variable to read on its own. On both Linux and Windows it collides with the operating system’s login user. A dashboard that read it would take whichever shell user launched the container and silently promote that string to the dashboard’s username. That is a genuinely bad behaviour: it makes the security posture depend on how the process happened to be started, and it would hand an attacker a predictable username for free.

Namespacing the variables and dropping the ambiguous bare names closed that footgun. If I had reviewed that change in isolation, I would have approved it. The reasoning is sound, the intent is protective, and the change does exactly what it says.

So the lesson is not “do not rename configuration keys”. It is that this change removed a control from deployments that were relying on it, and nothing in the system was positioned to notice.

The real defect: a control keyed on a string

Strip the specifics away and the design is this. Authentication is enabled if and only if a particular string resolves to a value. The string is configuration. The control reads it, and its state follows.

That is a common and usually fine pattern, and it is worth being precise about when it stops being fine. A config-keyed control is acceptable when three things hold: the configuration is validated, the failure mode is restrictive, and the control’s state is observable. This case failed the second and third while passing the first, which is exactly the combination that produces a silent exposure rather than a noisy outage.

There is a second-order point that generalises well beyond ESPHome. When a security control is keyed on a name, the name becomes part of the security boundary. Renaming it is not a refactor in the way that renaming an internal function is, because deployments are coupled to it through documented guidance, container environment blocks, orchestration manifests, compose files and Helm charts. The name is an interface, and it is an interface that carries an authorisation decision.

This is a different failure from the ones I have written about this week, and the difference is worth naming. The MCP server article was about checks applied at the wrong layer. The session-issuance article was about a check present on one path and absent on another. The model-loading article was about a flag a loader did not consult. All three are validation failures: a check existed somewhere and did not run where it mattered. This is a verification failure. Nothing was bypassed. The control was simply not there, and nothing asked.

It failed open

The decisive design choice is what the software does when its authentication configuration does not resolve. It chose to continue.

Read the consequence honestly. The dashboard did not misjudge a credential, reject a valid one or accept a wrong one. It concluded that authentication was not required, because nothing told it otherwise, and it served traffic on that basis. The service started, bound its port, answered requests and looked completely healthy. From the outside, and from most monitoring, that is a working deployment.

Fail-open is not always wrong. For a rate limiter, a cache or an optional hardening feature, availability often should win. For an authentication control guarding device management and firmware flashing, it should not. When the control cannot determine its own configuration, the safe state is to refuse: exit, bind nothing, or serve only a diagnostic endpoint. A failed deployment is visible and gets fixed. A silently unauthenticated one waits.

The warning existed, and went nowhere

This is the detail I would put in front of an engineering team, because it is the most instructive part of the advisory and the easiest to repeat.

The process logged a startup banner stating that it was running without authentication. That is real instrumentation, and somebody thought about the problem. It failed for a structural reason: the affected deployment is a detached container. docker run -d does not surface stdout to a human, and the advisory says so directly. So the one signal the design relied on is invisible in precisely the deployment that is exposed.

That is a general pattern and it deserves a name. A log line is not an alert. It is a message placed on a channel that a human may or may not be reading, and the further a deployment moves toward containers, orchestrators and detached processes, the less likely that reading is happening. Instrumentation that only exists on stdout is instrumentation that exists for the person who happens to be watching the terminal, which in a container platform is nobody.

The migration signal failed in the same way. The change was tagged as a breaking change, which is the correct convention, but it did not reach the changelog for the release in which it shipped. An operator following the normal upgrade path would have had no reason to look. Two notification channels were used, a startup banner and a release tag, and both were structurally incapable of reaching the person who needed them.

Misconfigured is not the same as absent

There is a distinction here that I find useful when triaging security findings, and this advisory is a clean illustration of it.

A misconfigured control still runs. It evaluates something, reaches a decision, and gets it wrong. A password is compared against the wrong hash, an allowlist contains the wrong domain, a role mapping points at the wrong group. These are bad, and they are comparatively easy to find, because the control produces evidence: a rejection, an acceptance, a log line at the decision point, a behaviour that differs from expectation.

An absent control does not run at all. Nothing is evaluated. There is no decision, so there is no decision log, no rejection to notice and no anomaly to compare against a baseline. The system does not behave differently from a correctly configured one from the perspective of any legitimate request, because a legitimate request would have been authorised either way.

That asymmetry explains why this class survives review, scanning and monitoring. Static analysis sees a settings parser that works. Dynamic testing sends an authenticated request and gets a sensible response. Monitoring sees a healthy service. The only way to detect an absent control is to send a request that should be refused and check that it is, which is a test almost nobody runs against their own deployment on a schedule.

So the practical recommendation here is not another dashboard. It is one scheduled request. For every service that requires authentication, generate an unauthenticated request, on a timer, and assert that the response is a refusal. That single check would have caught this exposure on the first run after the upgrade, on any deployment, without reading a single log line.

Control integrity: three checks

What I take from this advisory is a short discipline for any security control that depends on configuration. I call it control integrity, and it has three parts ordered by cost.

Comparison of a fail-open authentication control against a fail-closed one, followed by the three checks: fail closed, assert the control is armed at startup, and expose the control state on a channel that does not require reading logs
Original model. The first check is free. The third is the one that survives contact with containers.

Fail closed. When a security control cannot resolve its own configuration, it should refuse to serve rather than continue with the control disabled. In practice this means treating an unresolved authentication configuration as a fatal startup error, not as an empty setting. This is the cheapest of the three and it prevents the entire class. The trade is real, because a misconfigured deployment now fails instead of running, and that is the correct trade for authentication specifically.

Assert that the control is armed. Startup should check the control you believe is enabled, not merely that configuration parsed. The distinction is the whole failure here: parsing succeeded, the process started, and the security property was gone. A startup assertion that says “authentication is active, using credentials from source X” turns a silent exposure into a refused start. Kubernetes readiness probes are not a substitute, because a container whose authentication is off is genuinely ready by every definition a probe uses.

Expose the control state where a machine will see it. If the disarmed state is reachable at all, it needs to be visible on a channel that does not require a human to read a log stream. A readiness endpoint that reports authentication state, a metric a monitor already scrapes, or a startup probe that fails when the control is off. The test I would apply is blunt: if the only way to learn that authentication is disabled is to read stdout, then in a container platform you will not learn it.

The migration rule

The advisory also argues for a discipline around the change itself, and it is worth stating because renaming security-relevant configuration is common and usually handled as housekeeping.

Renaming a security-relevant configuration key is a security event, not a refactor. Three practices follow. Deprecate the old name with a hard warning rather than removing it in the same release, keeping a compatibility path for at least one cycle. Make the compatibility path refuse to guess: if both names are present with different values, stop rather than pick one. And put the change where operators will meet it, which means the changelog for the release that ships it, not only a tag on a pull request.

The reason to insist on the changelog point is that it is the difference between a change that is technically documented and one that is practically discoverable. A breaking-change label on a commit is a signal for maintainers. The changelog is the signal for the operator who is about to run an upgrade on a Sunday.

There is a second layer to this that is easy to miss. A deprecation window only works if the old path still functions while the new one is introduced. Removing a name and adding a warning in the same release is not a deprecation, it is a removal with a note attached, and the note competes for attention with everything else in a startup log. The useful pattern is to read the old name, prefer the new one when both are present, refuse to start when they disagree, and only remove the old name in a later release once the warning has had a full cycle to reach someone.

What does not follow

Three limits, stated because the advisory is narrower than a headline would suggest.

First, this is not a compromise in the ordinary sense. There is no evidence of exploitation here, and the path requires that an operator had configured the old variable names and exposes the dashboard port. That is a real population, but it is not every ESPHome user, and Home Assistant add-on installations are explicitly unaffected.

Second, this is not an argument against the rename or against namespacing configuration. The old behaviour, silently promoting the operating system login user to dashboard username, was worse. The right conclusion is that the removal needed a compatibility path and a startup assertion, not that the rename was a mistake.

Third, I have not tested this myself. What I can defend is the mechanism and the design reading, taken from the published advisory, and the generalisation, which is my own analysis. Anyone reproducing it would need a container, the affected version and a deliberately unconfigured environment, which is the correct way to confirm a fail-open control.

Evidence to look for in your own deployments

Signal Why it indicates this class
A service whose authentication configuration is empty but which is serving The direct condition. If credentials are absent by design, authentication should be off by design, and that decision should be explicit rather than incidental.
Environment variables in a manifest or compose file that no longer match the documented names This is what a silent rename looks like from the operator’s side: an env block that is still present, still plausible, and no longer read.
Startup warnings that only appear on stdout Not a finding on its own, but it means the deployment has no alerting path for static misconfiguration, which is the deeper issue.
Upgrades that changed security-relevant configuration without a changelog entry The migration signal is part of the control. A security-relevant change that only exists as a commit is a change most operators will not see before it affects them.
A readiness or health check that passes while a control is disabled Health and security are different properties. A green check that cannot distinguish them is a monitoring gap, not a control.

The cheapest test I would run this week is a one-line query over deployment manifests: for each service that requires authentication, does the environment actually contain the variable the current version reads? That is the question a rename turns into a silent failure, and it takes an afternoon to answer across a normal estate.

Frequently asked questions

What does fail open mean?

It means that when a security control cannot determine its own configuration, it continues operating with the control disabled rather than refusing to run. In this advisory, unresolved credentials meant authentication was treated as not required, so the dashboard started and served traffic normally. Fail closed is the opposite: refuse to serve until the control is configured.

What was the ESPHome dashboard vulnerability?

ESPHome Device Builder read dashboard credentials from $ESPHOME_USERNAME and $ESPHOME_PASSWORD. When those variables were renamed, the older bare $USERNAME and $PASSWORD names were removed without a fallback. A deployment configured only with the old names resolved to no credentials, so the REST authentication middleware and the WebSocket login gate were both disabled. Rated CVSS 9.8, CWE-306, fixed in esphome-device-builder 1.0.12.

Why is authentication being keyed on an environment variable a problem?

Because the variable name becomes part of the security boundary. When a control activates only if a particular string resolves, renaming that string removes the control from every deployment that used the old name, and the deployments have no way to signal that they are now unprotected. It is acceptable only when the failure mode is restrictive and the control’s state is observable, which is what this case lacked.

Was the rename itself a mistake?

No. The old bare names had a genuine problem: $USERNAME collides with the operating system login user, so a dashboard reading it could silently promote the shell user to dashboard username. Removing that behaviour was correct. The mistake was removing it without a compatibility path, without a startup assertion that authentication was active, and without the change reaching the release changelog.

Why did nobody notice the startup warning?

The process logged a banner saying it was running without authentication, but the affected deployment is a detached container, and docker run -d does not surface stdout to a person. Instrumentation that exists only on stdout is effectively invisible in container platforms. A log line is not an alert, and this is the clearest example of that distinction I have read this year.

How do you prevent this class of failure?

Three practices. Fail closed, so an unresolved authentication configuration is a fatal startup error rather than an empty one. Assert at startup that the control is armed, rather than trusting that configuration parsed. And expose the control state on a channel a machine reads, such as a readiness endpoint or a scraped metric, so the disarmed state cannot hide behind a healthy process.

Sources

Related reading on this site: the difference between a check applied at the wrong layer and a check that never ran is covered in The MCP Server Is the Attack Surface; a control present on one path and absent on a sibling is covered in The Second Door; and a flag a loader simply did not consult is covered in The Model File Is a Program.

One next action

If you run an agent that executes tools, AgentCrash is an open-source harness that crash-tests it in a synthetic workplace: synthetic tools, visual evidence and regression tests. It is built for exactly this problem, turning a containment claim into something you can run against the system instead of arguing about it in review.