Skip to content

Security Configuration

shed is local-development-first: out of the box (the brew/apt install) it runs localhost-only. Networked access is opt-in. Its network security is two independent choices:

  1. Postureauth.mode: open | token | mtls. Open trusts the network (plain HTTP, no credential, no TLS). Token trusts nothing (pinned TLS + bearer tokens + an SSH key allowlist), and is TLS-only — it serves no plain-HTTP listener at all. mtls is the same hardened bundle with the bearer token replaced by a client certificate bound to a key that never leaves the client's device — the recommended posture for anything internet-facing. (secure is accepted as a deprecated alias for token.)
  2. Where listeners bindbind_address governs every listener (HTTP, HTTPS, SSH). It defaults to loopback (127.0.0.1) in every mode, so the server is reachable only on this machine until you set it to a routable address.

Think of it as two deployment shapes:

  • Local development (the default) — localhost-only, open (the installed default), token, or mtls. Cases 1 and 2 below.
  • Remote-facing — reachable across the network. mtls or token is the preferred posture (TLS + a credential); open is allowed only with an explicit bind_address and allow_insecure_exposure: true. Cases 3, 3b, and 4 below.

This guide walks the common shapes those two choices produce. For the underlying model — the invariants, the credential bus, and the exact connection flow — see the Security reference. For a full internet-facing walkthrough, see Public VPS Deployment. Upgrading an existing token-mode server to mtls, or upgrading from a pre-v0.7.4 server (the loopback default is new)? See Upgrading to mTLS and Upgrading v0.7.3 → v0.7.4.

Server settings live in server.yaml (/etc/shed/server.yaml on Linux, ~/.config/shed/server.yaml or the Homebrew …/etc/shed/server.yaml on macOS). Client settings are written per server into ~/.shed/config.yaml by shed server add.

Choosing a posture

Question If yes
Is everything on one machine, and do you want zero auth ceremony? Open + loopback (Case 1) — the installed default
One machine, but you want TLS + key checking anyway (shared box, or mirror prod locally)? Token + loopback (Case 2)
Reachable over the network / internet-facing, and curl/scripts/CI need to reach the API directly? Token, all interfaces (Case 3)
Reachable over the network / internet-facing, and every caller is part of the shed client fleet (CLI, desktop, host-agent, mobile)? mtls, all interfaces (Case 3b) — the recommended networked posture
Reachable over the network, but a trusted private network only and you accept plaintext? Open on a LAN (Case 4) — needs allow_insecure_exposure: true

Two invariants hold across every token- and mtls-mode deployment, and explain the startup rejections you may hit (see Troubleshooting):

  • credential ⟺ TLS ⟺ enforced mode — HTTP credential enforcement (a bearer token in token mode, a client certificate in mtls mode) only exists in an enforced mode, and an enforced mode is always TLS.
  • https ⟺ enforced modehttps_port is valid only in token/mtls mode. So a client (or the host-agent) can treat an https:// api_url as proof the server enforces a credential.

Case 1 — Local only, simplest (open + loopback)

For: a developer running shed-server and the CLI on the same machine, who wants nothing exposed to the LAN and no SSH allowlist / token / TLS ceremony.

server.yaml:

auth:
  mode: open             # the default — may be omitted
bind_address: 127.0.0.1  # the default — every listener (HTTP + SSH) binds loopback only

bind_address: 127.0.0.1 is the default, so this is also what you get with an empty server.yaml. Add the server (plain HTTP — no flags):

shed server add localhost

You get: plain HTTP + SSH bound to loopback. Nothing reaches the LAN; no tokens, no TLS, no key allowlist. Trade-off: any local user on the box can reach the API — loopback is the trust boundary. Right for a single-user laptop.


Case 2 — Local only, hardened (token + loopback)

For: one machine, but you want full TLS and an SSH key allowlist even locally — a shared/multi-user host where loopback isn't a sufficient boundary, or to mirror a production token-mode config on your laptop.

server.yaml:

auth:
  mode: token
  ssh:
    authorized_keys:
      - ssh-ed25519 AAAA…your-key… you@laptop   # the key you SSH/​bootstrap with
bind_address: 127.0.0.1   # the default — HTTPS + SSH bind loopback only
# https_port defaults to 8443 in token mode

Add the server over TLS (pins the self-signed cert, mints a token over SSH):

shed server add localhost --https-port 8443

You get: TLS-only on 127.0.0.1:8443, SSH allowlist enforced, bearer tokens enforced — nothing plaintext, nothing on the LAN. Trade-off: token mode requires an SSH key source, so you must list your key (authorized_keys here, or github_users: [you]); clients pin the cert. More setup than Case 1, in exchange for defense-in-depth on the box itself.


Case 3 — Remote, internet-facing (token, all interfaces)

For: a VPS or remote host reachable over the network — only your keys may connect, and all traffic is encrypted.

server.yaml:

auth:
  mode: token
  ssh:
    github_users: [charliek]   # only these GitHub keys may SSH in (and mint tokens)
tls_names:
  - shed.example.com           # your public hostname / IP (extra cert SANs)
bind_address: 0.0.0.0          # token mode defaults to loopback too — set this to face the network
# https_port defaults to 8443

Token mode defaults to loopback like every other posture, so a remote server must set bind_address explicitly (0.0.0.0/* for all IPv4, :: for all interfaces, or a specific public/tailnet IP) — otherwise it is unreachable. Token mode needs no allow_insecure_exposure ack to bind the network: TLS + tokens already make it safe.

From your laptop:

shed server add shed.example.com --https-port 8443

This fetches the cert + SSH host key, shows both fingerprints for confirmation, pins them, then mints a token over the _bootstrap SSH channel — no token to paste. You get: HTTPS on all interfaces (8443), SSH allowlist, tokens; no plaintext anywhere. A co-located host-agent (one running on the same box) reaches the credential bus over the same pinned-TLS listener at https://127.0.0.1:8443, gated by the credentials scope. See Public VPS Deployment for the full flow, out-of-band fingerprint verification, and rotation.

When to prefer Case 3b instead: token mode is the right call whenever curl, a CI runner, or any third-party integration needs to hit the API directly with a bearer header — that's not possible against an mtls server.


For: the same VPS/remote-host shape as Case 3, but every caller is part of the shed client fleet (CLI, desktop, host-agent, mobile) and you want the credential itself to be a key-bound certificate rather than a bearer instrument. This is the recommended posture for anything internet-facing.

server.yaml:

auth:
  mode: mtls
  ssh:
    github_users: [charliek]   # only these GitHub keys may SSH in (and enroll certificates)
tls_names:
  - shed.example.com           # your public hostname / IP (extra cert SANs)
bind_address: 0.0.0.0          # mtls mode defaults to loopback too — set this to face the network
# https_port defaults to 8443

Every invariant from Case 3 holds identically (loopback-by-default, no allow_insecure_exposure ack needed, github_users/authorized_keys required). What's different is what a client presents: instead of a bearer token, shed server add generates a private key locally, sends a certificate signing request over the _bootstrap SSH channel, and the server's small internal CA signs it. The private key never leaves the client:

shed server add shed.example.com --ssh-port 2222 --trust-on-first-use

You get: the same TLS-only, SSH-allowlisted surface as Case 3, but an unauthenticated peer cannot even complete the HTTPS handshake — the server demands a client certificate as part of RequireAndVerifyClientCert (curl -k with no certificate gets no HTTP response at all, not even a 401). Trade-off: revoking access means removing the SSH key (coarser than token mode's per-token revoke — it also cuts SSH/SFTP), and curl/scripts cannot reach the server at all. See mTLS mode for the full guarantee, the per-request re-validation, and the accepted limitations, and Upgrading to mTLS for flipping an existing token-mode server over.


Case 4 — Remote, open on a trusted LAN (open + allow_insecure_exposure)

For: a server reachable across a trusted private network only (a Tailscale tailnet or a closed LAN), where you accept plaintext and want no credential / TLS ceremony. Prefer mtls (Case 3b) or token (Case 3) mode for anything networked — this case has no transport security, so use it only when the network itself is the trust boundary.

server.yaml:

auth:
  mode: open               # plain HTTP, no tokens, no TLS
bind_address: 0.0.0.0      # face the network (or a specific tailnet/LAN IP)
allow_insecure_exposure: true   # required: acknowledge a non-loopback bind with no transport security

Binding a non-loopback interface in open mode requires allow_insecure_exposure: true — without it the server refuses to start, because open mode would otherwise put plaintext on the network silently. (Token mode needs no such ack.) Add the server (plain HTTP — no flags):

shed server add my-host.tailnet.ts.net --name my-host

You get: plain HTTP + SSH on the LAN, no tokens, no TLS. Trade-off: everything is plaintext and any host that can reach the address can reach the API — the private network is the trust boundary. Move to mtls (Case 3b) or token mode (Case 3) the moment the server faces anything wider.


Staging the SSH allowlist with warn (avoid locking yourself out)

auth.mode: token/mtls force the SSH allowlist to enforce — and a wrong or incomplete allowlist on a remote host locks you out (recovery means editing server.yaml from the provider's console). The safe rollout uses warn as a pre-flight, in open mode, before you commit:

auth:
  mode: open
  ssh:
    mode: warn                 # consult the allowlist, LOG would-deny, but still accept
    github_users: [charliek]

Restart, then watch the log for would-deny lines while you (and your CI, and the host-agent) connect. Once nothing legitimate is denied, switch to auth.mode: token or auth.mode: mtls (either forces enforce). This is the same pattern as SELinux permissive or CSP report-only — a dry run that proves the policy before it blocks. warn is valid only in open mode; token and mtls mode always enforce.

Is anything plaintext in token or mtls mode?

No — nothing, anywhere. Both modes serve no plain-HTTP listener at all (not even on loopback): a single pinned-TLS listener faces clients, and a client that holds a pin but is handed a non-https URL fails closed rather than send plaintext. The SSH channel (shed sessions and the credential issuance channel) is encrypted with the host key pinned in known_hosts. The only trust-on-first-use moment is shed server add showing you the cert fingerprint(s) to confirm (or pass --tls-fingerprint / --fingerprint to verify out-of-band). See the connection-flow table.

The credential bus (credentials scope) and Connect tunnel (control or credentials) ride that same TLS listener — so a co-located host-agent reaches them over https://127.0.0.1:8443 with the pinned cert (in mtls mode, its own client certificate). There is no plaintext channel, implicit or opt-in.

Troubleshooting startup rejections

Token and mtls mode refuse to start half-configured, and the simplification removed the footgun states — so these configs are rejected at startup (the server names the gap and exits):

Message names… Cause Fix
auth.mode: token requires … an SSH key source (or auth.mode: mtls requires …) token/mtls mode with no github_users / authorized_keys / authorized_keys_file Add a key source (Cases 2–4).
https_port requires auth.mode: token or mtls https_port set under open Use token/mtls (either defaults https_port to 8443), or drop https_port.
auth.ssh.mode: enforce requires auth.mode: token or mtls enforcing the allowlist without an enforced mode Use token/mtls, or warn to stage (above).
auth.mode: token forces auth.ssh.mode: enforce (or auth.mode: mtls forces …) an explicit off/warn under an enforced mode Remove auth.ssh.mode — token/mtls mode derives enforce.
config key "auth.http" was removed a leftover auth.http block Delete it — token enforcement derives from auth.mode: token.
config key "http_bind"/"ssh_bind" was removed a leftover http_bind/ssh_bind Replace both with a single bind_address.
config key "internal_http_port" was removed a leftover internal_http_port Delete it — the bus + Connect tunnel ride the single listener (Case 3/3b).
bind_address … requires allow_insecure_exposure a non-loopback bind_address in open mode Add allow_insecure_exposure: true (Case 4), or switch to token/mtls mode (Case 3/3b).
invalid auth.mode: … (must be open, token, or mtls) a typo or unsupported value in auth.mode Use open, token, or mtls (secure is also accepted, as a deprecated alias for token).

Quick reference

Case auth.mode bind_address TLS shed server add
1 — local simple open 127.0.0.1 (default) none shed server add localhost
2 — local hardened token 127.0.0.1 (default) loopback :8443 shed server add localhost --https-port 8443
3 — remote, curl/CI-compatible token 0.0.0.0 all ifaces :8443 shed server add <host> --https-port 8443
3b — remote (recommended) mtls 0.0.0.0 all ifaces :8443, client cert required shed server add <host> --ssh-port <port>
4 — remote open on LAN open + allow_insecure_exposure 0.0.0.0 none shed server add <host>