ViennaWhat went wrong?Security and accessGetting started and running it

400 Bad Request, and my config had never been active

Remote access answered with 400 and nothing else. Three suspects cleared — until it turned out Home Assistant no longer reads that file at all.

The Vienna flat was finally reachable from outside — technically. After a long back and forth the connection had a proper IPv4 address again, the port forwarding was in place, the certificate was valid.

And every request answered with:

HTTP/1.1 400 Bad Request
400: Bad Request

Sixteen bytes of plain text. Those sixteen bytes were the most useful clue of the entire evening.

First question: who is actually answering?

Between the internet and Home Assistant sits a reverse proxy. So two programs can answer — and they answer differently.

The proxy would have sent an HTML page. A heading, line breaks, its own name at the bottom. Home Assistant sends exactly this short line.

So Home Assistant answered, and it did so deliberately. The log said why:

A request from a reverse proxy was received from 172.30.33.4,
but your HTTP integration is not set-up for reverse proxies

The address was the right one, and it sat inside the range I had entered as trusted. Home Assistant simply did not know about that setting.

The shape of an error message gives away its sender before you read its content. In a chain of several programs, not knowing which one answered means searching the wrong house.

Three suspects, three dead ends

In order, because each one was plausible.

One: the package isn’t being read. The setting lived in its own package file. Maybe that file was never loaded. I moved the block into the main configuration — no change.

Two: the restart isn’t taking effect. A fair suspicion, because my own deployment script had been reporting “back after 5 s” for days, which could not possibly be true. Measured: after the restart command Home Assistant keeps serving for about 65 seconds before the core actually stops. My script waited 60 seconds for it to disappear, gave up — and then checked the old process.

That was a real bug, and it is fixed. But it was not the cause: with clean proof from the reported core state (RUNNING → NOT_RUNNING → RUNNING) the 400 stayed.

Three: the configuration is invalid. Also no. A deliberately broken value showed that the checker does read the file — it named the line number and the filename.

Three suspects, three tests, three acquittals. And one of them was a genuine bug that had nothing to do with the symptom. Those exist.

The cause was in the source code

Since version 2026.7.5 the HTTP configuration migrates once from YAML into an internal store. After that, the store wins. The source code says it in one sentence:

YAML config is only migrated once. Subsequent boots will ignore YAML and use the store exclusively.

From then on every http: section in YAML is dead weight. No warning in the log, and the built-in configuration check still reports it as valid — it checks the form, not the effect.

The store on this device held just the bare minimum:

"server_port": 80,
"login_attempts_threshold": -1,
"ip_ban_enabled": true,
"created_at": "2026-08-28T06:41:36"
"yaml_migration_done": true

No entry about the reverse proxy. And here is the line that hurts: the created_at is from the morning of 28 August — that is, before my package file was even written. The migration froze the state from initial setup.

The file was ineffective from its very first minute. My own documentation nonetheless said the setting was in place. That was never true. I had written the configuration and never measured whether it worked.

Two side findings from the same store

Port 80 instead of 8123. Why this instance listens on that unusual port had been an open question in the project since day one. It is in the store, not in the YAML. A question open since setup was answered the moment I read the right file.

The lockout after failed logins was off from the factory. The threshold was -1. The field next to it read ip_ban_enabled: true and looked reassuring — except nothing was counting that could have triggered the ban.

For a system about to become reachable from the internet, that is the more important of the two findings. It would never have surfaced on its own, because it produces no symptom. A lock that does not engage looks exactly like a lock that was never needed.

The right way — and why it beats a file

The setting is no longer written into a file but applied through three commands:

Command Effect
read shows what is actually in force
set stores the new configuration as pending and restarts
promote makes it permanent

The middle step is the interesting one. If the configuration is not promoted, it falls back to the previous state by itself after a deadline.

A setting that locks you out does not survive this way.

That is a design idea I have kept: the dangerous change applies on probation first, and between setting and promoting belongs the test — from outside, through the proxy, the way a real request looks.

In my case: restart in 12 seconds, then the login page from outside with 200 OK and 8503 bytes instead of 16, then promote. After that the store contained what the file had only been claiming all along.

The http section is removed from the YAML and the package file is deleted — so nobody later mistakes it for the truth. Myself included.

The most annoying part comes last

Kapfenberg had solved exactly this ten days earlier. Documented, with a script, and with one sentence that concerned me:

This affects Vienna and Merano in the same way as soon as they are updated to 2026.7.5 or newer. If there is an http section in the YAML there, check what is in .storage/http.

Vienna was moved to a newer version three days before the error. The note had been sitting there for ten days. I did not read it, and opened the Home Assistant source code instead.

For a bug that could exist in a sister project, the first look belongs there — not in the software’s source code.

The script that finally settled the matter came from there. Exactly one line needed adapting: the address range of the add-on network.

And for Merano the point is still open. The same ineffective section is probably sitting there too; it will be checked as soon as that instance runs a matching version. I am writing it down here so I do not leave it lying around for another ten days.

What I’d suggest

  1. Read who is answering. Short text or HTML page, byte count, choice of words — in a chain of proxy and application, the shape tells you where to look.
  2. Don’t check that the file is there. Check that the value is in effect. And do it where the software actually reads it.
  3. Expect your YAML to have been stripped of power. A migration that runs exactly once makes every later change silently ineffective. This is not unique to Home Assistant.
  4. Apply dangerous settings with a fallback deadline and test from outside in between. Lock yourself out and you end up standing in front of your own flat.
  5. Search the neighbouring project before the source code. If you run the same software twice, you may have solved the bug once already.

So what?

The 400 was not the hard part. It was found in one evening, and the diagnosis ran in a straight line.

The hard part is what came before: my project held a file that I had believed, from day one, was doing something. It was in the documentation, it was syntactically correct, the checker reported it as valid — and it was ineffective from its first minute.

That is the same pattern as the umlaut that killed my frost alarm: there, a protective function failed to fire for months because it pointed at an entity that had never existed. Both times the system was fine. Both times my assumption was the fault.

Written configuration is not working configuration. The difference costs nothing but one measurement — and that is exactly what I had skipped, both times.