Your Cloudflare Tunnel Token Is in the Process List
Table of Contents
I put my NAS behind a Cloudflare Tunnel so I could reach it from anywhere without opening a port. The Cloudflare dashboard gave me one line for Docker. I pasted it, the connector came up healthy, and I moved on to the next thing.
Later I ran ps auxww on the NAS to check on the container. There, in the middle of the process list, sat the tunnel token: the whole eyJ... string, in plain text. That string is the credential for the entire tunnel, and the setup page had told me to put it there.
The command the dashboard hands you #
Create a tunnel under Networking, then Tunnels, choose Docker, and this is what you get:
docker run cloudflare/cloudflared:latest tunnel --no-autoupdate run --token eyJhIjoi…<redacted>Read the amber box: “This token grants access to your account. Keep it secure and don’t share it.” Cloudflare knows the token is sensitive. Every option on the page then passes it as a command-line argument. Docker gets the line above. Debian, Red Hat and macOS get sudo cloudflared service install <token>, with cloudflared tunnel run --token <token> offered as the manual alternative. Windows gets cloudflared.exe service install <token>, run from an Administrator Command Prompt.
Where a command line ends up #
The setup page offers two kinds of command, and they leak in different ways.
The long-running ones, docker run and cloudflared tunnel run --token, keep the token in the process’s arguments for as long as the connector runs. Those live in /proc/<pid>/cmdline, and the proc(5) man page states the default: unless /proc is mounted with hidepid, any process on the host can read them. A container doesn’t hide it. Every service account on the host can read the token, and so can anything that compromises one of them. Containers started with --pid=host, the usual mode for monitoring agents, see it too. Docker also saves the arguments in the container’s definition, so docker inspect and every compose file, export and backup of that definition hold a copy.
The one-shot installs leak at install time. service install <token> runs for a few seconds, but its command line outlives it: your shell history keeps it, and sudo writes the full command, token included, to the system log (/var/log/auth.log on Debian, /var/log/secure on Red Hat, and the journal on both). On Windows, when process-creation auditing includes command lines, event 4688 records it in the Security log, and Microsoft warns that anyone who can read those events can read the arguments. Endpoint detection tools such as Microsoft Defender for Endpoint record process command lines and ship them to a central console. Once a secret is on a command line, you can’t say where else it went.
A stolen token puts the attacker in front of your origins #
Decode the token and you get a small JSON object: your account ID, the tunnel ID, and the tunnel secret. Cloudflare’s tunnel token reference is blunt about what that means: anyone with the token can run the tunnel.
So an attacker runs cloudflared on a server of their choosing, and it joins your tunnel as one more replica. Cloudflare allows up to 25 replicas per tunnel and doesn’t load-balance them; per its configuration docs, traffic goes to the geographically closest replica. An attacker who rents a server near your users wins those users’ requests, for every hostname on the tunnel.
Their connector then acts as your origin. cloudflared is open source, so a modified build can send those requests anywhere: a phishing copy of your login page, a relay to your real service that keeps every password and session cookie it sees, a response with a script injected into it. Cloudflare still terminates TLS at its edge, so the browser shows your hostname and a valid certificate while a stranger’s server writes the page.
The rogue connector gets more than requests:
- A remotely managed tunnel pushes its full configuration to every connector, so the attacker learns each public hostname and the internal address and port behind it.
- Cloudflare Access checks every request at the edge, so a request without a valid Access session never reaches any replica. Signed-in users’ requests do, carrying their Access token in the
Cf-Access-Jwt-Assertionheader and, from a browser, usually theCF_Authorizationcookie too. The optional Binding Cookie, which blocks reuse of a stolen cookie, is off by default; without it, that token works until the session expires or you revoke it. - If the tunnel also carries private IP or hostname routes for Cloudflare’s device client, the attacker’s connector serves those too; Cloudflare says all replicas serve the same routes.
The token can’t edit the tunnel’s configuration, add DNS records, or touch anything else in the account; those need an API token. Every attack above works without one.
Don’t count on an alert. The tunnel stays healthy because your own connector is still connected, and Cloudflare’s tunnel notifications are built around health and deployment status changes. The evidence is there if you look: the connector list (cloudflared tunnel info <tunnel>) shows each connector’s ID, version and origin IP, and the account audit log records a Registered event each time a connector joins. An origin IP you don’t own means someone else holds your token.
Rotating the token doesn’t evict the attacker #
Cloudflare’s docs say that after you rotate a token, cloudflared can’t open new connections with the old one, and existing connectors remain active until restarted. The attacker’s connector restarts when the attacker decides, so it keeps serving your traffic after the rotation until you delete its connection.
If your connector ever ran with --token on the command line, treat the token as exposed and work through this in order:
- Rotate the token on the tunnel’s page in the dashboard (Networking, Tunnels, your tunnel, Rotate token).
- Delete every connection with
cloudflared tunnel cleanup <tunnel>, or with an API token and aDELETEon the tunnel’sconnectionsendpoint. Do this after rotating, or the rogue connector reconnects with the old token. - Start your connectors with the new token from a file, as shown for your platform below.
- Check the connector list and confirm every connector ID and origin IP is yours.
- Clean up the copies: shell history, old containers and compose files, exports, and backups. If Access protects the hostnames, revoke active sessions, turn on Binding Cookie, and rotate the passwords for the apps behind the tunnel.
Secure setup by platform #
One rule covers every platform: the token lives in a file that only the connector’s account and administrators can read, and cloudflared reads it with --token-file (version 2025.4.0 or later). Don’t move it into the TUNNEL_TOKEN environment variable instead. The kernel lets only the same user and root read /proc/<pid>/environ, but the value still lands in service and container definitions, and it takes precedence over --token-file, so cloudflared uses the variable and never reads the file.
For the three service installs, cloudflared already writes that file. Since 2026.7.2 on Linux and macOS and 2026.7.3 on Windows, service install <token> saves the token to disk and starts the service with --token-file. The leak is the install command, so treat the first token as burned: install, rotate, and put the new token straight into the file.
Linux (Debian and Red Hat) #
- Run the dashboard’s
sudo cloudflared service install <token>with cloudflared 2026.7.2 or later. Older installers put--tokenin the service’s own command line. - Rotate the token in the dashboard.
- Open
/etc/cloudflared/token(mode0600, owned by root) in an editor withsudo, replace its contents with the new token, and save. - Run
sudo systemctl restart cloudflared, then delete the old connections as in the runbook. - Remove the install line from your shell history.
Skip the manual cloudflared tunnel run --token option. If you run cloudflared by hand, point --token-file at a root-only file.
macOS #
The same sequence, with 2026.7.2 or later from Homebrew. Run as root, the installer writes the token to /Library/Application Support/com.cloudflare.cloudflared/token with mode 0600. Replace the token there with sudo and an editor, restart with sudo launchctl kickstart -k system/com.cloudflare.cloudflared, and clear the install line from ~/.zsh_history.
Windows #
Use cloudflared 2026.7.3 or later. Earlier versions left the token in the service’s registry entry, readable by any local user. The installer writes %ProgramData%\cloudflared\token and restricts it to SYSTEM and Administrators. Rotate the token, open the file in Notepad run as Administrator, paste the new token, save, and run Restart-Service Cloudflared from an elevated PowerShell. If your fleet audits command lines (event 4688, Defender for Endpoint), the first token is in those logs, which is exactly why you rotate it.
Docker and Docker Desktop #
Never paste the dashboard’s docker run line. On a Linux Docker host such as my NAS, this script keeps the token in a 0400 file inside a 0700 folder, owned by UID 65532 (the distroless nonroot user the image runs as). It reads the token from standard input, so it never touches shell history or the process list, and runs the container with a read-only filesystem, no Linux capabilities, and a pinned image tag, because whatever image runs can read the token however you supply it:
#!/usr/bin/env bash
# Run a Cloudflare Tunnel connector with its token in a file, not on the command line.
# Usage: sudo ./run-cloudflared.sh /path/to/secrets (paste the token on stdin, then Ctrl-D)
set -euo pipefail
dir="${1:?usage: run-cloudflared.sh <parent-dir>}/cloudflared"
image="${CLOUDFLARED_IMAGE:-cloudflare/cloudflared:2026.10.7}" # pinned; update on purpose
uid=65532 # the image's distroless "nonroot" user
umask 077
mkdir -p "$dir"
chmod 0700 "$dir"
# read and printf are shell builtins: the token never becomes a process argument.
IFS= read -rs token || true
[[ -n "$token" ]] || { echo "no token on stdin" >&2; exit 1; }
printf '%s' "$token" > "$dir/token"
unset token
chmod 0400 "$dir/token"
chown "$uid:$uid" "$dir" "$dir/token"
docker run -d --name cloudflared --restart unless-stopped \
--user "$uid:$uid" \
--read-only --cap-drop ALL --security-opt no-new-privileges \
-v "$dir/token:/etc/cloudflared/token:ro" \
"$image" tunnel --no-autoupdate run --token-file /etc/cloudflared/token
On Docker Desktop for macOS or Windows, or anywhere you use Compose, make the token a Compose secret. Compose mounts the file read-only at /run/secrets/tunnel_token, and the command line and docker inspect show only that path:
# cloudflared on Docker Desktop (or any Docker host) with the token as a Compose secret.
# Write the token to ./tunnel-token with an editor, never as a command argument, then:
# chmod 400 tunnel-token # and keep it out of git and synced folders
# Compose mounts it read-only at /run/secrets/tunnel_token; cloudflared reads it there.
services:
cloudflared:
image: cloudflare/cloudflared:2026.10.7 # pinned; update on purpose
command: ["tunnel", "--no-autoupdate", "run", "--token-file", "/run/secrets/tunnel_token"]
secrets:
- tunnel_token
user: "65532:65532" # the image's distroless "nonroot" user
read_only: true
cap_drop: [ALL]
security_opt: ["no-new-privileges:true"]
restart: unless-stopped
secrets:
tunnel_token:
file: ./tunnel-token
Keep tunnel-token out of git and out of synced or backed-up folders. If cloudflared reports Failed to read token file, the container user can’t read the host file; on Linux and macOS hosts, sudo chown 65532 tunnel-token fixes it. Either way, check that docker inspect shows a path and nothing starting with eyJ.
Kubernetes #
Cloudflare’s own Kubernetes guide stores the token in a Secret and injects it as TUNNEL_TOKEN. Mount the Secret as a read-only file instead:
# Cloudflare Tunnel connector on Kubernetes, token read from a mounted Secret file.
# The Secret below shows the shape. Create the real one from a file instead:
# kubectl create secret generic cloudflared-token --from-file=token=./token
# Don't use --from-literal: it puts the token back in your shell history and process list.
# Don't kubectl apply a filled-in copy either: apply stores the plaintext in the
# last-applied-configuration annotation.
apiVersion: v1
kind: Secret
metadata:
name: cloudflared-token
type: Opaque
stringData:
token: "<paste-tunnel-token>" # placeholder only; do not commit a real token
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: cloudflared
labels:
app: cloudflared
spec:
replicas: 2 # two connectors so one pod can restart without dropping the tunnel
selector:
matchLabels:
app: cloudflared
template:
metadata:
labels:
app: cloudflared
spec:
automountServiceAccountToken: false # cloudflared never calls the Kubernetes API
enableServiceLinks: false
securityContext:
runAsNonRoot: true
runAsUser: 65532 # the image's distroless "nonroot" user
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: cloudflared
image: cloudflare/cloudflared:2026.10.7 # pinned; update on purpose
args:
- tunnel
- --no-autoupdate
- --metrics
- 0.0.0.0:2000 # tunnel-level flag, so it goes before "run"; see the NetworkPolicy
- run
- --token-file
- /etc/cloudflared/token/token
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: [ALL]
volumeMounts:
- name: tunnel-token
mountPath: /etc/cloudflared/token
readOnly: true
livenessProbe:
httpGet:
path: /ready # 200 only while cloudflared holds a connection to Cloudflare
port: 2000
initialDelaySeconds: 10
periodSeconds: 10
failureThreshold: 3
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 500m
memory: 256Mi
volumes:
- name: tunnel-token
secret:
secretName: cloudflared-token
# Secret files are owned by root. With fsGroup set, the kubelet makes 65532 the
# group owner and adds group read, so 0400 would come out as 0440 anyway.
# 0440 states the real result: root and group 65532 read, everyone else nothing.
defaultMode: 0440
items:
- key: token
path: token
---
# cloudflared only dials out. Its metrics port also serves /config, which lists every
# hostname and internal origin address, so allow no inbound traffic to these pods.
# Kubelet probes still work: traffic from the pod's own node is always allowed.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: cloudflared-deny-ingress
spec:
podSelector:
matchLabels:
app: cloudflared
policyTypes: [Ingress]
With fsGroup: 65532, the kubelet makes that group the owner of the root-owned Secret file and adds group read, so the non-root container reads a 0440 file and nothing else in the pod can. Create the Secret with --from-file; --from-literal puts the token back on a command line. Kubernetes stores Secrets unencrypted in etcd by default, and anyone who can create a pod in the namespace can read them, so turn on encryption at rest and give cloudflared its own namespace with tight RBAC. The NetworkPolicy blocks inbound traffic, because cloudflared’s metrics port also serves its configuration, hostnames and origin addresses included.
Cloudflare fixed the service, not the setup page #
The release notes for cloudflared 2026.7.2 list “Linux service: Use –token-file instead of –token” and the same change for macOS; 2026.7.3 does it for Windows. The Linux commit gives the reason: the old installer left tokens “visible in the output of ps aux by unprivileged users.” On Windows the token sat in the service’s registry entry, where any unprivileged user could read it. Those fixes protect the running service. The setup page still tells every platform to pass the token as an argument, and the Docker and manual commands keep it there for the life of the process.
I build on Cloudflare because a tunnel gives a NAS in a closet the same front door as a production service: no open ports, a global edge, and the option of an Access policy on every hostname. That reach makes the default matter: the setup command is the first thing a new user runs, and I didn’t look at mine again until ps showed me the token. The fix is small: let the installer read the token from standard input, and hand Docker users the token-file version.
Until it does, don’t paste the command as given. Put the key in a file.