Auth (CHAP + JWT)
RunWisp has one access boundary and one session token. The login uses CHAP — a challenge-response handshake that never puts the password on the wire, even over plaintext HTTP. Once you log in, you carry an HS256 JWT for the next 24 hours. Every protected REST endpoint, SSE stream, and TUI action checks that token.
That’s all the daemon’s identity model. There are no users, no roles, no API keys, no SSO. The trust model is “if you can log in, you control the daemon” — RunWisp is built for one operator on one machine, and the auth boundary is sized to that.
The login flow
Section titled “The login flow”Client Daemon │ │ │ GET /api/auth/challenge │ │ ──────────────────────────────▶│ │ │ generate 32-byte nonce │ { nonce: "<64 hex>" } │ │ ◀──────────────────────────────│ │ │ │ compute response = │ │ SHA256(password + ":" + nonce)│ │ │ │ POST /api/auth │ │ { nonce, response } │ │ ──────────────────────────────▶│ │ │ verify response; │ │ consume nonce (one-shot) │ { token: "<JWT>" } │ │ ◀──────────────────────────────│- Nonce — 32 random bytes, hex-encoded (64 chars). Lives in an
in-memory LRU cache (1000 entries) for 5 minutes then expires.
Each nonce is single-use: replaying it returns
401 Invalid or expired challenge. - Response —
hex(SHA256(password + ":" + nonce)). The password itself never leaves the client. - Token — issued on success; embedded in the response body and as a
Set-Cookie: runwisp_jwt=…header.
If the password is wrong, the daemon returns 401. If your IP has
exceeded its budget (see rate limiting), 429.
The session token
Section titled “The session token”| Field | Value |
|---|---|
| Algorithm | HS256 (HMAC-SHA256) |
| Lifetime | 24 hours from issuance |
iss claim | "runwisp" |
aud claim | "runwisp-api" |
| Other claims | iat, exp |
Tokens with the wrong issuer or audience are rejected — so a token
issued by daemon A cannot authenticate against daemon B even if they
share a JWT secret by accident. Expired tokens are rejected exactly at
their exp second; there is no grace period.
The HMAC key (the JWT secret) lives in SQLite, not on disk —
specifically the config_entries row keyed jwt_secret. It’s a
base64-URL-encoded 32-byte random string generated on first boot. Wipe
that row (or delete the DB) and the daemon mints a new one on next
start, invalidating every outstanding token.
Rotating the JWT secret
Section titled “Rotating the JWT secret”There is no runwisp rotate-jwt-secret command. Two supported
ways to force a fresh JWT secret:
- Rotate the password. Set
RUNWISP_PASSWORDto a new value (or changedata/passwordand restart). When the daemon detects the change it rotates the JWT secret automatically and every outstanding token is invalidated. This is the intended mechanism for the “everyone re-logs in” workflow. - Wipe the row.
DELETE FROM config_entries WHERE key = 'jwt_secret';on the daemon-shutdown SQLite file, then restart. The daemon regenerates the secret on next boot. Use this when you want to invalidate sessions without touching the password.
Two ways to present the token
Section titled “Two ways to present the token”The daemon accepts the JWT via either header — whichever arrives first and validates wins:
Authorization: Bearer <token>Cookie: runwisp_jwt=<token>The cookie is set automatically on a successful login with these flags:
| Attribute | Value |
|---|---|
HttpOnly | always — JS can’t read it |
SameSite | Strict — never sent on cross-site requests |
Secure | set to true only when the daemon detects HTTPS — see reverse proxies |
Path | /api/ |
Max-Age | 86400 (24 hours, matches token lifetime) |
The cookie is the only way to authenticate the SSE log streaming
endpoint — EventSource cannot set custom headers, so the browser must
hold the cookie when it opens the stream.
The password
Section titled “The password”The password is the only secret guarding the daemon. On first run
RunWisp generates a 22-character base62 string (≈131 bits of entropy)
and writes it to <data-dir>/password with 0600 permissions:
$ ls -l data/-rw------- 1 runwisp runwisp 23 May 7 14:30 password-rw------- 1 runwisp runwisp 6 May 7 14:30 daemon.pid-rw-r--r-- 1 runwisp runwisp 28 KB May 7 14:30 runwisp.db…The file is plaintext. It has restrictive permissions but is not encrypted. Treat it as a credential — never commit it to git, never include it in a tarball you publish.
Setting your own password
Section titled “Setting your own password”RUNWISP_PASSWORD overrides the file at startup. The value is held in
memory only — the daemon does not write it back to data/password.
This matters for Docker secrets, systemd LoadCredential=, sealed
secrets, and any deployment that deliberately keeps credentials out of
the data dir.
export RUNWISP_PASSWORD='my-shared-secret'runwisp daemonBecause the env var is not persisted, the TUI and CLI must obtain the
password the same way the daemon did. In practice this means setting
RUNWISP_PASSWORD in the same shell (or --password flag for one-off
commands). If you start the daemon with RUNWISP_PASSWORD and try to
launch the TUI from a fresh shell with no env var and no
data/password, the TUI will generate its own password — which won’t
match the daemon’s.
Resolution order on every startup (daemon, TUI, CLI):
RUNWISP_PASSWORDenv var (in-memory only).data/passwordfile (read existing).- Generate a fresh password and write it to
data/password(daemon only — interactive TUI/CLI will hit an auth failure here, which is the correct outcome).
If you change RUNWISP_PASSWORD between restarts, the daemon notices —
it stores a hash of the env-supplied password in SQLite (config_entries
key password_hash) and rotates the JWT secret when the hash
changes. That invalidates every outstanding token, forcing a fresh
login. Auto-generated passwords don’t track a hash, so the JWT secret
survives restarts.
Rate limiting
Section titled “Rate limiting”Failed login attempts are rate-limited per source IP:
| Knob | Value | Source |
|---|---|---|
| Window | 5 minutes | shared between challenge + auth |
| Max attempts | 5 | combined across both endpoints |
| Response | 429 Too Many Requests | with standard Retry-After header |
| IP source | real TCP peer | not X-Forwarded-For unless trusted |
The 429 lockout releases automatically when the window slides past. The limiter intentionally counts both the challenge fetch and the auth POST — so attackers can’t grind nonces without using attempts.
Separately, SSE streaming endpoints have their own concurrency caps:
64 global and 8 per IP open streams. Exceeding them returns
503 Service Unavailable.
Reverse proxies
Section titled “Reverse proxies”By default RunWisp binds to 127.0.0.1:9477 and serves plain HTTP. To
expose it publicly with TLS, run a reverse proxy in front and tell the
daemon to trust it:
export RUNWISP_TRUST_PROXY='10.0.0.0/8,2001:db8::/32'runwisp daemon --host 127.0.0.1 --port 9477RUNWISP_TRUST_PROXY is a comma-separated list of CIDRs whose
X-Forwarded-Proto and X-Forwarded-For headers the daemon will
honour. Common cases:
# Single nginx box on the same hostRUNWISP_TRUST_PROXY='127.0.0.1/32,::1/128'
# Anywhere on a private LANRUNWISP_TRUST_PROXY='10.0.0.0/8,172.16.0.0/12,192.168.0.0/16'The daemon rejects 0.0.0.0/0 and ::/0 — trusting the entire
internet would let any client spoof its source IP for rate limiting.
When the request arrives from a trusted CIDR with
X-Forwarded-Proto: https, the daemon treats it as a secure request
and sets the Secure flag on the JWT cookie. The TCP peer address is
captured before any proxy middleware runs, so rate limiting and
loopback detection always see the real peer — not a spoofable
header.
Endpoints that don’t need auth
Section titled “Endpoints that don’t need auth”| Path | Reason |
|---|---|
GET /health | Used by runwisp status, Docker HEALTHCHECK, Kubernetes probes. |
GET /api/auth/status | Lets a client check whether it’s already authenticated. |
GET /api/auth/challenge | Step one of CHAP. Rate-limited. |
POST /api/auth | Step two of CHAP. Rate-limited. |
GET /api/auth/launch | Loopback-only ticket redemption used by the TUI auto-login. |
Every other path under /api/ requires a valid JWT.
What RunWisp deliberately doesn’t ship
Section titled “What RunWisp deliberately doesn’t ship”- No SSO / OIDC / SAML / OAuth2. The daemon doesn’t know how to federate. Put it behind your identity-aware proxy (Cloudflare Access, Pomerium, oauth2-proxy) if you need that.
- No LDAP / AD.
- No multi-factor. No TOTP, no WebAuthn, no SMS.
- No API keys. Every client logs in via CHAP and carries a JWT.
- No multi-user model. One password, one set of permissions: full control.
- No RBAC. An authenticated client can do anything the API exposes.
These are choices, not omissions. RunWisp is for one operator on one machine; federated identity, multi-user, and RBAC are explicit non-goals.
Where to next
Section titled “Where to next”- Operations: data directory — where
password,runwisp.db, and the JWT secret physically live. - CLI reference —
runwisp tui --password, environment variable precedence,runwisp status. - Deploy: VPS — terminating TLS upstream and setting
RUNWISP_TRUST_PROXYcorrectly.