Putting Home Assistant on the Internet Behind a Cloudflare Client Certificate
Home Assistant behind Cloudflare mTLS: the client certificate rule, the /api/webhook/ exception the companion app needs, a separate .p12 for iOS, and the real visitor IP in the logs.
HomeLab, Smart home. Updated . 5 min read.
My Home Assistant is on the public internet, and without the right certificate you can't even load its login page. Cloudflare drops the request at the edge, so it never reaches my network. There is one hole in that wall, on purpose: /api/webhook/.
That hole is the part most mTLS write-ups skip. Without it, the phone app's background location and sensor posts never get through.
TL;DR: Home Assistant sits behind a Cloudflare Tunnel and a Caddy site. A Cloudflare security rule blocks every request to the Home Assistant host that doesn't carry a verified client certificate, except paths under /api/webhook/, because the companion app's background posts send no certificate. The iOS app needs the .p12 imported inside the app, separate from the Safari profile. Caddy copies CF-Connecting-IP into X-Forwarded-For, so Home Assistant logs the real visitor instead of 127.0.0.1.
The path
browser / app -> Cloudflare edge (cert check) -> Tunnel -> Caddy (http, 127.0.0.1) -> Home Assistant :8123
TLS ends at Cloudflare, and so does the certificate check. Home Assistant never sees a client certificate and doesn't need to know mTLS exists. Inside the house nothing changed: the internal URL still goes straight through the LAN.
The Cloudflare side
It takes two pieces.
First, a client certificate under SSL/TLS > Client Certificates, issued by the Cloudflare Managed CA. Mine is valid until 2041, so it's not something I'll be rotating on a schedule. The same page has a Hosts list, and the Home Assistant hostname has to be on it. Without that, Cloudflare never asks the browser for a certificate on that host, so the verified flag is never true and the rule below blocks you too.
Second, a security rule, action Block:
(http.host eq "ha.example.com"
and not cf.tls_client_auth.cert_verified
and not starts_with(http.request.uri.path, "/api/webhook/"))
That's the whole policy: no certificate, no page, unless the path is a webhook.
Why the webhook hole has to be there
The companion app does two kinds of traffic. When you open it, it's a normal client, and it can present a certificate. In the background, it posts location and sensor updates to /api/webhook/<id>, and those posts don't present the certificate. Block them and your phone's sensors in Home Assistant stop updating.
So the rule lets /api/webhook/ through without a certificate. That's a real trade-off, not a free pass. A Home Assistant webhook authenticates by its id alone, and the ids here are 64-character secrets. Someone would have to guess one to send anything. I'm fine with that, and I'd rather know exactly where the exception is than have the app break in silence.
Getting the certificate onto devices
I keep four files on a backup share, all mode 600 in a 700 folder: the certificate PEM, the key PEM, a .p12 bundle, and the .p12 password. Each device wants a different one of them.
- Windows PC: import the
.p12into the Windows certificate store. Chrome offers it from the store when the site asks for one. - iPhone: two installs. The profile under Settings > VPN & Device Management covers Safari. The Home Assistant app ignores it. It needs the
.p12imported inside the app, under Settings > Companion App > Servers > your server > Connection > Client Certificate. - Android: Settings > Security > Encryption & credentials > Install a certificate, then "VPN and app user certificate". Not the Wi-Fi certificate, which looks close enough to pick by mistake.
The iPhone one is the trap. Safari works, so you assume the phone works, and the app still gets the block page.
Keep the PEM originals. If a device is lost, I revoke its certificate in Cloudflare and issue a new one, and the PEMs let me re-export a .p12 whenever I need one.
Debugging a blocked request
If you see Cloudflare's block page, the request had no certificate or one that didn't verify. On iPhone, Safari caches the TLS session, so after you install the profile it can keep failing until you force-close Safari.
The less obvious part: a blocked request never reaches Caddy. It isn't in the access log at all. So an empty Caddy log for the public host isn't a broken tunnel. It means the edge is doing its job. Look in Cloudflare's security events, not on the server.
Making Home Assistant see the real visitor
Once it worked, Home Assistant logged every public request as 127.0.0.1. That's the tunnel. cloudflared connects to Caddy on loopback, and Caddy doesn't trust an incoming X-Forwarded-For unless you tell it to. So it sent its own view of the client, which was always localhost.
Cloudflare puts the address it saw in CF-Connecting-IP. One line in the Caddy site for the public host forwards it:
reverse_proxy <nuc>:8123 {
header_up X-Forwarded-For {http.request.header.CF-Connecting-IP}
}
Home Assistant also has to trust Caddy as a proxy before it reads that header, which is use_x_forwarded_for and trusted_proxies under http: in its config.
The check is to fail a login on purpose. Send a request with the client certificate and a bad token, then grep the Home Assistant log:
curl --cert client-cert.pem --key client-key.pem \
-H "Authorization: Bearer not-a-token" https://ha.example.com/api/
docker logs homeassistant 2>&1 | grep "invalid auth"The line shows my public IP now, not 127.0.0.1. That matters more than tidy logs. Anything in Home Assistant that acts on the client address, like ip_ban, would otherwise treat every public request as the same 127.0.0.1.
Monitoring through the same wall
A plain Uptime Kuma check on the public URL would only ever see the block page. Kuma 2 supports mTLS on HTTP monitors, so the "public" monitor now sends the same certificate and key. I set it with a small script over Kuma's socket API: editMonitor with authMethod: "mtls", tlsCert, and tlsKey read from the PEM files. That monitor now proves the whole path, edge rule included, instead of only proving the block page loads.
Why Immich didn't get the same treatment
Immich is also public, behind a different Cloudflare rule: the request has to carry a secret header, which the Immich app sends. It stays on that on purpose. Immich public share links are meant for people who have no certificate and never will. mTLS would break every link I send. Home Assistant has no share links, so it gets the stricter door.
Questions
- How do I require a client certificate for Home Assistant behind Cloudflare?
- Create a client certificate under SSL/TLS > Client Certificates with the Cloudflare Managed CA, add your Home Assistant hostname to that page's Hosts list, then add a security rule that blocks requests to that host when cf.tls_client_auth.cert_verified is false. Install the certificate on every device that should get in.
- Why does the Home Assistant companion app stop reporting behind Cloudflare mTLS?
- The app's background posts go to /api/webhook/<id> and do not present the client certificate, so a strict mTLS rule blocks them. Exclude paths that start with /api/webhook/ from the rule. Each webhook id is a 64-character secret, so that path is not an open door.
- Does the Home Assistant iOS app use the certificate profile installed on the iPhone?
- No. The profile in Settings > VPN & Device Management covers Safari. The app needs the .p12 imported on its own, under Settings > Companion App > Servers > your server > Connection > Client Certificate.
- Why does Home Assistant log 127.0.0.1 for every request through a Cloudflare Tunnel?
- cloudflared connects to the reverse proxy from loopback, and the proxy reports that address. In Caddy, set header_up X-Forwarded-For {http.request.header.CF-Connecting-IP} on the site, so Home Assistant receives the visitor IP that Cloudflare saw.