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:
- Cloudflare verifies a certificate installed on the client device.
- 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:
- a domain whose DNS is managed by Cloudflare;
- Home Assistant OS with the Let’s Encrypt (addons/letsencrypt at master · home-assistant/addons · GitHub) and Cloudflared (GitHub - homeassistant-apps/app-cloudflared: Connect remotely to your Home Assistant instance without opening any ports using Cloudflared. · GitHub) apps/add-ons;
- OpenSSL on a trusted computer;
- access to your router, firewall, or other local DNS server;
- a plan to issue one unique client certificate for each phone, tablet, or computer, using the procedure below.
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
.p12file 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.
- Choose a local hostname, such as
lan.ha.example.com, and a public hostname, such asha.example.com. - 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.
- 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.comgoes 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.
- 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.
- In Home Assistant, go to Settings → Apps → Install app and install Let’s Encrypt (addons/letsencrypt at master · home-assistant/addons · GitHub).
- I find YAML easier: on the app’s Configuration tab, click the three-dot menu and select Edit in YAML.
- 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.
- Start the app. It writes the certificate and key to
/ssl/fullchain.pemand/ssl/privkey.pem, then stops. - Check the app log.
Stop here unless the log confirms that certificate issuance succeeded and both
/ssl/fullchain.pemand/ssl/privkey.pemwere 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.comresolves 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
- In the Cloudflare dashboard, go to Networking → Tunnels.
- 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 nohttps://—the route itself is configured below. - On the connector setup page, choose an environment so Cloudflare displays an installation command.
- Copy the displayed command. Find
--tokenin 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
-
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.
-
Open the Cloudflared app configuration.
-
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.
-
Save the configuration and start the app.
-
Check the app log and confirm that it connected successfully.
-
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
- Open the tunnel in Cloudflare and select Routes.
- Choose Add route → Published application.
- Set the public hostname, for example
ha.example.com. - Set the service URL to
https://lan.ha.example.com:443. - Under Additional application settings → TLS, leave No TLS Verify disabled.
- 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:
- Go to SSL/TLS → Client Certificates.
- In Hosts, associate the public Home Assistant hostname with mTLS if it is not already associated.
- Choose Add Certificate.
- Use the Cloudflare-managed certificate authority.
- Choose Use my private key and CSR and paste the CSR already on the clipboard.
- Select a suitable validity period and create the certificate.
- Copy the complete certificate Cloudflare returns, including the
BEGIN CERTIFICATEandEND CERTIFICATElines.
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:
- Go to Your Domain → Security → Security rules.
- Select Create rule → Custom rules.
- Give the rule a clear name such as
Require mTLS for Home Assistant. - 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")
- 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.
- Go to Settings → System → Network and find Home Assistant URL.
- Under Internet, leave Use Home Assistant Cloud off and enter
https://ha.example.com. - Under Local network, turn Automatic off and enter
https://lan.ha.example.com. - 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.
- Transfer the
.p12file securely to the device. - In the Home Assistant app, open Settings → Companion app, select the server, and open its connection settings.
- Find Client Certificate, import the
.p12file, and enter its export password. - Set the internal URL to
https://lan.ha.example.com. - 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.
- Open the companion app once and confirm that it works through the external URL.
- Do not force-quit the app. Put it in the background and turn off Wi-Fi so the phone uses cellular data.
- In Home Assistant, record the current timestamps or values of the phone’s location and companion-app sensor entities.
- Run this action, replacing the notification action with the one for your phone:
action: notify.mobile_app_your_phone
data:
message: request_location_update
- Wait at least five seconds, then verify that one or more relevant entities received a new
last_updatedtimestamp or changed data. - Check
sensor.last_update_triggeras supporting evidence: on iOS a requested update reportsPush 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_revokedclause 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.comandha.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
.p12inside 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 includeslan.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,
.p12files, 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
.p12locally. - 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.

