Operating

Security model

shed has one kind of user: a GitHub account you named in the config file. Everything else is about keeping that boundary tight, from the sign-in flow to the headers on every response.

Authentication

You sign in with GitHub through the same App that handles repositories and webhooks. shed never sees a password. Access is decided by one list in shed.toml:

your browsershedGitHub1 login2 302 + state3 authorize4 code + state5 callback6 exchange7 userallowed_users?case-insensitive8 cookie, 302 /
Steps 3 and 4 happen entirely between your browser and GitHub.
  1. GET /api/auth/login sets a state cookie, shed_oauth_state (16 random bytes, valid for 10 minutes, scoped to /api/auth), and redirects to GitHub's authorize URL carrying the same state.
  2. GitHub sends the browser to /api/auth/callback with a code and the state. shed compares the state with the cookie in constant time, and clears the cookie either way. A mismatch is a 400. A missing code, because you declined, redirects to /login?error=denied.
  3. shed exchanges the code for your GitHub profile. A failed exchange redirects to /login?error=failed.
  4. Your login is checked against auth.allowed_users, case-insensitively. An empty list denies everyone. If you're on it, shed saves your profile, creates a session, sets the cookie, and redirects to /. Otherwise you land on /login?error=denied.

The rule is enforced again on every request, not just at sign-in. At startup shed also permanently deletes the sessions of logins that are no longer on the list. To revoke someone, remove them from allowed_users and restart shed, which also ends their open log streams. See environment overrides for setting the list without editing the file.

Agents that connect to the MCP server sign in through the same flow and the same list, but they get their own read-only tokens instead of a session.

Sessions

A session is a random token in a cookie. shed stores only its hash, so a copy of shed.db or a backup does not contain anything a browser could present.

Propertyshed_sessionshed_oauth_state
Value32 random bytes, base64url16 random bytes, base64url
Stored server-side assha256 hex in sessions.token_hashnothing; compared to the query
Lifetime30 days, fixed at sign-in10 minutes
Path//api/auth
HttpOnlyyesyes
SameSiteLaxLax
Securewhen server.url is httpswhen server.url is https

The lifetime does not slide: you sign in again every 30 days. Logout (POST /api/auth/logout) deletes the session row and clears the cookie, and it has the same origin and JSON checks as any other mutation.

Request protections

Every API route that needs a session runs through the same chain, outermost first. Order matters: an unauthenticated mutation gets 401, not 403.

Security headersevery responseSession401 no sessionOrigin check403 bad originJSON only415 not JSONHandleryour request
The status each layer returns when it rejects a request.
  • Origin check. For POST, PUT, PATCH, and DELETE, an Origin header must equal the origin of server.url. With no Origin, a Sec-Fetch-Site header, if present, must be same-origin. Anything else is a 403 untrusted request origin. Sibling apps on your other domains are different origins, so they can't drive the API even with your cookie. Clients that send neither header, like curl, pass this check and rely on the cookie. Reads (GET, HEAD, OPTIONS) skip it.
  • JSON only. POST, PUT, and PATCH must declare Content-Type: application/json, even with an empty body, or get 415. Browsers can't send that type cross-site without a CORS preflight, which backs up the SameSite=Lax cookie. Request bodies are capped at 1 MiB.
  • Security headers. Every response from shed's handler, API and dashboard, carries the four headers below, so the dashboard can't be framed by another site.

For Vite development, set server.url to the dev server's origin so the origin check passes.

HeaderValueEffect
Content-Security-Policyframe-ancestors 'none'No other site can embed the dashboard in a frame.
X-Frame-OptionsDENYThe same rule for older browsers.
X-Content-Type-OptionsnosniffBrowsers must trust the declared content type.
Referrer-Policysame-originOther sites never receive a referrer from the dashboard.

Contributors: the request checks are implemented in the HTTP layer.

Where secrets live

shed keeps secrets in as few places as it can, and keeps them out of everything that is displayed or logged.

SecretWhere it livesHow it is protected
Service variablesvariables table in shed.db, encryptedSupplied to builds as BuildKit secrets in private files outside the source, never as build arguments. Dockerfiles read them with RUN --mount=type=secret.
GitHub App credentialssettings table: client secret, webhook secret, private keyNever returned by the API.
Git clone credentialsShort-lived installation tokensPassed as a scoped HTTP authorization header in the child process environment, not on a command line or in repository config.
age secret keysettings table, backup.age_identityOnly GET /api/backups/settings/key returns it, on demand.
S3 secret access keybackup_destinations tableThe API returns hasSecret, never the secret. Saving with a blank secret keeps the stored one.
Session tokensOnly their sha256 in sessionsA database copy can't be replayed as a cookie.
Database passwords during backupsThe container's own environmentPassed as PGPASSWORD, MYSQL_PWD, REDISCLI_AUTH, or a private --config file, never on a command line.

Logs

Build logs mask the literal values of the service's variables and of clone credentials, including values split across writes. Container startup and runtime log streams mask the literal resolved values of stored variables the same way. Two limits are worth knowing. Masking uses the variable configuration at the time, so changing a variable can't scrub older saved logs. And a build can still embed or encode a secret it receives, so secret mounts don't make an untrusted build script safe.

The secrets in shed.db are encrypted with the key described in Encryption at rest. Turn on backup encryption as well so service backups are not readable in your bucket.

Encryption at rest

shed encrypts secrets in shed.db with AES-256-GCM under a 32-byte master key. The encrypted values are every row of settings (including the GitHub App credentials and the backup age identity), every service variable, the resolved variables kept for each active deployment, and the S3 secret access key of each backup destination. Each value is bound to its row, so it can't be copied to another one. Stored values start with v1:.

The key file. The key is shed.key next to the config file, by default /etc/shed/shed.key, outside data.dir. It holds 32 random bytes, base64-encoded. If the file is missing, shed generates it on start with mode 0600 and logs a warning to back it up. The first start of a version with encryption converts existing plaintext values in one transaction. After that, a key that doesn't match makes shed refuse to start, with an error naming the key path.

systemd credentials. If $CREDENTIALS_DIRECTORY/shed.key exists, shed uses it instead and never generates one. Provide it with LoadCredential= or LoadCredentialEncrypted= in the unit. An encrypted credential made with systemd-creds can be bound to the host's TPM, so the key is never readable from disk.

Back up the key off the server. Losing it means losing the variables, GitHub App credentials, S3 secret, and backup age identity stored in shed.db. A shed.db backup contains only ciphertext, so recovering shed.db needs the original shed.key as well.

What is not encrypted by shed:

  • Docker volumes, so database and app data.
  • Docker's container configs under /var/lib/docker/containers, which hold each container's resolved environment variables in plain text.
  • Images and the build cache.
  • Build logs in <data>/logs, container logs, and shed.log.
  • Caddy's certificate storage in <data>/caddy.
  • Backups, unless backup encryption is on.

Sessions and OAuth tokens are already stored only as hashes.

What this protects. A copy of shed.db, or a backup of it, that leaks without the key file. It does not protect against root on the running host, which can read the key and the process.

For everything else, use full-disk encryption: your provider's encrypted block storage, or LUKS on the host covering /var/lib/shed and /var/lib/docker. A VPS that boots unattended needs a TPM, network unlock (Clevis with Tang), or a manual unlock after each reboot.

Webhook limits

POST /api/github/webhook is the one route that is public and writes. GitHub can't present a session, so it is authenticated by an HMAC-SHA256 signature of the body, and bounded by a budget so a flood can't exhaust the host.

Signature shapesha256=<64 hex> · 401Guard8 burst · 4 at onceRead body≤ 1 MiB · 10 s · 413Verify HMAC401 on mismatchEvent filternot push → 202Dedupbody hash + serviceDeploytrigger push · 202
Rejected deliveries never reach the deploy pipeline.
LimitValueOver the limit
Signature headersha256= plus 64 hex digits401, before the body is read
Concurrent deliveries4429
Rateburst of 8, refilled at 1 per second429
Body size1 MiB413
Body read time10 s deadlineRead fails
SignatureHMAC-SHA256 of the body with the webhook secret401
Dedup cache1,024 entries, 24 hours, in memoryOldest completed entries are evicted

Only the shape of the signature header can be checked before the body is read, because the HMAC covers the body. The guard is what bounds an unsigned sender: at most four bodies of at most 1 MiB are in flight, and at most one new delivery per second after the burst. The signature is verified with a constant-time comparison before anything is parsed.

After the signature passes, only push events matter, and others get 202. For a push to refs/heads/<branch>, every app service with the same repo and branch and auto_deploy on gets a push deployment. Each payload is hashed, and a repeated delivery to the same service is skipped, so GitHub's redeliveries don't deploy twice. A delivery that is mid-scheduling, or that fails to enqueue, answers 503 so it can be retried safely. The cache is cleared on restart. Pushes bigger than 1 MiB exceed the budget and need a manual deploy.

Webhook guard simulator
tokens 8.0 / 8in flight 0 / 4
  • No deliveries yet.