Skip to content

Security Model

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

Cloud Credential Model (BYOC + Platform Fallback)

Section titled “Cloud Credential Model (BYOC + Platform Fallback)”

SAM supports Bring-Your-Own-Cloud (BYOC): users and self-hosters may store their own Hetzner, Scaleway, Vultr, Infomaniak, DigitalOcean, UpCloud, or GCP credentials, encrypted per-user in D1. This is the model for self-hosted deployments and BYO-key users.

However, SAM’s own hosted deployment also has an enabled platform-level cloud credential (platform_credentials, provider=hetzner, credential_type=cloud-provider). Provider resolution falls back user credential → platform credential, so on the hosted (zero-config) platform a user does not need their own cloud credential for SAM to provision workspaces or deployment nodes. Self-hosted deployments without a platform credential rely on user-supplied BYOC tokens.

These Cloudflare Worker secrets are generated or copied during deployment and are required for a fully functional install:

SecretPurpose
ENCRYPTION_KEYAES-256-GCM master key for BetterAuth sessions and user credential encryption
BETTER_AUTH_SECRETOptional override for BetterAuth session cookies (falls back to ENCRYPTION_KEY)
CREDENTIAL_ENCRYPTION_KEYOptional override for user credential encryption (falls back to ENCRYPTION_KEY)
JWT_PRIVATE_KEYRSA-2048 key for signing workspace and callback tokens
JWT_PUBLIC_KEYRSA-2048 key for token verification (exposed via JWKS)
DEPLOY_SIGNING_PRIVATE_KEYEd25519 key for signing deployment apply payloads (auto-generated)
DEPLOY_SIGNING_PUBLIC_KEYEd25519 key for deployment-node payload verification (auto-generated)
TRIAL_CLAIM_TOKEN_SECRETHMAC secret for trial onboarding claim tokens (auto-generated)
CF_API_TOKENCloudflare deploy, DNS, Origin CA certificate issuance, observability, and AI Gateway operations (requires Account → SSL and Certificates → Edit)
CF_ACCOUNT_IDCloudflare account identifier used by account-scoped Cloudflare APIs
CF_ZONE_IDCloudflare zone identifier used for DNS and Origin CA operations

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, Google OAuth, GitLab OAuth, analytics forwarding, R2 attachment-upload credentials, devcontainer cache credentials, trial provider keys, and smoke-test auth flags can be supplied as optional Worker secret fallbacks when an installation needs them. Runtime platform values saved through first-run setup or the superadmin platform config UI are stored encrypted in D1 and override environment fallbacks. They never appear in source control.

Production deployment secrets are additionally bounded by a GitHub Environment policy that permits deployments from the selected main branch only. This external policy is required because a workflow file on another branch cannot be trusted to enforce its own branch check. Automatic deployments also re-resolve the current main tip after entering the serialized production deployment queue, so a slower CI run for an older commit cannot roll back a newer deployment.

New VM nodes do not require static ORIGIN_CA_CERT or ORIGIN_CA_KEY Worker secrets. If those legacy secrets exist from an older deployment, remove them after draining old nodes and confirming the per-node CSR model is deployed.

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
GitLab OAuth client secretGitLab sign-in and repository accessRuntime D1 → Worker env (GITLAB_CLIENT_SECRET) → unset
Google infra OAuth client secretKeyless GCP/WIF authorization (separate client from login)Runtime D1 → Worker env (GOOGLE_CLIENT_SECRET) → unset

Resolving the table above costs 13 D1 queries and runs on the authentication preamble of every authenticated request, so the result is cached in memory per Worker isolate for PLATFORM_CONFIG_CACHE_MS (default 60 seconds — see the configuration reference).

The isolate that performs a change drops its own cache immediately, so an admin always sees their own write. Other warm isolates continue serving the previous values for up to the configured TTL. When rotating a credential in response to a suspected compromise, treat the old value as still live for that window; revoke it at the provider (GitHub, Google, GitLab) rather than relying on the SAM-side change alone. Setting PLATFORM_CONFIG_CACHE_MS=0 disables the cache entirely at the cost of 13 extra D1 queries per authenticated request.

User-provided secrets stored encrypted in D1:

CredentialPurposeEncryption
Cloud provider credentialsVM provisioning (Hetzner, Scaleway, Vultr, Infomaniak, DigitalOcean, UpCloud, GCP WIF or service-account JSON)AES-256-GCM, per-credential IV
Agent API keysClaude, OpenAI, Gemini, and other agent accessAES-256-GCM, per-credential IV
Agent OAuth tokensClaude Pro/Max, Codex subscriptionsAES-256-GCM, per-credential IV
Composable credentials (cc_*)Reusable credential + configuration attachments layered per project/profileAES-256-GCM, per-credential IV

Cloud provider credentials are stored with a credentialType of cloud-provider. GCP can use recommended keyless WIF or an OAuth-free service-account JSON key for VM provisioning. User credentials are never stored as environment variables or Worker secrets.

SAM uses BetterAuth with configured OAuth login providers for user authentication:

  1. User clicks a configured sign-in provider such as GitHub, Google, or GitLab
  2. API redirects to that provider’s OAuth flow
  3. The provider returns an authorization code
  4. API exchanges code for access token
  5. API fetches user profile and 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.

GCP WIF configuration and uploaded service-account JSON use the same versioned credential boundary. Existing unversioned WIF records are normalized when read. Service-account JSON is validated as a Google service_account key with an importable PKCS#8 RSA private key; uploaded token_uri and other endpoint fields are ignored.

The complete source credential is encrypted at rest with AES-256-GCM. SAM signs short-lived RS256 assertions and exchanges them only at the fixed Google OAuth token endpoint. Derived Google access tokens are cached in KV only until their returned expiry minus a safety buffer; they are never persisted as primary credentials. Cache identity includes the authentication mode and WIF or private-key identity, so switching modes or rotating a key cannot reuse a prior token.

Save and rotation verify the selected Compute zone before a D1 transaction replaces both legacy and composable credential copies. A failed verification or transaction leaves the previous credential intact. Disconnect removes SAM’s encrypted copies and cached derivatives but does not revoke a Google-managed service-account key.

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, cloud-init receives only the short-lived bootstrap/callback material needed to contact the control plane; long-lived provider and repository credentials are fetched through the bootstrap exchange:

  1. API creates a one-time bootstrap token (cryptographically random, 15-minute default expiry)
  2. Cloud-init starts the VM Agent with control-plane metadata and stores callback JWT material in a root-only file rather than the systemd environment
  3. VM Agent redeems the token: POST /api/bootstrap/{token}
  4. API returns the full configuration and encrypted credential payloads needed for the node
  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.

Interactive previews are a no-network execution tier for single-file HTML library artifacts:

  1. The authenticated app requests a short-lived URL only after project access and file scope are checked (apps/api/src/routes/library.ts).
  2. The API signs the project, file, file version, and expiry into a path prefix with a deployment-owned HMAC key (apps/api/src/services/interactive-preview.ts).
  3. preview.BASE_DOMAIN is dispatched before session middleware. It never reads session cookies (apps/api/src/index.ts, apps/api/src/routes/interactive-preview-host.ts).
  4. Every response CSP includes sandbox allow-scripts, giving scripts an opaque origin even when opened directly, and denies connections, workers, objects, base URLs, and forms.
  5. The app renders an allow-scripts-only iframe sandbox. Same-origin, forms, popups, downloads, and top navigation are never granted.

The preview starts as soon as a user opens the artifact (apps/web/src/components/library/InteractiveHtmlPreview.tsx). Opening the file is itself the deliberate user action — scripts still never execute passively while scrolling a chat timeline, because the document card only mounts the preview once clicked (apps/web/src/components/project-message-view/tool-cards/DocumentCard.tsx). The isolation above, not a confirmation prompt, is what contains the artifact: it runs on a separate origin with an opaque origin, no cookies or storage in scope, and no network egress, so there is no credential or exfiltration path to consent to.

The dedicated origin contains iframe-policy regressions; the CSP sandbox header protects direct-open links. Preview is deliberately absent from credentialed CORS and BetterAuth trusted origins, and responses never set cookies.

  • 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
  • Per-user credential isolation — each user’s cloud/agent secrets are encrypted with a per-credential IV and are never shared between users