tmux-next has no auth of its own and binds only to 127.0.0.1. To reach it
from your phone or the internet, put a reverse proxy in front to do two things: HTTPS,
and a login. This guide uses Caddy — it obtains
and renews certificates automatically, and the config is short.
Understand the risk first. Anyone who can reach this service can run commands on your machine — it hands out a shell. Treat access to it like SSH access: auth is mandatory, the password must be strong, HTTPS only.
tmux.example.com (Caddy will get a certificate for it automatically).127.0.0.1:7682 (the default port).A hash of your login password, and a random secret for the WebSocket (the next step explains why it is needed):
# password hash — put this in the Caddyfile, never a plaintext password caddy hash-password --plaintext 'your-strong-password' # random secret for the WebSocket — generate one and keep it openssl rand -hex 24
The first prints a $2a$… bcrypt hash; the second prints a random hex string.
You will use both shortly.
Here is a complete, working config. Replace the three placeholders with your own values:
tmux.example.com {
# WebSocket: authenticated by cookie instead — see the next step for why
@ws path /ws
handle @ws {
@noauth not header Cookie *tn_auth=your-random-secret*
respond @noauth 403
reverse_proxy 127.0.0.1:7682
}
# everything else: Basic Auth, and on success plant the cookie
handle {
basic_auth {
your-username $2a$…your-password-hash
}
header +Set-Cookie "tn_auth=your-random-secret; Path=/; Secure; HttpOnly; SameSite=Strict"
reverse_proxy 127.0.0.1:7682
}
}
The three placeholders: tmux.example.com becomes your domain;
both copies of your-random-secret become the string from openssl in step 2
(they must match); your-username and $2a$… become your login name and the
password hash from step 2.
This is the easiest thing to get wrong, and the easiest way to leave a hole. The terminal's
live data goes over a WebSocket (the /ws path), and a browser sends no Basic
Auth header on a WebSocket handshake. So if you protect the whole site with Basic Auth
alone, the pages look locked, but /ws can't connect because it never receives the
credentials — or worse, you open /ws up to make it work, and the terminal is wide
open to anyone.
The config above sidesteps that: once a normal request passes Basic Auth, the response
plants a cookie; a browser does send cookies on a WebSocket handshake, so the
/ws block admits it by cookie. That is why the random secret must stay secret — it
is effectively a key, and the Secure and HttpOnly flags keep it to HTTPS
and out of reach of scripts.
caddy reload --config /path/to/Caddyfile
Then check three things from the command line (swap in your domain):
# pages require auth → expect 401 curl -so /dev/null -w "%{http_code}\n" https://tmux.example.com/ # WebSocket with no cookie → expect 403 curl -so /dev/null -w "%{http_code}\n" https://tmux.example.com/ws # with the right cookie → expect 400 (passed auth, reached the service) curl -so /dev/null -w "%{http_code}\n" \ -H "Cookie: tn_auth=your-random-secret" https://tmux.example.com/ws
401 and 403 prove both doors are locked; the cookie request returning
400 is expected — that's the service saying "this isn't a valid WebSocket handshake,"
but the request reached it, which means auth let it through. All three correct, and you can open
the domain on your phone and log in.
Different machine or domain? This config doesn't depend on any particular
system. As long as tmux-next listens on 127.0.0.1:7682 somewhere and Caddy can
resolve your domain, copy it as is. For start-on-boot (launchd / systemd) and more, see
deploy.md in the repo.
The Basic Auth above is rough in two known ways: the login box is the browser's native popup, which can't be styled; and the cookie secret is static and never expires, so a leak means editing the config by hand. For a proper login page plus tokens that expire on their own, use the caddy-security plugin — it solves both.
It isn't in standard Caddy; build a version that includes it with xcaddy:
xcaddy build --with github.com/greenpau/caddy-security
The config is then three parts. First generate a signing key (the JWT is signed and verified with it; a leak means anyone can forge a login):
openssl rand -hex 32
1. The global block — defines the user store, the login portal, and the authorization policy:
{
# these two order the plugin's middleware correctly (copy as is, unrelated to whether the site uses basicauth)
order authenticate before respond
order authorize before basicauth
security {
# user store: passwords are bcrypt hashes, never plaintext
local identity store localdb {
realm local
path /path/to/users.json
user your-username {
name Your Name
email you@example.com
password "bcrypt:10:$2a$10$…your-password-hash"
roles authp/user
}
}
# login portal: issues a JWT that expires
authentication portal myportal {
crypto default token lifetime 28800 # token lifetime in seconds (8 hours here)
crypto key sign-verify your-signing-key
enable identity store localdb
# share the JWT cookie across the auth. and tmux. subdomains
cookie domain example.com
}
# authorization policy: verify the JWT, send anyone without one to the portal
authorization policy tmuxpolicy {
# across subdomains this must be an absolute URL to the portal
set auth url https://auth.example.com/auth/
crypto key verify your-signing-key
allow roles authp/user
}
}
}
2. The portal site block — this hosts the custom login page:
auth.example.com {
route /auth* {
authenticate with myportal
}
}
3. The tmux-next site block — protected by the policy, redirecting to the login page when unauthenticated:
tmux.example.com {
# every path (WebSocket included) requires a valid JWT first
authorize with tmuxpolicy
reverse_proxy 127.0.0.1:7682
}
Why no separate WebSocket rule this time? The JWT lives in a cookie, and a
browser does send cookies on a WebSocket handshake — same idea as the cookie in
step 4, except the cookie now holds a signed, expiring JWT rather than a static secret.
authorize checks every path uniformly, /ws included, so there's nothing
extra to write.
Keep the two keys straight, and secret. A leaked signing key
(crypto key) lets anyone forge any login; the password hash is made with
caddy hash-password and goes into password prefixed with
bcrypt:10:. When the token lifetime is up you log in again — which is
exactly why it beats a static cookie: a captured token only works until it expires.
caddy-security can also do GitHub / Google login (OAuth) and two-factor auth (TOTP / Yubikey). Given this service hands out a shell, 2FA is well worth it. Full options are in its documentation.