Security model
Port Zero runs a privileged daemon on your machine and, optionally, connects it to a cloud edge. This page states what the daemon reads, what it sends, why it needs elevated privileges, and how the local certificate authority is constrained.
What the daemon reads
To map a running process or container to a *.portzero.local name, the daemon scans
local processes. For each one it reads two things.
Process environment variables
The daemon looks for PZ_TUNNEL and its optional companions (PZ_TUNNEL_HTTP_PORT,
PZ_TUNNEL_PORTS, PZ_HEALTH_PATH, plus PWD for the working directory), reading
each process’s environment block the same way on every platform: the kernel’s frozen
copy of the environment captured at process start.
The scan reads the whole environment block into memory transiently but keeps only
the value of the specific PZ_TUNNEL* variables it’s looking for; the rest is
discarded, never stored, never logged, never transmitted. A process with no
PZ_TUNNEL variable is ignored.
Listening TCP ports
To learn which port a discovered process or container listens on, the daemon reads its listening TCP sockets. Only the port numbers are used, to build the local reverse-proxy route. No socket payload is read.
What leaves the machine
Logged out (no cloud account)
Nothing leaves the machine from the daemon. There is no analytics, no telemetry, and no phone-home in the long-running daemon:
- Tunnel requests are forwarded to
http://127.0.0.1:<port>— loopback only. - The local dashboard is probed over the overlay — local only.
- Authentication refreshes a stored token against the cloud API, but only when a token is already stored; logged out, this never runs.
The cloud connection itself is gated on an auth token loaded from disk — with no token, no connector is created and no bytes are sent to any Port Zero server.
One honest exception, and it’s the CLI front-end, not the daemon: running the
portzero command spawns a once-daily version check, a plain HTTP GET of a public
GitHub release asset. It sends no machine data beyond what any HTTP request reveals
(your IP and a User-Agent), runs at most once per 24 hours, and is disabled by setting
PZ_TUNNEL_NO_UPDATE_CHECK.
Logged in with cloud tunnels
When you log in and expose a Cloud tunnel, the daemon opens a WebSocket to
edge.portzero.net and transmits:
- Authentication — a message with your auth token, the client version, an OS string, and a machine identifier (your hostname plus a random suffix). The token is obtained at login and stored locally; it’s refreshed automatically.
- Tunnel registration metadata — for each exposed route: the tunnel domain, the local port, the protocol, and process metadata (working directory, executable path, process name, full command line). If you expose a Cloud tunnel, this process metadata leaves your machine. Local-only tunnels never register with the edge.
- The tunneled traffic itself — requests arriving from the internet are relayed
through the edge to your daemon, which forwards them to
127.0.0.1:<port>and relays the response back. This is the point of a tunnel: the traffic transits Port Zero’s edge.
Be clear about what that transit means: the edge terminates TLS and sees your
request and response plaintext. Cloud tunnels are not end-to-end encrypted. A
visitor’s HTTPS connection ends at the edge; the edge relays the decoded request to
your daemon over a separate encrypted WebSocket. This is deliberate — per-tunnel
authentication, IP allow-listing, and path routing all require reading the request.
When feedback comments are enabled on your plan, the edge also modifies HTML
responses: it injects the comment widget’s script tag before </body> (and strips
accept-encoding so it can parse the page). For custom domains, the edge itself
holds the certificate’s private key and terminates TLS directly (certificates are
issued via ACME DNS-01). The edge does not log or store request or response bodies;
it keeps only aggregate request counts and byte totals per domain. If you need
nobody-in-the-middle guarantees, serve your own TLS behind the tunnel or keep
secrets out of shared dev tunnels.
Why the daemon needs elevated privileges
The Local overlay creates a virtual network interface, installs a route, binds a DNS server on a privileged port, and writes OS resolver configuration — all of which require elevated privileges. See Platform privileges for the exact operations, the per-platform requirements, and what degrades when privileges are missing.
The local certificate authority
To serve https://*.portzero.local without browser warnings, Port Zero generates a
local certificate authority (CA) and installs its certificate into the OS trust store
(and into Firefox/Chromium NSS databases). This is the part of the design that most
deserves scrutiny, because a CA in your trust store is a powerful thing.
The CA private key is never written to disk
The CA private key is generated in memory, used once to self-sign the CA certificate
and to sign the *.portzero.local wildcard certificate, and then dropped. It is never
serialized to disk. The only private key persisted is the wildcard leaf key, which
can impersonate only *.portzero.local names in the first place.
Persisted files live in your platform’s application data directory:
| File | Contents | Permissions |
|---|---|---|
ca.crt | CA certificate (public) | default |
wildcard.crt | Wildcard certificate (public) | default |
wildcard.key | Wildcard private key | 0600 on Unix (owner read/write only) |
Name constraints
The CA certificate carries a critical X.509 Name Constraints extension (RFC 5280 §4.2.1.10), so a conforming verifier will reject any certificate the CA signs that steps outside its lane:
- Permitted DNS subtree
portzero.local. Per RFC 5280 this matches the apex name and every subdomain (portzero.local,app.portzero.local,a.b.portzero.local), and nothing else. The CA cannot mint a trusted certificate forexample.com. - Excluded IP address space
0.0.0.0/0and::/0. A DNS permitted subtree alone doesn’t constrain a leaf that presents only an IP-address SAN, because RFC 5280 applies constraints per name-form; excluding the entire IP space closes that bypass. Port Zero never issues IP certificates, so this costs nothing.
You can verify it yourself:
openssl x509 -in "$DATA_DIR/ca.crt" -noout -text | grep -A4 "Name Constraints"
Expect X509v3 Name Constraints: critical, a Permitted DNS:portzero.local, and an
Excluded IP:0.0.0.0/0.0.0.0.
Honest caveats
- Not every trust store enforces name constraints. Modern browsers (Chrome/Chromium, Firefox, Safari) and the OpenSSL and macOS/Windows platform verifiers do enforce them. But enforcement lives in the verifier, not the certificate: a client that ignores the extension gets no protection from it. Name constraints are defense-in-depth, not a substitute for keeping the trust store honest.
- Constraints bound DNS and IP identities only. A verifier doing TLS server authentication matches a site against DNS and IP SANs, which is exactly what we constrain. Other SAN forms (email, URI) aren’t name-forms a browser uses for server identity, so they aren’t an HTTPS MITM vector.
- The CA key would still matter if extracted at generation time. Because the key exists in daemon memory only during generation, an attacker would need to read that memory in that window. If they did, the name constraints still limit what the stolen key can forge.
Migration from earlier, unconstrained CAs
Port Zero originally shipped an unconstrained CA. If you installed one:
portzero trust generatedetects a CA that lacks name constraints, regenerates the whole bundle in place, and tells you to re-runsudo portzero trust install. The old, unconstrained anchor stays trusted until you do.portzero trust installremoves any previously installed “PortZero Local CA” anchor before adding the new one, so you don’t accumulate stale roots.- The daemon never regenerates the CA on its own. If it loads an unconstrained CA it
logs a one-line warning pointing at
portzero trust generate && sudo portzero trust install, because silently swapping the CA would break trusted HTTPS until you reinstalled it.
Reporting a problem
If you find a way for the daemon to send data it shouldn’t, or a way for the
constrained CA to sign something outside *.portzero.local, please report it via the
GitHub repository.