Skip to content

Security Model

SAM’s security model separates platform secrets (managed by operators) from user credentials (encrypted per-user in the database).

These are Cloudflare Worker secrets set during deployment:

SecretPurpose
ENCRYPTION_KEYAES-256-GCM key for encrypting user credentials
JWT_PRIVATE_KEYRSA-2048 key for signing workspace and callback tokens
JWT_PUBLIC_KEYRSA-2048 key for token verification (exposed via JWKS)
CF_API_TOKENCloudflare deploy, DNS, Origin CA certificate issuance, observability, and AI Gateway operations (requires Account → SSL and Certificates → Edit)

Security keys are automatically generated and persisted by Pulumi on first deployment. Cloudflare secrets remain Worker secrets because they are deployment trust roots. GitHub App/OAuth, GitHub webhook, and Google OAuth credentials can be supplied either as optional environment fallbacks or through the first-run/superadmin platform config UI; runtime values are stored encrypted in D1 and override environment fallbacks. They never appear in source control.

Admin-managed integration secrets stored encrypted in D1:

CredentialPurposeResolution order
GitHub OAuth client secretGitHub sign-in and OAuth refreshRuntime D1 → Worker env → unset
GitHub App private keyInstallation tokens for repository accessRuntime D1 → Worker env → unset
GitHub webhook secretGitHub App webhook HMAC verificationRuntime D1 → Worker env → unset
Google login OAuth client secretGoogle sign-in (BetterAuth social login)Runtime D1 → Worker env (GOOGLE_LOGIN_CLIENT_SECRET) → unset
Google infra OAuth client secretGCP deployment authorization flows (separate client from login)Worker env (GOOGLE_CLIENT_SECRET) → unset

User-provided secrets stored encrypted in D1:

CredentialPurposeEncryption
Hetzner API tokenVM provisioningAES-256-GCM, per-credential IV
Agent API keysClaude/OpenAI API accessAES-256-GCM, per-credential IV
Agent OAuth tokensClaude Pro/Max subscriptionsAES-256-GCM, per-credential IV

User credentials are never stored as environment variables or Worker secrets.

SAM uses BetterAuth with GitHub OAuth for user authentication:

  1. User clicks “Sign in with GitHub”
  2. API redirects to GitHub OAuth
  3. GitHub returns authorization code
  4. API exchanges code for access token
  5. API fetches user profile and primary email
  6. BetterAuth creates/updates user record and session
  7. Session cookie set in browser
TokenLifetimePurposeValidated By
Session cookieHoursBrowser authenticationAPI Worker (BetterAuth)
Workspace JWTMinutesTerminal WebSocket authVM Agent (via JWKS)
Bootstrap token5 minutesOne-time VM credential injectionAPI Worker
Callback tokenMinutesVM Agent → API callbacksAPI Worker

User credentials are encrypted at rest using AES-256-GCM:

Encrypt: plaintext + ENCRYPTION_KEY → { ciphertext, iv } (stored in D1)
Decrypt: { ciphertext, iv } + ENCRYPTION_KEY → plaintext (on-demand)

Each credential gets a random initialization vector (IV), ensuring identical plaintext values produce different ciphertext.

Terminal WebSocket connections use short-lived JWTs:

  1. Browser requests a terminal token: POST /api/terminal/token
  2. API signs a JWT with the workspace ID and user ID
  3. Browser connects: wss://ws-{id}.domain/workspaces/{id}/shell?token=...
  4. Worker proxies the WebSocket to the VM Agent
  5. VM Agent validates the JWT against the API’s JWKS endpoint (/.well-known/jwks.json)

When a new VM starts, it needs credentials (callback URL, node ID) but no secrets are embedded in cloud-init:

  1. API creates a one-time bootstrap token (cryptographically random, 5-minute expiry)
  2. Cloud-init script includes only the token and API URL
  3. VM Agent redeems the token: POST /api/bootstrap/{token}
  4. API returns the full configuration (callback URL, node ID, etc.)
  5. Token is invalidated after use

New nodes use per-node Origin CA key material rather than a platform-shared private key:

  1. The API Worker passes a node-scoped certificate endpoint into cloud-init (apps/api/src/services/nodes.ts).
  2. Cloud-init generates /etc/sam/tls/origin-ca-key.pem locally on the VM, creates a CSR, and posts only that CSR to POST /api/nodes/:id/origin-ca-certificate with the node callback JWT (packages/cloud-init/src/template.ts).
  3. The API Worker verifies the callback token is node-scoped and matches :id, then signs the CSR through Cloudflare Origin CA using CF_API_TOKEN (apps/api/src/routes/node-lifecycle.ts, apps/api/src/services/origin-ca-certificates.ts).
  4. The VM stores the returned certificate at /etc/sam/tls/origin-ca.pem and starts the VM agent with TLS_CERT_PATH and TLS_KEY_PATH.

The certificate hostnames remain wildcard-scoped (*.BASE_DOMAIN, *.vm.BASE_DOMAIN, and BASE_DOMAIN) so existing ws-* and {node}.vm routing continues to work. The private key is no longer shared across nodes or embedded in static cloud-init user-data. Each node receives a distinct private key and short-lived certificate, with ORIGIN_CA_CERT_VALIDITY_DAYS defaulting to 7 days.

Deployments created before the per-node CSR model may have running nodes that still hold a broadly distributed wildcard ORIGIN_CA_KEY. Rotate that legacy material by draining or deleting old nodes, deploying the per-node certificate model, revoking the old wildcard Origin CA certificate in Cloudflare SSL/TLS → Origin Server, and removing any manually configured ORIGIN_CA_CERT/ORIGIN_CA_KEY Worker secrets. New nodes do not require those Worker secrets.

  • Rotate keys quarterly — regenerate JWT and encryption keys
  • Minimal GitHub App permissions — only Contents (read/write), Metadata (read-only), and Email addresses (read-only)
  • HTTPS everywhere — all traffic encrypted via Cloudflare
  • Session isolation — each workspace JWT is scoped to a specific workspace ID
  • No shared cloud credentials — BYOC model means the platform has no Hetzner access