Skip to content

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.

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.
  • Responsehex(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.

FieldValue
AlgorithmHS256 (HMAC-SHA256)
Lifetime24 hours from issuance
iss claim"runwisp"
aud claim"runwisp-api"
Other claimsiat, 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.

There is no runwisp rotate-jwt-secret command. Two supported ways to force a fresh JWT secret:

  1. Rotate the password. Set RUNWISP_PASSWORD to a new value (or change data/password and 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.
  2. 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.

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:

AttributeValue
HttpOnlyalways — JS can’t read it
SameSiteStrict — never sent on cross-site requests
Secureset to true only when the daemon detects HTTPS — see reverse proxies
Path/api/
Max-Age86400 (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 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.

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.

Terminal window
export RUNWISP_PASSWORD='my-shared-secret'
runwisp daemon

Because 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):

  1. RUNWISP_PASSWORD env var (in-memory only).
  2. data/password file (read existing).
  3. 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.

Failed login attempts are rate-limited per source IP:

KnobValueSource
Window5 minutesshared between challenge + auth
Max attempts5combined across both endpoints
Response429 Too Many Requestswith standard Retry-After header
IP sourcereal TCP peernot 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.

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:

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 CIDRs whose X-Forwarded-Proto and X-Forwarded-For headers the daemon will honour. Common cases:

Terminal window
# Single nginx box on the same host
RUNWISP_TRUST_PROXY='127.0.0.1/32,::1/128'
# Anywhere on a private LAN
RUNWISP_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.

PathReason
GET /healthUsed by runwisp status, Docker HEALTHCHECK, Kubernetes probes.
GET /api/auth/statusLets a client check whether it’s already authenticated.
GET /api/auth/challengeStep one of CHAP. Rate-limited.
POST /api/authStep two of CHAP. Rate-limited.
GET /api/auth/launchLoopback-only ticket redemption used by the TUI auto-login.

Every other path under /api/ requires a valid JWT.

  • 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.

  • Operations: data directory — where password, runwisp.db, and the JWT secret physically live.
  • CLI referencerunwisp tui --password, environment variable precedence, runwisp status.
  • Deploy: VPS — terminating TLS upstream and setting RUNWISP_TRUST_PROXY correctly.