Skip to content

Authentication

RunWisp has two ways in:

  • The CLI and TUI on the same machine use the control socket. No password.
  • The Web UI and anything else over HTTP need the password.

runwisp, runwisp tui, runwisp status, runwisp run, and the other commands talk to the daemon over the control socket. Only the user who started the daemon can open it, so there’s nothing to log in to. Anyone who can read the data directory controls the daemon.

With --url (or RUNWISP_URL), runwisp run and runwisp tui connect over HTTP instead and log in like any network client.

If the CLI prints daemon is not reachable at <path> while a daemon is up, the two are using different data directories. Check --data and which user you’re running as.

The Web UI asks for the password once, then keeps you logged in with a session cookie for 24 hours. The password itself never crosses the network: login is a challenge and response. To log in from a script, use runwisp run --url or the script in Remote triggers.

The session cookie and run output are only as private as the connection. When you bind beyond loopback, turn on TLS or put the daemon behind a reverse proxy that does.

The TUI’s Open in browser logs the browser in with a single-use ticket that expires after 60 seconds. When runwisp tui --url points at a plain http:// address that isn’t loopback, the TUI warns you first, because the ticket would cross the network unencrypted.

Terminal window
RUNWISP_PASSWORD='long-random-string' runwisp daemon
  • Set RUNWISP_PASSWORD. The daemon reads it from the environment and keeps it in memory. Browser sessions survive a restart as long as the value doesn’t change. Keep the value in whatever secret store you already use.
  • Leave it unset. The daemon makes up a new random password on every boot, so each restart logs everyone out. Fine for a daemon you start by hand; annoying for one that runs for months.

runwisp service install generates a stable password for you, so an installed daemon doesn’t have this problem.

To rotate the password, change RUNWISP_PASSWORD (or unset it) and restart. Every network session ends.

RunWisp never writes the password or the session signing key to disk.

Terminal window
runwisp password | wl-copy # Linux, Wayland
runwisp password | pbcopy # macOS

In the TUI, open Home, focus the Password row, and press Enter to copy it.

runwisp password works only over the control socket, and only for a generated password. It exits 1 when RUNWISP_PASSWORD is set (RunWisp never hands out a password you chose) and 5 when authentication is off. Piping it into a clipboard tool keeps it out of your scrollback.

The system service’s socket belongs to root, so ask it with sudo runwisp password.

since: 1.7

Terminal window
RUNWISP_PASSWORD='long-random-string' \
RUNWISP_VIEWER_PASSWORD='another-string' runwisp daemon

A second password lets someone look at the dashboard without being able to change anything. It’s off unless you set it: with no RUNWISP_VIEWER_PASSWORD there is no second secret and nobody can sign in read-only.

Someone who signs in with it can see tasks, runs, logs and notifications. The daemon rejects everything that changes state with a 403: triggering, starting, stopping or restarting a task, pausing, deleting runs, reloading the config and marking notifications read. The Web UI hides those controls and shows a Read-only badge in the top bar. A remote runwisp tui --url hides them too and marks the sidebar read-only. A remote runwisp run --url signed in this way fails with a message saying so.

  • The two passwords must differ. The daemon refuses to start if they match.
  • It can’t be combined with RUNWISP_AUTH=off.
  • Changing it ends read-only sessions only; full sessions carry on.
  • It can’t be used to get the full password: a read-only session can’t mint a launch ticket (see Network clients).
  • The control socket is unaffected. Local clients always have full access.

To get a read-only password without setting one in the environment, ask the running daemon for it:

Terminal window
runwisp password --viewer

The first call makes up a random password and keeps it in memory until the daemon restarts; later calls print the same one. Like runwisp password, it exits 1 when RUNWISP_VIEWER_PASSWORD is set.

Terminal window
RUNWISP_AUTH=off runwisp daemon

The API and Web UI answer every request without a password. Anyone who can reach the port can read every log and run any task as the user that started the daemon. Use it for local development or on an isolated network, never on a port the internet can reach. If you need remote access without a RunWisp login, put a proxy that does its own authentication in front.

  • RUNWISP_AUTH accepts off or on (any case). Any other value stops the daemon at startup.
  • It can’t be combined with RUNWISP_PASSWORD; the daemon refuses to start with both.
  • The daemon prints a warning at startup, the Web UI shows an Auth disabled badge, and the TUI shows the password as disabled.
  • The control socket works the same as before.

The official Docker image won’t start until you choose RUNWISP_PASSWORD or RUNWISP_AUTH=off. For a passwordless local container, publish the port on loopback only:

Terminal window
docker run --rm -e RUNWISP_AUTH=off -p 127.0.0.1:9477:9477 \
-v ./runwisp:/etc/runwisp runwisp/runwisp:latest

A CI job or webhook can control one task or service without the password. Give it a hook_tokens entry, then send the token as a bearer header:

Terminal window
curl -fsS -X POST -H "Authorization: Bearer $CI_DEPLOY_TOKEN" \
"https://runwisp.example.com:9477/api/hooks/tasks/deploy/run?wait=true"

Each hook is a session route under /api/hooks/, with the same query, body, and response:

Hook Same as
POST /api/hooks/tasks/{name}/run POST /api/tasks/{name}/run
POST /api/hooks/tasks/{name}/start POST /api/tasks/{name}/start
POST /api/hooks/tasks/{name}/stop POST /api/tasks/{name}/stop
POST /api/hooks/tasks/{name}/restart POST /api/tasks/{name}/restart

Add ?wait=true to hold the request until the work is done:

  • run, start, restart on a task: returns the finished run with its exitCode and endReason. start on a task that is already running waits for that run.
  • stop: returns once every run has ended, or 409 if some are still ending when waitTimeout (seconds, default 120) runs out.
  • start, restart on a service: 400, since a service instance doesn’t end on its own.

A hook token can’t read run logs. Since 1.8, run takes tail=N (up to 1000) and adds the last N lines of the run’s log to the response as a log array of {n, stream, text}, so a CI job can show why a run failed:

Terminal window
curl -fsS -X POST -H "Authorization: Bearer $CI_DEPLOY_TOKEN" \
"https://runwisp.example.com:9477/api/hooks/tasks/deploy/run?wait=true&tail=50" \
| jq -r '.log[].text'

Runs started this way show as triggered by hook.

If a webhook sender can only set a URL, pass the token as a query parameter instead. The header wins when both are sent.

https://runwisp.example.com:9477/api/hooks/tasks/deploy/run?token=<token>

A missing or wrong token gets 401, and so does an unknown task, so hooks don’t reveal which tasks exist. A valid token whose allow list leaves out the action gets 403. Tokens are checked even with RUNWISP_AUTH=off, and manual_trigger doesn’t apply. A rotated token takes effect on runwisp reload.

Only wrong passwords count, per client IP. The first 5 cost nothing. After that, each one doubles the wait before the next try: 2 seconds, then 4, 8, and so on up to 90 seconds. Trying sooner gets 429 Too Many Requests with a Retry-After header, even with the right password. A successful login clears the count, and so do 15 minutes without a wrong password. A restart also clears it.

Launch tickets and the control socket aren’t limited. Challenges are capped at 60 per minute per IP.

Hooks are limited by failures only: after 20 rejected tokens from one client IP within a minute, that IP gets 429 (even with a valid token) until the window moves on. Successful calls never count.

Live log streams are capped at 128 open at once, and 16 per client IP. Past the cap, new streams get 503 Service Unavailable. Close some log views and try again.

Use a reverse proxy (nginx, Caddy, Traefik, Cloudflare Tunnel) when you need a publicly trusted certificate. Let the proxy handle TLS, keep RunWisp on plain HTTP behind it, and tell RunWisp which addresses the proxy connects from:

runwisp.toml
[daemon]
trusted_proxies = ["127.0.0.1/32", "10.0.0.0/8"]

From those addresses only, RunWisp trusts:

  • X-Forwarded-For, so logs and the login throttle see the real client IP.
  • X-Forwarded-Proto: https, so the session cookie is marked Secure.

Without it, every client looks like the proxy, so one client’s wrong passwords slow down logins for everyone. When proxy headers arrive from an address that isn’t in the list, RunWisp logs a warning and adds one to the notification bell.

Keep the list tight: an address in it can claim to be any client IP, and so use up another client’s login limit. The RUNWISP_TRUSTED_PROXIES environment variable (comma-separated) overrides the key. See trusted_proxies.

Serve RunWisp at the root of its own host name, such as runwisp.example.com. A path prefix like example.com/runwisp/ doesn’t work, because the Web UI loads its files from /.

A minimal nginx site:

/etc/nginx/sites-available/runwisp
server {
listen 443 ssl;
server_name runwisp.example.com;
# ssl_certificate and ssl_certificate_key go here
location / {
proxy_pass http://127.0.0.1:9477;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

The same with Caddy, which gets the certificate for you:

Caddyfile
runwisp.example.com {
reverse_proxy 127.0.0.1:9477
}

Live logs and Web UI updates use long-lived streams. RunWisp tells nginx not to buffer them and sends a keepalive every 30 seconds, so the default proxy_read_timeout of 60 seconds is enough. If your proxy times out idle connections sooner, raise that limit.

For a LAN or a tailnet, you don’t need a proxy: tls = "auto" serves HTTPS with a self-signed certificate.

These answer without a password:

  • GET /health: always 200 OK. Use it as a liveness probe.
  • GET /metrics: when metrics are on and share the main listener.
  • /api/auth/*: login status, challenge, login, and launch-ticket redemption.
  • POST /api/hooks/*: needs one of the unit’s hook tokens instead, in the header or the URL.
  • GET /api/daemon/identity: tells a starting runwisp which daemon holds the port. Loopback and socket callers only; 403 for everyone else.
  • The Web UI’s static files.

Everything else under /api/ needs a session or the control socket.

The login response is PBKDF2-HMAC-SHA256 (600,000 rounds) of the password, salted with a single-use challenge, so a captured login is slow to brute-force. It doesn’t protect the session token; TLS does.