[GUIDE] Secure Remote (mTLS) and Local HA Access

Secure remote and local Home Assistant access with Cloudflare Tunnel and mTLS

This guide puts Home Assistant behind a Cloudflare Tunnel and requires a client certificate for every request to the public hostname. It also uses a separate local hostname so devices on your LAN connect directly instead of taking the Cloudflare route.

The result is two independent authentication layers for remote access:

  1. Cloudflare verifies a certificate installed on the client device.
  2. Home Assistant still requires the normal Home Assistant login.

There is no /api/webhook/ bypass in this version. A push-requested background update from the Home Assistant iOS app has been tested successfully through the same mTLS rule. The same request command is supported on Android, and the Android app maintainer has confirmed that Android does not require excluded paths.

What this builds

Use Example hostname Route Client certificate required
Away from home ha.example.com Internet → Cloudflare → Cloudflare Tunnel → Home Assistant Yes
On the LAN lan.ha.example.com Local DNS → Home Assistant on port 443 No

Use hostnames you control in place of these examples.

The separate names are deliberate. They make the route predictable and keep the local connection independent of Cloudflare. Step 1 also adds one split-horizon record—returning the LAN address for the public name while at home—because Home Assistant hands the public name to devices such as speakers (step 10 explains why). Relying on split-horizon DNS alone is harder to diagnose when a client uses a VPN, encrypted DNS, or another resolver, so the local name remains the primary local route.

Before you start

You need:

Do not reuse one certificate everywhere. A certificate per device lets you revoke a lost device without disrupting all of the others.

Keep these secret:

  • each client private key;
  • each exported .p12 file and its password;
  • the Cloudflare tunnel token;
  • Home Assistant webhook IDs and access tokens.

The certificate signing request (.csr) and issued public certificate are not secret.

1. Configure local DNS for both names first

Complete this step before changing Home Assistant’s port or enabling HTTPS. Otherwise you will be reaching Home Assistant by IP address with a certificate warning until the names work.

  1. Choose a local hostname, such as lan.ha.example.com, and a public hostname, such as ha.example.com.
  2. In your local DNS server—typically your router or firewall—resolve both hostnames directly to the Home Assistant host’s LAN address. The public name needs a local record because Home Assistant hands that name to devices such as speakers; step 10 explains why, and a missing record fails silently.
  3. From a device using that DNS server, run:
ping lan.ha.example.com
ping ha.example.com

Confirm that both names resolve to the correct LAN address. The ping replies themselves are not important; name resolution is the check.

Do not create a proxied Cloudflare record for the local hostname. The Let’s Encrypt DNS-01 challenge proves control with temporary TXT records and does not require the hostname to resolve publicly.

Because the public name now resolves locally, test the public route only from outside your LAN—on cellular, or from a device that does not use your local DNS. From inside, https://ha.example.com goes straight to Home Assistant and never touches Cloudflare, so the checks in steps 5 and 12 would pass or fail for the wrong reason.

2. Issue the Home Assistant server certificate

Because the domain is already managed by Cloudflare, the Let’s Encrypt app can use a DNS-01 challenge to issue a publicly trusted certificate without opening inbound port 80.

  1. In Cloudflare, create an API token restricted to the domain used for Home Assistant with only Zone → DNS → Edit permission. Cloudflare calls a domain a “zone” on this screen.
  2. In Home Assistant, go to Settings → Apps → Install app and install Let’s Encrypt (addons/letsencrypt at master · home-assistant/addons · GitHub).
  3. I find YAML easier: on the app’s Configuration tab, click the three-dot menu and select Edit in YAML.
  4. Configure the app for a DNS challenge and request a certificate containing both names:
email: [email protected]
domains:
  - ha.example.com
  - lan.ha.example.com
certfile: fullchain.pem
keyfile: privkey.pem
challenge: dns
dns:
  provider: dns-cloudflare
  cloudflare_api_token: YOUR_RESTRICTED_API_TOKEN

If you want to use the app’s normal visual editor it has a separate DNS Provider configuration field. In that field, enter only the attributes below dns: rather than including the dns: key itself.

  1. Start the app. It writes the certificate and key to /ssl/fullchain.pem and /ssl/privkey.pem, then stops.
  2. Check the app log.

Stop here unless the log confirms that certificate issuance succeeded and both /ssl/fullchain.pem and /ssl/privkey.pem were written. If the files are missing or invalid, Home Assistant 2026.8 falls back to its last working HTTP settings, but you will have no HTTPS and the following steps will fail.

The token is a credential: do not post or commit it, and do not use Cloudflare’s account-wide Global API Key instead.

The app checks for renewal only when it starts, and renews only within 30 days of expiry. Start on boot runs it on an OS reboot; for anything more reliable, create an automation that restarts the app nightly. After a renewal, restart Home Assistant so it loads the new certificate, and monitor expiry rather than assuming renewal worked.

Reference: Home Assistant Let’s Encrypt app documentation

3. Serve HTTPS directly from Home Assistant on port 443

Before continuing, confirm that lan.ha.example.com resolves to Home Assistant’s LAN address. If it does not, return to step 1. Until it does, the only way to reach the new HTTPS listener is by IP address with a certificate warning.

In Home Assistant 2026.8 or later, go to Settings → System → Network, find HTTP server, and set:

  • Server port: 443
  • SSL certificate path: /ssl/fullchain.pem
  • SSL key path: /ssl/privkey.pem
  • SSL Profile: Modern

Cloudflared sends X-Forwarded-For. Enable Trust X-Forwarded-For and add 172.30.33.0/24—the Home Assistant OS app network, which the Cloudflared app documentation also recommends—under Trusted proxies.

I enabled IP banning and set it to five login attempts, but that’s up to you.

Saving the HTTP server settings restarts Home Assistant, which comes back on the new port with HTTPS. I find it best to have a second browser open to https://lan.ha.example.com so you can refresh the page and catch it once it’s up. Confirm the settings when prompted after the restart; Home Assistant rolls them back if they are not confirmed within five minutes.

If you upgraded from a release before 2026.8, Home Assistant imports the old http: YAML configuration into the UI. Verify the imported values, remove the old http: block from configuration.yaml, and restart as directed by the repair notification.

Before continuing, confirm that https://lan.ha.example.com loads Home Assistant with a valid certificate. Cloudflared verifies this certificate in step 4.

Do not forward port 443—or any other Home Assistant port—from the Internet to Home Assistant. The Cloudflare Tunnel is the remote entry path and uses an outbound connection, so router port forwarding is unnecessary and would bypass the design’s protections.

Reference: Home Assistant HTTP integration

4. Create and connect the Cloudflare Tunnel

This guide uses a remotely managed tunnel: create and route it in Cloudflare, then give its token to the Home Assistant Cloudflared app.

Create the tunnel in Cloudflare

  1. In the Cloudflare dashboard, go to Networking → Tunnels.
  2. Select Create a tunnel, choose Cloudflared, and give the tunnel a useful name. If the dashboard asks for a hostname at this stage, enter the public hostname—ha.example.com, with no https://—the route itself is configured below.
  3. On the connector setup page, choose an environment so Cloudflare displays an installation command.
  4. Copy the displayed command. Find --token in it and copy only the long value immediately after --token. Do not run the displayed command on Home Assistant.

The tunnel token is a credential. Do not post or commit the command or token.

Install and configure the Cloudflared app in Home Assistant

  1. Install the Cloudflared Home Assistant app/add-on (GitHub - homeassistant-apps/app-cloudflared: Connect remotely to your Home Assistant instance without opening any ports using Cloudflared. · GitHub), but do not start it yet.

  2. Open the Cloudflared app configuration.

  3. In Cloudflare Tunnel Token, paste the token you copied in step 4 above. Leave the other fields alone: when a tunnel token is set, the app ignores every other option and says so in its log at startup, so External Home Assistant Hostname and Cloudflare Tunnel Name do not need to match anything.

  4. Save the configuration and start the app.

  5. Check the app log and confirm that it connected successfully.

  6. Return to Networking → Tunnels in Cloudflare and confirm that the tunnel reports healthy.

Manage this tunnel’s routes in the Cloudflare dashboard; the app’s own routing options do not apply to a token-managed tunnel.

Publish Home Assistant through the tunnel

  1. Open the tunnel in Cloudflare and select Routes.
  2. Choose Add route → Published application.
  3. Set the public hostname, for example ha.example.com.
  4. Set the service URL to https://lan.ha.example.com:443.
  5. Under Additional application settings → TLS, leave No TLS Verify disabled.
  6. Save the route.

Because the service URL uses lan.ha.example.com, which is on the Let’s Encrypt certificate, Cloudflared can validate both the certificate chain and hostname without an Origin Server Name override. The Cloudflared app must use local DNS that resolves this name to Home Assistant’s LAN address.

References: Create a Cloudflare Tunnel, Cloudflare Tunnel origin parameters, and Cloudflared Home Assistant app documentation

5. Verify ordinary HTTPS before adding mTLS

First confirm, from outside your LAN, that https://ha.example.com reaches the Home Assistant login page. From inside, the local record from step 1 sends you straight to Home Assistant and proves nothing about the tunnel. Do not add the client-certificate rule until this basic path works.

Cloudflare’s free Universal SSL covers the domain apex and first-level subdomains, such as ha.example.com. A deeper name such as ha.home.example.com needs an Advanced Certificate, which may carry a charge.

In Cloudflare, under your Domain → SSL/TLS → Overview, keep Cloudflare’s default Automatic SSL/TLS, or use Full (strict) if you manage the mode manually. Home Assistant now has a valid Let’s Encrypt origin certificate, so Full (strict) is appropriate. Do not use Flexible.

Optional but recommended: under SSL/TLS → Edge Certificates, enable Always Use HTTPS.

References: Cloudflare encryption modes and Universal SSL limitations

6. Create one private key and certificate request per device

These steps create a private key and a certificate signing request (CSR) for one device; step 8 builds the .p12 bundle from Cloudflare’s reply. Generating the key yourself keeps it off Cloudflare and gives the certificate a Common Name you can recognize in the dashboard.

The commands below are for macOS because they use pbcopy and pbpaste; on another system, replace or omit the clipboard commands. Apple’s built-in openssl is LibreSSL, which handles every step here (see the .p12 note in step 8). On a trusted Mac, create a private working directory, enter it, and restrict newly created files:

umask 077

Create a key and certificate signing request (CSR). Edit the three variables first. Use a distinct DEVICE value for every device so the files and Cloudflare certificate remain easy to identify:

# ---- edit these three ----
DEVICE="johns-phone"
COUNTRY="US"
SITE="Home"
# --------------------------

openssl req -new -newkey rsa:2048 -nodes \
  -keyout "${DEVICE}-key.pem" \
  -out    "${DEVICE}.csr" \
  -subj   "/C=${COUNTRY}/O=${SITE}/CN=${DEVICE}" 2>/dev/null \
&& pbcopy < "${DEVICE}.csr" \
&& echo "Wrote ${DEVICE}-key.pem and ${DEVICE}.csr — the CSR is now on your clipboard."

This creates johns-phone-key.pem and johns-phone.csr with the example value. The CSR is copied to the clipboard. Repeat the process with a different DEVICE value for every client device. Never send or paste the -key.pem file anywhere.

7. Have Cloudflare issue the client certificate

In the Cloudflare dashboard for your domain:

  1. Go to SSL/TLS → Client Certificates.
  2. In Hosts, associate the public Home Assistant hostname with mTLS if it is not already associated.
  3. Choose Add Certificate.
  4. Use the Cloudflare-managed certificate authority.
  5. Choose Use my private key and CSR and paste the CSR already on the clipboard.
  6. Select a suitable validity period and create the certificate.
  7. Copy the complete certificate Cloudflare returns, including the BEGIN CERTIFICATE and END CERTIFICATE lines.

Cloudflare receives the public certificate request, not the device’s private key. The CSR carries no expiry date; Cloudflare sets the validity when it signs, so choose the lifetime in its form.

Reference: Create a client certificate and Enable mTLS for a hostname

8. Build the .p12 from Cloudflare’s certificate

With Cloudflare’s returned certificate on the clipboard, edit the same DEVICE value and choose a strong export password:

# ---- edit these two ----
DEVICE="johns-phone"
P12_PASSWORD="changeme"
# ------------------------

pbpaste > "${DEVICE}-cert.pem"
grep -q "BEGIN CERTIFICATE-" "${DEVICE}-cert.pem" \
&& [ "$(openssl x509 -in "${DEVICE}-cert.pem" -noout -modulus 2>/dev/null | openssl md5)" \
   = "$(openssl rsa  -in "${DEVICE}-key.pem"  -noout -modulus 2>/dev/null | openssl md5)" ] \
&& openssl pkcs12 -export \
     -out     "${DEVICE}.p12" \
     -in      "${DEVICE}-cert.pem" \
     -inkey   "${DEVICE}-key.pem" \
     -name    "HA ${DEVICE} Client Certificate" \
     -passout "pass:${P12_PASSWORD}" \
&& echo "Wrote ${DEVICE}.p12" \
|| echo "FAILED — the clipboard isn't a certificate matching ${DEVICE}-key.pem"

The command first saves the clipboard as johns-phone-cert.pem, verifies that the certificate matches johns-phone-key.pem, and then creates johns-phone.p12. Replace changeme; the password protects the .p12 during transfer and import, and setting it in the command leaves it in your shell history.

The working directory now contains four files:

  • johns-phone-key.pem — private key; keep secret;
  • johns-phone.csr — certificate request; not secret;
  • johns-phone-cert.pem — issued public certificate;
  • johns-phone.p12 — password-protected certificate and private key for device import; keep secret.

Keep these files, together with the other credentials for this Home Assistant installation, in one protected folder for the site (for example, Home).

Transfer the .p12 file securely. Delete unsecured transfer copies after installation, but retain a protected administrative copy if it is part of your recovery plan.

If an older device cannot import the .p12, the cause is usually the newer encryption that OpenSSL 3 uses by default. Apple’s built-in openssl is LibreSSL: it still writes the older format and has no -legacy option, so a .p12 made on a stock Mac already imports on older devices. With OpenSSL 3 (Homebrew, Linux), add -legacy to the pkcs12 -export command only for a device that needs it.

9. Require mTLS on the entire public hostname

In the Cloudflare dashboard:

  1. Go to Your Domain → Security → Security rules.
  2. Select Create rule → Custom rules.
  3. Give the rule a clear name such as Require mTLS for Home Assistant.
  4. Use this expression, replacing the hostname:
(not cf.tls_client_auth.cert_verified or cf.tls_client_auth.cert_revoked)
and (http.host eq "ha.example.com")
  1. Set the action to Block and deploy the rule.

This blocks requests when Cloudflare did not verify a client certificate or when the certificate has been revoked. The explicit revocation test matters: Cloudflare documents that cert_verified can remain true for a revoked Cloudflare-managed certificate.

Do not add an exclusion for /api/webhook/ or any other Home Assistant path. A blanket webhook exception would let requests reach every webhook on the instance without passing the client-certificate check, and a Home Assistant webhook ID is itself a secret credential.

References: Cloudflare custom rules, cert_verified field, revoking client certificates, and Home Assistant webhook security

10. Set the Home Assistant URLs and keep LAN media working

Home Assistant shares its own addresses with other devices when they need to fetch something from it, such as text-to-speech audio or other hosted media. Set both addresses so they match this design.

  1. Go to Settings → System → Network and find Home Assistant URL.
  2. Under Internet, leave Use Home Assistant Cloud off and enter https://ha.example.com.
  3. Under Local network, turn Automatic off and enter https://lan.ha.example.com.
  4. Select Save.

Because Home Assistant now serves HTTPS itself, it prefers the Internet address when it hands out media links, even to devices on your LAN. Without the local record for ha.example.com that you created in step 1, a speaker on the LAN would try to fetch from Cloudflare, be blocked by the client-certificate rule, and fail silently. With that record, the speaker connects to Home Assistant directly; the Let’s Encrypt certificate already includes both names, so the connection validates. Devices outside your network still resolve the public name to Cloudflare, so the client-certificate rule is unaffected.

Verify from the LAN that https://ha.example.com loads Home Assistant directly and that a text-to-speech or media action plays on a LAN speaker.

11. Install the client certificate and set the app URLs

Home Assistant app on iPhone and iPad

The Home Assistant app must import the client certificate itself; installing it only as an iOS configuration profile is not enough for the app. The iOS app documents client-certificate support as experimental: it is not available on Apple Watch, and Local Push notifications may not work over a client-certificate connection.

When setting up the app for the first time, or adding another server in the app’s configuration, I’ve found the phone, tablet, or Mac needs to be able to resolve the LAN hostname for Home Assistant. A VPN works as long as Home Assistant’s LAN hostname is resolvable through it.

  1. Transfer the .p12 file securely to the device.
  2. In the Home Assistant app, open Settings → Companion app, select the server, and open its connection settings.
  3. Find Client Certificate, import the .p12 file, and enter its export password.
  4. Set the internal URL to https://lan.ha.example.com.
  5. Set the external URL to https://ha.example.com.

Background return traffic has been tested with Wi-Fi disabled by sending the app a request_location_update notification command while it was backgrounded. The app updated its Home Assistant entities through the external route, so an API or webhook exemption is not required. The full test is in step 12.

If you also want Safari to use the certificate, install it separately as an iOS profile and approve it when Safari asks. That system installation and the Home Assistant app import serve different clients.

Home Assistant app on Android (Steps not verified)

Install the .p12 as a VPN and app user certificate in Android. In the Home Assistant app, set the internal URL to https://lan.ha.example.com and the external URL to https://ha.example.com. Open the external URL and choose the client certificate when Android prompts.

The Android app maintainer has stated that Android does not require excluded URL paths. A separate cold-start issue, where the WebView did not ask for the certificate soon enough, is fixed in app 2026.8.4 (a beta build when this was written). If you see a retry page, update before weakening the Cloudflare rule.

References: Companion app TLS client authentication, Android issue 7324, and Android fix 7381

macOS browsers

Import the .p12 into your login keychain. Safari and Chrome can then ask for it when connecting. Grant private-key access only to the applications that need it; do not select unrestricted access for every application.

Firefox normally uses its own certificate store, so import the .p12 in Firefox’s certificate settings if needed.

Windows browsers (Steps not verified)

Import the .p12 into the current user’s Personal certificate store. Browsers using the Windows certificate store can then offer it for the site.

12. Test the finished configuration

Test every route before relying on it.

Public route without a certificate

From outside your LAN, use a private browser profile or a device that does not have the client certificate. Opening https://ha.example.com should be blocked by Cloudflare, normally with HTTP 403. From inside the LAN this test is meaningless: the local record from step 1 loads Home Assistant directly.

Public route with a certificate

From outside your LAN, use a configured browser or companion app. Cloudflare should accept the certificate and show the normal Home Assistant login or app connection. mTLS does not replace the Home Assistant login.

iOS and Android background return-path test

Both companion apps support the request_location_update notification command. It asks the backgrounded app to acquire a location and send an update to Home Assistant, which exercises the external return connection protected by mTLS.

  1. Open the companion app once and confirm that it works through the external URL.
  2. Do not force-quit the app. Put it in the background and turn off Wi-Fi so the phone uses cellular data.
  3. In Home Assistant, record the current timestamps or values of the phone’s location and companion-app sensor entities.
  4. Run this action, replacing the notification action with the one for your phone:
action: notify.mobile_app_your_phone
data:
  message: request_location_update
  1. Wait at least five seconds, then verify that one or more relevant entities received a new last_updated timestamp or changed data.
  2. Check sensor.last_update_trigger as supporting evidence: on iOS a requested update reports Push Notification. It holds only the most recent trigger, so a later event can overwrite it; the changed entity timestamps or data remain the decisive proof.

On iOS, enable notifications, Background App Refresh, and Always location access. Apple gives the app limited background execution time, so a request can occasionally fail even when the configuration is correct.

On Android, grant background location access and exclude the companion app from manufacturer battery or sleep restrictions if necessary. The command is supported, but an Android device that suspends the app may defer the update until the app wakes.

Do not run this command frequently as a polling mechanism. It can consume additional battery and neither mobile operating system guarantees every requested background update.

Reference: Companion app request-location-update command

This test specifically covers the background API/webhook path that older versions of this guide exempted.

Local route

While connected to the LAN, verify that lan.ha.example.com resolves to Home Assistant’s LAN address and loads Home Assistant directly on port 443. Also confirm that ha.example.com resolves to the LAN address from inside the network (step 1) and to Cloudflare from outside. Then disconnect from the LAN and confirm that the local route is unavailable unless you intentionally made it reachable through a VPN.

Cloudflare events

Use Domain → Security → Analytics → Events to confirm that requests without a valid certificate match the block rule. Valid, non-mitigated traffic may appear in general traffic analytics rather than security events.

Maintenance and revocation

  • Record which Cloudflare certificate belongs to which device and when it expires.
  • Replace certificates before expiry.
  • If a device is lost or retired, revoke that device’s certificate in SSL/TLS → Client Certificates.
  • Keep the cert_revoked clause in the block rule.
  • Retest local HTTPS, remote access, and background sensor updates after changing the rule, certificate, tunnel, or companion app.
  • Review Cloudflared’s trusted proxy network after changing app/add-on networking.
  • Update both local DNS records (lan.ha.example.com and ha.example.com) if the Home Assistant LAN address changes.
  • Confirm that the Let’s Encrypt renewal automation runs and restart Home Assistant after a certificate is renewed.

Troubleshooting

Cloudflare returns 403 on a configured device

  • Confirm that the correct certificate is installed in the application making the request.
  • Confirm that the certificate was issued by the Cloudflare-managed CA in this Cloudflare account and that the hostname has mTLS enabled under Hosts.
  • Check that it has not expired or been revoked.
  • For the iOS companion app, import the .p12 inside the app; an iOS profile alone is not sufficient.
  • For Android, update the companion app to 2026.8.4 or later if an older build shows a cold-start retry page.

A LAN speaker or text-to-speech action stays silent

Home Assistant handed the device the public URL, and the client-certificate rule blocked it. Confirm that ha.example.com resolves to the Home Assistant LAN address from inside the network (step 1) and that the Let’s Encrypt certificate includes that name.

Home Assistant returns 400 through the tunnel

Confirm Cloudflared’s actual source address, then enable Trust X-Forwarded-For and add that exact address or network to Trusted proxies. Use a network address with a CIDR mask, not a host address with a mask.

The tunnel is healthy but Home Assistant does not load

  • Verify the published application’s service hostname, protocol, and port from inside the Cloudflared environment.
  • Confirm that Home Assistant is serving HTTPS on port 443.
  • If the log reports a certificate-name error, confirm that the service URL is https://lan.ha.example.com:443, that the Let’s Encrypt certificate includes lan.ha.example.com, and that local DNS resolves it to Home Assistant.
  • Keep No TLS Verify disabled; fix the service URL, local DNS, or certificate names instead.
  • Check Home Assistant and Cloudflared logs for the specific connection error.

The local hostname works in one client but not another

Check which DNS resolver that client is actually using. VPNs, encrypted DNS, browser-specific DNS, and cached records can bypass the local resolver.

Security boundaries

This design protects the Cloudflare hostname with device certificates and carries traffic through a Cloudflare Tunnel. It does not eliminate the need to:

  • keep Home Assistant and its apps updated;
  • use strong Home Assistant authentication;
  • protect the Cloudflare account with strong multifactor authentication;
  • safeguard private keys, .p12 files, tunnel tokens, and webhook IDs;
  • restrict the Let’s Encrypt Cloudflare token to DNS editing for the one required domain;
  • limit trusted proxies to known addresses;
  • revoke certificates for lost devices.

The public hostname should have one simple rule: no verified, non-revoked client certificate means no access—on every path.

What changed from the original guide

  • Removed NGINX Proxy Manager; Home Assistant now serves local HTTPS directly.
  • Added the Let’s Encrypt app with Cloudflare DNS-01 validation.
  • Changed Home Assistant’s server port to HTTPS port 443.
  • Removed the /api/webhook/ mTLS bypass; the client-certificate rule now protects every path.
  • Replaced Cloudflare-generated private keys with per-device OpenSSL keys and CSRs, then built each .p12 locally.
  • Added the Home Assistant URL settings and a local DNS record for the public name so LAN speakers can still fetch media.
  • Added a background companion-app return-path test for iOS and Android.
5 Likes

This is fantastic! Thanks for putting the time and effort into researching, testing and documenting the process!

1 Like

2.3 Ensure Your Hostname Has a Valid Edge Certificate

Have cloudflare changed this since your wrote it? It appears you need to pay $10 a month for “advanced certificate manager” now

1 Like

I'll reword that section, it's a little confusing. Basically, you only need to order an Advanced Certificate if your clouldflare tunnel hostname for home assistant is deeper than *.yourdomain.com.

For instance, if your cloudflare tunnel hostname is ha.yourdomain.com you do not need an Advanced Certificate but if you want to use ha.home.yourdomain.com you will need an advanced certificate which then requires Advanced Certificate Manager and there will be a $10/mo fee.

Awesome guide and thanks for taking the time to put it together.

I am running HA 2026.05 and could get everything working but the iOS companion app in iOS 26. The bit in section 5, iOS / iPadOS setup, step 4 no longer seems true:

and the HA Companion App's foreground WKWebView requests will pick it up as well

It just wasn't using the cert by default. Instead when assigning the external URL in the companion app settings there's now a client certificate import section. Import the same p12 cert there and then the companion app should start working.

Hi @mwolter,

Very nice guide! I ran into it after setting up my similar feature. The Local tunnel worked for me well by the way even with adding multiple extra proxy hostnames.

Another note:

You can install nginx proxy manager app in your HA installation and use letsencrypt with cloudflare dns challenge by creating an API token (the proxy manager uses ..._api_key parameter in the default config but the email is not needed and the correct parameter is ..._token.)

This way you can create a valid ssl cert for any fqdn in the home network, even wildcard or deeper sublevel.

Thanks heaps for this guide, it was very helpful!

A few observations from my run-through:

  • For creating client certificates, using Cloudflare to generate the private key and CSR results in all the certificates looking the same (e.g CN=Cloudflare, C=US), so it’s hard to match a certificate to a specific device.

    I found it much better to use openssl to manually generate the CSR with custom values first and put the generated CSR into Cloudflare e.g.

    openssl req -new -newkey rsa:2048 -nodes -keyout LucasAndroid.key -out LucasAndroid.csr -subj "/C=AU/O=Lucas home/CN=LucasAndroid"
    
  • The other thing that I found, is that desktop Firefox was having issues presenting the certificate, which turned out to be caused by HTTP/3. Disabling that in Firefox fixed the issue, but I ended up disabling it on the Cloudflare side to be sure (and so that my browser can still use HTTP/3 elsewhere).

    I’m not sure if this is a Firefox thing or a Cloudflare tunnel thing, but it seems like it doesn’t play nice with HTTP/3. (Chrome and Safari seemed fine, but I’m not sure if they were using HTTP/3 connections before I turned it off).

1 Like

I’ve disabled HTTP/3 in Cloudflare as well. This allowed me to connect with HomeAssistant app on my Android phone. On top of that it seems that if HTTP/3 is disabled then /api/webhook bypass isn’t required anymore as those calls will be used together with mTLS installed on my device. At least that’s how it works for me now.

4 things I found while setting this up:

First, for the section 4.2 Rule, the new menu naming/location is now Security > Security Rules in the domain settings (not WAF). And you can create it from the Client Certificates page by clicking the Create mTLS Rule button.
Second, the rule language is incorrect. Per the template you get from the Create mTLS Rule button, you probably don’t need the cf.tls_client_auth.cert_revoked part but you do need to add https:// to the beginning of the full_uri so a new rule might look like:

(not cf.tls_client_auth.cert_verified and http.request.full_uri wildcard r"https://ha.yourdomain.com/*" and not starts_with(http.request.uri.path, "/api/webhook/"))

Third, I can also report that it was necessary to import the certificate to the iOS companion app server settings.

Lastly, in the MacOS keychain after import, I had to double-click on the Cloudflare certificate entry, go to the Trust section and change When using this certificate to Always Trust. Then toggle the caret next to Cloudflare and double-click on Imported Private Key, go to Access Controls and change to Allow all applications to access this item. When I had not done that, Safari constantly asked for the OS credentials to allow access to the cert.

You should clarify this with Home Assistant devs. According to them, at least the Android App doesn’t need this exclusion. It was even mentioned again in a recent issue that I was following for a different problem I was having. Here you go as quoted with link:

For the Android app you do not need to exclude any paths.

But please clarify for your full guide since you are writing guides for everyone. Don’t just assume and don’t just rely entirely on AI.

The guide above has been rewritten (September 2026).

Thanks to everyone who tested it and reported back. The main post now describes what I actually run today, and every step in it was re-verified against the app source, the Home Assistant and Cloudflare docs, and a live instance before posting. What changed:

  • No more /api/webhook/ bypass. The client-certificate rule now protects every path. I tested the background return path by sending request_location_update to the iOS app while it was backgrounded on cellular; the entities updated through the mTLS-protected route. As @eriri linked, the Android maintainer confirms no excluded paths are needed there either.
  • Nginx Proxy Manager is gone. Home Assistant serves HTTPS itself on port 443 with a Let’s Encrypt certificate issued by the Let’s Encrypt app over Cloudflare’s DNS-01 challenge. Since 2026.8 the HTTP settings live in Settings → System → Network.
  • Per-device certificates from your own CSR (thanks @lucasc), so each certificate has a recognizable name in the Cloudflare dashboard and the private key never leaves your machine.
  • Home Assistant URL settings, plus a local DNS record for the public name. With Home Assistant serving TLS itself, it hands the public hostname to LAN devices such as speakers. Without a local record those requests hit Cloudflare and get blocked. Easy to miss; it cost me an afternoon.
  • Android: the cold-start “retry” problem (issue #7324) is fixed in app 2026.8.4, currently a beta build.

On the webhook exclusion, since it came up: it was never an assumption. When this guide was written in April, the iOS app’s mTLS support was a few weeks old and its background requests did not present the certificate — that 403 was measured, not guessed, and the exclusion was the workaround that worked at the time. The app finished that work over the summer, and the guide has now caught up. I would rather retest than take anyone’s word for it, including my own from four months ago.

If you hit the HTTP/3 issue @lucasc and @jakunow described, I have not reproduced it, so it is not in the guide; their notes above still stand.