Skip to content

Hardening checklist

A self-hosted ShellWatch is most of the way there out of the box, but a few defaults are deliberately permissive (so first-run “just works” on localhost). Tighten these before exposing it.

Default: 127.0.0.1/32 and ::1/128.

This applies to /mcp and /agent-proxy only — the Web UI is authenticated separately. Once you’ve set trustProxy, real client IPs are visible to the allowlist; either narrow the CIDRs to the offices/VPNs that should have agent access, or open it up explicitly with 0.0.0.0/0 if you’re relying purely on the OAuth token + passkey gate.

security:
allowedNetworks:
- 10.10.0.0/16 # corporate VPN
- 198.51.100.42/32 # CI runner static IP

2. Pin the relying-party ID and trusted origins

Section titled “2. Pin the relying-party ID and trusted origins”

Passkeys are bound to the relying-party ID, which must match the domain you serve from. Get this right before users start registering — changing it later invalidates every existing passkey.

security:
rpId: shellwatch.example.com
trustedWebauthnOrigins:
- https://shellwatch.example.com

Origins are checked exactly — include the protocol and port if non-standard. No wildcards.

Default: selfRegistrationEnabled: false. The first registration on a fresh install always works (so you can bootstrap the admin), but subsequent registrations are blocked unless you flip this on.

security:
selfRegistrationEnabled: false # admin invites only

For a personal install, leave it off. For a team install, leave it off and have the admin invite users (or flip on temporarily, register, flip off).

Per-IP rate limits on auth-adjacent endpoints. Defaults are sane but tune for your population:

security:
rateLimit:
selfRegister:
max: 5
windowMinutes: 15
passkeyRegister:
max: 10
windowMinutes: 15
loginOptions:
max: 20
windowMinutes: 15
loginVerify:
max: 10
windowMinutes: 15

The store is in-memory — limits are per-process, not per-cluster. If you’re running multiple replicas (you shouldn’t — see below), each gets its own counters.

ShellWatch performs WebAuthn ceremonies with userVerification: "required", but UV is only as good as the server’s enforcement. On every target host that should require UV:

/etc/ssh/sshd_config
PubkeyAuthOptions verify-required

…or per-key in ~/.ssh/authorized_keys:

verify-required sk-ecdsa-sha2-nistp256@openssh.com AAAA... user@host

See SSH agent proxy → Enforcing UV.

6. Keep Hydra’s admin port off the internet

Section titled “6. Keep Hydra’s admin port off the internet”

Hydra’s admin API (hydra.adminUrl, port 4445) accepts login/consent decisions, OAuth client CRUD, and token introspection with no further authentication. Only ShellWatch may reach it:

  • In compose, don’t publish the port at all (or bind it to 127.0.0.1 only).
  • Never add a reverse-proxy route for it.
  • Verify from outside that :4445 is unreachable.

While you’re at it: set a real HYDRA_SECRETS_SYSTEM (openssl rand -hex 16) in .env.hydra — the dev default is public knowledge, and it encrypts Hydra’s data at rest. Rotating it later invalidates all existing tokens and sessions. Drop the --dev flag from the Hydra service command for any internet-facing deployment (it accepts an http:// issuer without TLS and relaxes other checks).

Also consider hydra.introspectionCacheTtlMs: introspection results are cached for 60 s by default, so a revoked or logged-out token can keep working for up to that long. Lower it (or set 0) for tighter revocation latency.

Terminal window
sudo chmod 600 /opt/shellwatch/config.yaml
sudo chmod 700 /opt/shellwatch/data

Run the broker as a dedicated low-privilege user (shellwatch) with NoNewPrivileges and ProtectSystem=strict if using systemd.

The SQLite database holds everything mutable on the ShellWatch side — accounts, passkeys, endpoints, and the audit tables (audit_session_lifecycle and audit_signing_requests; session input/output is not persisted). Snapshot the directory before upgrades, and have a retention policy. SQLite in WAL mode is safe to copy with sqlite3 ... ".backup ..." from a hot process; a plain cp of the .db + .db-wal + .db-shm triplet works for a stopped process.

Hydra’s hydra.sqlite (in the same ./data directory) holds the OAuth side — clients, grants, and tokens. Losing it doesn’t lose accounts, but every client (web, MCP, agent) has to re-authenticate. Backing up the whole ./data directory covers both databases. (pnpm hydra:migrate also snapshots hydra.sqlite before applying schema changes.)

ShellWatch is a single Node.js process and is not designed for horizontal scaling:

  • Terminal sessions live in process memory.
  • The PendingActionStore is in-memory.
  • Rate-limit counters are in-memory.
  • WebSocket fan-out is per-process.

If you need HA, run an active/passive setup with shared storage and a single hot node at any time. Active/active will not work without significant changes.

For a typical personal or small-team broker, one well-backed-up VM is the right sizing.

GET /health returns 200 when the server is up. Use it for your load balancer health check and for external uptime monitoring. The Docker image has a baked-in HEALTHCHECK that hits this endpoint.

If you publish a shellwatch-agent binary to your team, watch the agent-client releases and ship updates — particularly to pull in OpenSSH-version compatibility fixes and the X-ShellWatch-Version handshake header. The version is shown to approvers on /sign/:id, so keeping it current is part of the audit story.