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:
GET /api/auth/loginsets 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.- GitHub sends the browser to
/api/auth/callbackwith 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. - shed exchanges the code for your GitHub profile. A failed exchange redirects to
/login?error=failed. - 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.
| Property | shed_session | shed_oauth_state |
|---|---|---|
| Value | 32 random bytes, base64url | 16 random bytes, base64url |
| Stored server-side as | sha256 hex in sessions.token_hash | nothing; compared to the query |
| Lifetime | 30 days, fixed at sign-in | 10 minutes |
| Path | / | /api/auth |
| HttpOnly | yes | yes |
| SameSite | Lax | Lax |
| Secure | when server.url is https | when 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.
- Origin check. For POST, PUT, PATCH, and DELETE, an
Originheader must equal the origin ofserver.url. With noOrigin, aSec-Fetch-Siteheader, if present, must besame-origin. Anything else is a 403untrusted 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, likecurl, 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 theSameSite=Laxcookie. 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.
| Header | Value | Effect |
|---|---|---|
Content-Security-Policy | frame-ancestors 'none' | No other site can embed the dashboard in a frame. |
X-Frame-Options | DENY | The same rule for older browsers. |
X-Content-Type-Options | nosniff | Browsers must trust the declared content type. |
Referrer-Policy | same-origin | Other 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.
| Secret | Where it lives | How it is protected |
|---|---|---|
| Service variables | variables table in shed.db, encrypted | Supplied 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 credentials | settings table: client secret, webhook secret, private key | Never returned by the API. |
| Git clone credentials | Short-lived installation tokens | Passed as a scoped HTTP authorization header in the child process environment, not on a command line or in repository config. |
| age secret key | settings table, backup.age_identity | Only GET /api/backups/settings/key returns it, on demand. |
| S3 secret access key | backup_destinations table | The API returns hasSecret, never the secret. Saving with a blank secret keeps the stored one. |
| Session tokens | Only their sha256 in sessions | A database copy can't be replayed as a cookie. |
| Database passwords during backups | The container's own environment | Passed 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, andshed.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.
| Limit | Value | Over the limit |
|---|---|---|
| Signature header | sha256= plus 64 hex digits | 401, before the body is read |
| Concurrent deliveries | 4 | 429 |
| Rate | burst of 8, refilled at 1 per second | 429 |
| Body size | 1 MiB | 413 |
| Body read time | 10 s deadline | Read fails |
| Signature | HMAC-SHA256 of the body with the webhook secret | 401 |
| Dedup cache | 1,024 entries, 24 hours, in memory | Oldest 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.
- No deliveries yet.