Tutorial 2026-04-17 · ~17 min read

Secure Clash Meta External Controller: Bind-Address, Secret, and Firewall Steps

Modern Clash Meta (Mihomo-class) cores expose a compact REST and WebSocket control plane on external-controller. GUI clients and web dashboards use it to switch modes, reload profiles, and stream logs. That convenience becomes a liability when the listener is reachable from your LAN—or worse, the public internet—without authentication. This guide walks through bind-address choices, setting a secret (Bearer token), verifying behavior with curl, and layering host and network firewalls so remote management stays intentional rather than accidental.

What external-controller is (and what it is not)

The external-controller field points the core at a TCP address such as 127.0.0.1:9090 where it serves the external control API. Companion UIs connect there to read runtime state and submit changes. It is not the same socket as your mixed-port HTTP/SOCKS inbound for browsers—confusing the two is how people open the wrong port in a firewall rule or paste proxy settings into a dashboard URL field.

On Windows, some distributions also document external-controller-pipe for local IPC; your YAML might show a pipe name while Linux/macOS stay on TCP. Regardless of transport, the security question is identical: who can invoke management verbs on your running core and whether they must prove possession of a shared secret.

If you run Meta inside Docker or systemd on a headless box, the controller is often the only ergonomic way to reload subscriptions without SSH editing. That operational convenience makes hardening the listener a first-class task, not an advanced footnote. Treat any host that answers on the controller port as holding the same authority as the account that launched Clash.

Threat model: an unauthenticated controller

When secret is empty or missing, many builds accept unauthenticated requests from any client that can complete a TCP handshake to the bound address. On a typical laptop that still feels harmless because the default bind is loopback. The moment you set 0.0.0.0, a wildcard interface bind, or forward the port through Docker without publishing restrictions, you have potentially granted configuration authority to every device that can route packets to that socket.

Attackers on the same café Wi-Fi should not be your only concern. Malware on a trusted gaming PC, a compromised IoT camera, or a roommate’s infected laptop on the same subnet may attempt to scan for open controller ports, especially if you previously enabled LAN proxy sharing and loosened personal firewall profiles. The controller is more sensitive than a passive SOCKS port because it can change global policy, not merely relay bytes.

Plain HTTP control plane

Traffic between a browser and external-controller is usually not TLS unless you terminate HTTPS elsewhere. Anyone on the same broadcast domain can observe management calls unless you tunnel through SSH, WireGuard, or a reverse proxy with modern TLS. Secrets reduce casual abuse but do not hide metadata from a passive observer on the LAN.

Choosing bind-address for localhost, LAN, or WAN

Clash separates which address families listen from whether LAN clients may connect. A conservative baseline for everyday desktop use is 127.0.0.1:<port> on the controller, keeping dashboards and local APIs on loopback while your proxy listeners follow their own allow-lan policy. That pattern mirrors how database administrators bind admin ports to localhost even when the service port faces clients.

When you genuinely need a phone or second PC to open the web UI, bind to your host’s private IPv4 or use a dual-stack explicit form supported by your build. Wildcard IPv4 binds (0.0.0.0) are convenient for Docker because they make port publishing predictable, but they also enlarge the blast radius—pair them with published port maps that only face an internal bridge network or with rigid firewall rules on the host. Never forward 9090 straight to the internet on a home router without additional controls.

IPv6 introduces another listener surface. If your network advertises global unicast addresses to workstations, binding to [::] may unintentionally expose the service beyond the subnet you had in mind. If you are still learning the topology, start with IPv4 literals, confirm behavior, then widen carefully.

Bind pattern Typical use Risk note
127.0.0.1:9090 Local GUI, CLI, scripts on the same machine Lowest exposure; combine with SSH port forwarding for remote use
192.168.x.x:9090 LAN tablet or browser dashboard Requires LAN trust; still set secret
0.0.0.0:9090 Containers, multi-interface servers Must align with Docker networks and firewall zones

Setting secret and sending Authorization: Bearer

Assign a long, random secret string in your configuration. Meta-class cores expect clients to present it as an HTTP header: Authorization: Bearer <secret>. Shorter secrets are easier to leak through screenshots, shell history, or screen sharing; generate at least 32 high-entropy bytes with a password manager or openssl rand -hex 32 and store the value in a file with restrictive permissions on servers.

After you set secret, expect 401 responses for unauthenticated calls. Some dashboards let you paste the token once into settings; others read it from a packaged config. If your UI suddenly cannot connect after hardening, verify you restarted the core, that no secondary merge file stripped the field, and that the UI points at the same host-port pair you edited.

Rotate like a password

If you suspect disclosure, change secret, reload, and update every dashboard instance. Unlike proxy passwords for family devices, controller tokens rarely need to be memorable—optimize for entropy and uniqueness per host.

Step-by-step YAML you can paste and adapt

Align keys with your distribution; the following skeleton is representative for Meta-style configs running on a workstation where only local management should be reachable:

# Local-only controller with authentication (example)
external-controller: 127.0.0.1:9090
secret: "replace-with-long-random-token"
# Optional: tune CORS if a web UI is served from another origin
# external-controller-cors:
#   allow-private-network: true

For a trusted LAN browser on 192.168.1.50 hosting the Clash machine at 192.168.1.10, you might temporarily use external-controller: 192.168.1.10:9090 together with the same secret, then tighten the personal firewall to allow TCP 9090 only from 192.168.1.0/24. Document the port in the same place you document mixed-port sharing so future you does not mistake it for SOCKS.

Reload discipline

  1. Edit YAML on disk or through your GUI’s advanced editor.
  2. Validate there is only one secret key in the merged runtime view.
  3. Restart or hot-reload per client documentation; confirm logs show the listener address.
  4. Run the curl checks in the next section from both localhost and a LAN peer.

Verify with curl before you trust a browser

From the Clash host, a minimal version probe might look like the following (adjust port and path to match your core):

# Without secret (should fail after hardening)
curl -sS http://127.0.0.1:9090/version

# With secret
curl -sS http://127.0.0.1:9090/version \
  -H "Authorization: Bearer YOUR_SECRET_HERE"

If the first command still returns JSON after you believe you enabled secret, you are either hitting a stale process, a different port, or a config merge that dropped the field. Use ss -lntp on Linux, netstat -ano on Windows, or lsof -iTCP:9090 -sTCP:LISTEN on macOS to confirm which PID owns the socket, then reconcile with the YAML your supervisor actually loaded.

When testing from a second device, replace 127.0.0.1 with the host’s LAN IP. Timeouts usually mean host firewall, wrong bind address, or AP isolation—not a bad token. Connection refused means nothing is listening on that interface-port pair.

Firewall and network layering

Host firewall first. On Windows, create an inbound rule scoped to Private networks that allows TCP on your controller port only from RFC1918 ranges you recognize—or, better, from a single admin workstation IP. On Linux with UFW, prefer a rule such as allow from 192.168.1.0/24 to any port 9090 proto tcp over a global open. macOS application firewalls should allow your Clash binary to receive connections, but you still want the smallest audience possible.

Router and perimeter. Do not port-forward the controller through NAT for convenience. If you must manage a remote home server, use a VPN overlay (WireGuard tailnet, site-to-site tunnel) so the controller stays unroutable from the raw internet. Consumer routers rarely offer application-layer filtering fine enough to save you from exposing administrative APIs.

Docker and compose. When following the Docker Compose deployment guide, map 9090:9090 only onto internal networks unless you explicitly need host LAN access. Combine user-defined bridge networks with iptables or firewalld on the host so another container cannot unexpectedly reach the controller socket.

systemd and VPS deployments. For unattended servers documented in the Linux systemd article, prefer 127.0.0.1 binds plus SSH local forwarding (ssh -L 9090:127.0.0.1:9090 user@host) for remote dashboards. That pattern reuses mature SSH authentication instead of inventing a new exposure on 0.0.0.0.

Web dashboards, CORS, and mixed content

Browser-based panels fetch the API from JavaScript. If the HTML is served from a file:// origin or a different host than the controller, you may need external-controller-cors settings (exact keys vary by release) so preflight requests succeed. This is a usability feature, not a substitute for secret—misconfigured CORS can block your UI while leaving naive curl clients unaffected, so debug network tabs and console errors alongside API logs.

Mixed content warnings appear when an HTTPS page tries to call an HTTP controller. The durable fixes are: serve the UI over HTTPS and terminate TLS at a reverse proxy, or keep everything on trusted localhost with forwarding. Band-aid browser flags are the wrong long-term answer.

Optional: reverse proxy and TLS termination

Teams that outgrow SSH port forwarding sometimes place nginx or Caddy in front of the controller, enforce mutual TLS or OIDC at the edge, and keep the upstream bind on loopback. Document the extra hop—timeouts, header sizes, and WebSocket upgrade headers all need attention. If that sounds heavy, you probably only needed SSH -L and a strong secret to begin with.

FAQ

Is external-controller the same port as mixed-port?

No. They are different listeners with different purposes. Firewall and documentation should list both explicitly.

Can I skip secret on localhost?

Technically many setups work without it on 127.0.0.1, but setting a token anyway protects against local malware that expects default-insecure controllers and reduces accidents when you temporarily widen the bind.

I need WAN access for emergencies—what is the least bad approach?

Use a VPN into your home network or a zero-trust overlay, then connect to the private IP. Avoid naked port forwarding.

I get 401 even with the right token

Check for trailing newline characters when pasting secrets, multiple conflicting Authorization headers, or a reverse proxy stripping headers.

Hardening checklist

  1. Confirm controller port vs proxy ports in YAML and in firewall rules.
  2. Bind to the narrowest address that still meets your workflow.
  3. Set a long random secret; verify 401 without it.
  4. Restrict inbound TCP with OS firewall and sane network design.
  5. Prefer VPN/SSH forwarding over internet port publishing.

Use a client that makes security defaults obvious

Whether you manage Clash from a polished GUI or a headless unit file, readable configs and clear listener logs keep mistakes visible. If you are new to the ecosystem, start with the beginner guide before you expose management interfaces.

Download Clash for free and experience the difference

Lock the control plane first

Bind external-controller deliberately, set a Bearer secret, prove it with curl, then add firewall rules that match your real topology.

Download Clash