Skip to content

Auth

There are really two doors into RunWisp. Network clients — the Web UI and remote REST callers — go through a single password and session. Local clients that sit on the same box as the daemon — the TUI and the CLI — go in through a Unix socket. No secrets are stored on disk. The rule of thumb is: if you can log in over the network, or if you can already read the data directory, you control the daemon. RunWisp is built for one operator on one machine, and the auth model leans into that.

The CLI and TUI talk to the daemon over a Unix socket at <datadir>/runwisp.sock. The socket sits at 0600 inside a 0700 data directory, so only the user who started the daemon can reach it — same guarantee the SQLite file already relies on. As an extra belt to that suspenders, the daemon double-checks the peer UID at accept time with SO_PEERCRED on Linux and LOCAL_PEERCRED on macOS, and slams the door on anyone whose UID doesn’t match.

That’s why the CLI and TUI don’t need a password or a JWT — being on the local socket as the right user is the credential. Commands like runwisp, runwisp tui, runwisp list, and runwisp exec all connect through the socket automatically.

Point runwisp tui or runwisp exec at a --url (or set RUNWISP_URL) and they switch to the network path below instead — the daemon then authenticates them like any other network client.

If the socket isn’t there, or the CLI can’t reach it, you’ll see daemon not running at <datadir> and that’s it — no quiet fallback to some other transport. When a daemon is running but the CLI still can’t get through, it’s almost always a data-dir ownership or path mismatch, not a network problem.

The Web UI shows a single password field. Your password never goes across the network in the clear. Login is a challenge-response handshake: the browser asks the daemon for a one-time challenge, runs it together with your password through PBKDF2 (600,000 rounds of HMAC-SHA-256), and sends back the derived response the daemon can verify. The password itself never hits the wire, and the slow KDF makes a captured transcript expensive to brute-force offline. That’s defense in depth behind the encrypted channel, not a substitute for it — and whenever the daemon binds beyond loopback it serves HTTPS by default (see TLS), so the handshake rides an encrypted connection too.

Once you’re in, the daemon hands you a session inside a secure cookie. Set a new RUNWISP_PASSWORD and restart, and every existing session is gone.

When you use the TUI’s “Open in browser” action, the TUI mints a single-use launch ticket (60-second TTL) and the browser redeems it into a session cookie — so you land in the dashboard already logged in. Locally, the TUI mints over the Unix socket and the browser redeems on 127.0.0.1; the password never leaves the host. Over a runwisp tui --url … connection the ticket is minted from your already-authenticated session and redeemed against the remote daemon — minting always requires a valid session, and the ticket stays single-use and short-lived. If that connection is plain http:// to a non-loopback host, the TUI warns you first, because the ticket would cross the network unencrypted; https:// (a TLS proxy) closes that gap.

RunWisp doesn’t write the password — or the JWT signing key — to disk, ever. You’ve got two ways to set it:

  • Set RUNWISP_PASSWORD and the daemon picks it up from the environment, in memory only. This is what you want for Docker secrets, systemd’s LoadCredential=, or any sealed-secrets setup. As long as the value doesn’t change between restarts, your existing browser sessions survive a restart.
  • Leave it unset and the daemon generates a fresh random password every boot, again in memory only. Every restart rotates the password, which rotates the JWT key, which logs every browser out.

The JWT signing key isn’t stored anywhere — it’s derived from your password using HKDF-SHA-256, salted with a per-install fingerprint (machine ID, cwd, executable path, hostname). The fingerprint is generated once on first boot and persisted to the database, so the key survives restarts because the same fingerprint plus the same password yields the same key — not because anything sensitive was written to disk. Same password on the same install gives you the same key, so sessions persist across restarts. A different password — or the same password on a different machine — gives you a different key, so sessions can’t leak between installs.

To rotate, change RUNWISP_PASSWORD (or unset it to get a fresh random one) and restart. Everyone logged in over the network is out.

When the daemon makes up its own password (because you didn’t set RUNWISP_PASSWORD), the value never shows up in a log line, a startup banner, or anywhere on screen. There are two ways to pull it back out — and no Web UI tab here on purpose, since you need the password to get into the Web UI in the first place.

The Home view has a Password row showing ••…. Focus it and hit Enter — a copy modal pops up with the real value already on your clipboard.

The endpoint behind runwisp password only listens on the local Unix socket. Try it over the network — even with a valid session cookie — and you’ll get 403 Forbidden. Try it when the daemon was started with RUNWISP_PASSWORD already set, and you’ll get 404 Not Found. The daemon never hands out an operator-supplied password; if you set one, you’re the one responsible for stashing it — Docker secret, systemd LoadCredential=, vault, password manager, whatever you already use.

One thing worth flagging: runwisp password writes to stdout, which means it lands in your shell scrollback. If that matters, pipe it straight into a clipboard tool.

Sometimes a login wall is just in the way — a local dev container, a demo on your own laptop, a throwaway instance on an isolated network. For those cases you can switch auth off entirely:

Terminal window
RUNWISP_NO_AUTH=1 runwisp daemon

Set RUNWISP_NO_AUTH=1 (or true) and every /api/ route answers without a password, the Web UI skips the login screen, and a persistent Auth disabled badge sits in the Web UI header so the instance can never be mistaken for a secured one. The TUI’s Home page shows Password disabled (RUNWISP_NO_AUTH) for the same reason, and the daemon prints an unmissable warning banner at startup.

Be clear-eyed about what you’re switching off: anyone who can reach the port has full control of the daemon — browsing run history and logs, and triggering tasks that execute shell commands as the user who started it. Only use this for local development or on a trusted, isolated network. Never expose the port to the public internet, and if you need remote access without a login, put an authenticating reverse proxy in front instead.

A few sharp edges, sanded:

  • RUNWISP_NO_AUTH and RUNWISP_PASSWORD are mutually exclusive — the daemon refuses to start with both set, because a password that is never checked is worse than no password.
  • Any value other than 1 / true is a startup error, not a guess.
  • runwisp password exits with code 5 and an explanation — there is no password to print.
  • The local Unix socket and the optional cloud connection are unaffected; this switch only opens the local HTTP API and Web UI.

This is the headline use case. Inside a container the daemon has to bind 0.0.0.0 for port mapping to work, so pair the two:

Terminal window
docker run --rm \
-e RUNWISP_NO_AUTH=1 \
-p 127.0.0.1:9477:9477 \
-v ./runwisp.toml:/etc/runwisp/runwisp.toml:ro \
your-runwisp-image \
runwisp --config /etc/runwisp/runwisp.toml --host 0.0.0.0 daemon

Or in a compose.yaml:

services:
runwisp:
image: your-runwisp-image
command: runwisp --config /etc/runwisp/runwisp.toml --host 0.0.0.0 daemon
environment:
- RUNWISP_NO_AUTH=1
ports:
- "127.0.0.1:9477:9477"
volumes:
- ./runwisp.toml:/etc/runwisp/runwisp.toml:ro

Note the 127.0.0.1: prefix on the port mapping — it keeps the passwordless daemon reachable from your machine only, not from everything on your LAN. Drop it only when the network between you and the container is genuinely trusted.

Failed network logins are rate-limited per source IP. Cross the limit and you’ll get 429 Too Many Requests until the window slides past. Restarting the daemon resets the counter. The local-socket path is exempt.

Live log streams are capped too — a few dozen open at once, with a smaller per-IP cap — so one runaway script can’t exhaust the daemon on its own. Hit the cap and you’ll get 503 Service Unavailable; close some open log views and try again.

RunWisp binds to 127.0.0.1:9477 by default and serves plain HTTP there. Bind beyond loopback and it self-signs and serves HTTPS on its own — enough for a LAN or a tailnet, no proxy required. Where a reverse proxy still earns its keep is a publicly-trusted certificate: put it on the public internet behind nginx, Caddy, Traefik, or a Cloudflare Tunnel, let the proxy own TLS, set tls = "off" so RunWisp serves plain HTTP on its private side, and tell the daemon which IP ranges that proxy will be calling from:

Terminal window
export RUNWISP_TRUST_PROXY='10.0.0.0/8,2001:db8::/32'
runwisp daemon --host 127.0.0.1 --port 9477

RUNWISP_TRUST_PROXY is a comma-separated list of CIDR ranges whose X-Forwarded-Proto and X-Forwarded-For headers the daemon will trust. When a request comes in from one of those ranges with X-Forwarded-Proto: https, the daemon treats the connection as secure and marks the session cookie accordingly. Rate limiting is applied after the real-IP middleware, so proxied requests count against the originating IP, not the proxy’s address — limit your trust proxy ranges carefully or an attacker could consume the rate budget from behind a trusted proxy.

The daemon refuses 0.0.0.0/0 and ::/0. Trusting the whole internet would let any random client lie about where it’s coming from.

A short list of routes that intentionally skip the password/JWT check:

  • /health — liveness probe. Always 200 OK.
  • /metrics — Prometheus / OpenMetrics scrape endpoint. See Metrics for what’s exposed and the trade-off that comes with it.

Everything else under /api/ (other than the auth handshake itself) needs either a valid session cookie or a request coming in over the local Unix socket.