Skip to content

Security Model

Status: Authoritative security reference.

Cross-references: ARCHITECTURE.md (encryption model), LEASING.md (lease isolation), WEBHOOK_DESIGN.md (HMAC verification).

Threat Protection
Tenant B reads Tenant A’s forge token Envelope encryption + tenant-scoped DB queries
Attacker forges webhook events HMAC-SHA256 signature verification
Attacker calls admin API SHA-256 hashed API keys
Attacker brute-forces API key Key length + hash-based comparison
Worker crash exposes credentials Tokens never logged, never persisted in plaintext
DoS via webhook flood Rate limiting per tenant + HMAC cost
Multi-tenant data leak Durable Object isolation + tenant-scoped DB queries

See ARCHITECTURE.md for the full model.

Cloudflare Secret (master key, 256-bit) ← CREDENTIAL_MASTER_KEY
↓ AES-GCM (random 96-bit IV per token)
Forge token (PAT or OAuth token, encrypted, stored in Supabase)

New encrypted records use the prefix enc:v1: followed by base64url-encoded IV and ciphertext, separated by a colon:

enc:v1:<iv_base64url>:<ct_base64url>

Legacy records are raw base64 (the old btoa(token) format) and are transparently accepted by the decryptor.

The master key is a 256-bit (32-byte) random value, base64-encoded, provisioned as a Cloudflare Secret:

Terminal window
wrangler secret put CREDENTIAL_MASTER_KEY <<< "<base64-of-32-random-bytes>"

Generate one locally with:

Terminal window
node -e "console.log(Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64'))"

If CREDENTIAL_MASTER_KEY is absent when an encrypt or decrypt is called, the worker throws a clear error ("CREDENTIAL_MASTER_KEY is not configured") — no silent fallback.

OnCreate (new connection):
1. Encrypt token with master key via AES-GCM (random IV)
2. Store `enc:v1:<iv>:<ct>` in Supabase forge_connections.encrypted_token
OnRead (engine tick):
1. Read encrypted_token from Supabase
2. If prefixed `enc:v1:` → AES-GCM decrypt with master key
3. If raw base64 → legacy decode (backward compat)
4. Use token in-memory for forge API calls
5. Clear from memory when tick completes
OnRotate:
1. Encrypt new token with master key (new random IV)
2. Atomically update encrypted_token in Supabase
3. Old value is never decrypted again (no overlap window)
OnCreate:
1. Generate random 256-bit DEK
2. Encrypt DEK with master key → (encryptedDEK, nonce)
3. Encrypt token with DEK → (encryptedToken, nonce)
4. Store (encryptedDEK, nonce, encryptedToken, nonce) in Supabase
OnRead (engine tick):
1. Read encrypted DEK + nonce from Supabase
2. Decrypt DEK with master key (from Cloudflare Secrets)
3. Read encrypted token + nonce from Supabase
4. Decrypt token with DEK
5. Use token in-memory for forge API calls
6. Clear from memory when tick completes
OnRotate:
1. Encrypt new token with same DEK
2. Atomically swap encrypted token in Supabase
3. Old token invalid at next tick (no overlap window)
Context Rule
API responses Tokens are never returned by any API endpoint
Logs Decrypted tokens are never written to logs
Error messages Tokens are redacted in errors (<REDACTED>)
Crash dumps No token data in stack traces
Client-side Tokens never reach the browser (no JS API exposes them)

Rotating a tenant’s forge token must be atomic — there must be no window where both the old and new tokens are valid:

  1. Encrypt new token with existing DEK.
  2. Atomically update the encrypted token row in Supabase.
  3. Next engine tick uses the new token; old token is never decrypted again.

See WEBHOOK_DESIGN.md for the full routing flow.

1. Extract signature header: X-Hub-Signature-256: sha256=<hex>
2. Extract tenant from request (tenant_id → forge_connection)
3. Look up webhook_secret from Supabase
4. Compute: HMAC-SHA256(webhook_secret, raw_request_body)
5. Constant-time compare with header signature
6. If mismatch → 401 Unauthorized
7. If match → proceed

Always use crypto.timingSafeEqual() (JS) or hmac.Equal() (Go) to prevent timing attacks. Never use == or === for signature comparison.

Each forge connection has a webhook_secret column — a random 32-byte value generated during onboarding. Secrets are:

  • Stored encrypted in Supabase (same envelope encryption as forge tokens).
  • Never returned by API endpoints.
  • Rotatable via the API (new secret takes effect immediately).

API keys are never stored in plaintext. The workflow:

Tenant sends: X-API-Key: my-secret-key
Worker:
1. Hash the key: SHA-256(salt + key)
2. Compare hash against tenants.api_key_hash in Supabase
3. If match → attach tenant_id to request context
4. If no match → 401 Unauthorized

When creating a tenant, a random 32-byte API key is generated. The key is returned to the caller exactly once (in the CreateTenant response). The salt is stored alongside the hash in the tenants table.

The admin key (ADMIN_KEY in Cloudflare Secrets) is a special key that grants full access to all tenant endpoints. It is:

  • Injected as a Cloudflare Secret at deploy time.
  • Never stored in Supabase (checked before DB lookup).
  • Used for internal/management operations.
Provider OAuth2 scopes Recommendation
Gitea 1.23+ Granular (read:repository, write:repository) Default auth path
Forgejo < 1.22 No scopes implemented (full admin) Use PAT paste
Codeberg No scopes implemented Use PAT paste
1. Tenant clicks "Connect with OAuth2" on dashboard
2. Redirect to: https://forge.example.com/login/oauth/authorize?
client_id={app_id}&redirect_uri={callback}&response_type=code&scope=read:repository,write:repository
3. Tenant authorizes on their forge instance
4. Forge redirects to callback with authorization code
5. Worker exchanges code for access + refresh tokens
6. Tokens are encrypted and stored in forge_connections
7. Access token is used for API calls; refresh token renews on expiry
1. Tenant pastes a scoped PAT in the dashboard
2. Token is encrypted via envelope encryption
3. Stored in forge_connections.token_encrypted
4. Used for API calls
{
"sub": "user_id",
"org_id": "org_id",
"exp": 1722786400
}
  • Signed with Cloudflare Secret key.
  • Stored in httpOnly cookie.
  • 30-day TTL, refreshable.
Method Path Description
GET /auth/github Redirect to GitHub OAuth
GET /auth/github/callback Exchange code for token, create session
GET /api/v1/me Get current user
POST /api/v1/logout Invalidate session cookie
Secret Purpose
CREDENTIAL_MASTER_KEY Envelope encryption master key (256-bit, base64)
GONDOLIER_MASTER_KEY Deprecated alias; use CREDENTIAL_MASTER_KEY
SUPABASE_URL Supabase REST API URL
SUPABASE_SERVICE_ROLE_KEY Supabase service role (full DB access)
ADMIN_KEY Admin API key (not hashed)

Injected at deploy time via wrangler secret put. Never in repo.

Database-encrypted fields:

Column Table Algorithm
encrypted_token forge_connections enc:v1:<iv>:<ct> AES-GCM
webhook_secret forge_connections Raw (DB encryption)

Every API request must carry a tenant context. The AuthMiddleware extracts the tenant_id from the API key hash and attaches it to the request context. All database queries filter by this tenant_id.

Row-level isolation via tenant_id foreign keys:

  • forge_connections.tenant_id → tenants.id
  • managed_repos.tenant_id → tenants.id
  • audit_log.tenant_id → tenants.id

All queries include WHERE tenant_id = $1 (or the admin bypass).

Each DO instance is keyed by (tenant_id, repo_slug). One DO per managed repo. Storage is isolated per DO instance — zero cross-tenant access.

A worker tick processes one tenant at a time. The decrypted token for one tenant never enters the memory scope of another tenant’s tick.

Per-tenant sliding window via Cloudflare KV:

  • Default: 100 requests per 60-second window.
  • Key: rl:{tenant_id}.
  • On limit exceeded: 429 Too Many Requests.

HMAC verification is computationally cheap (SHA-256), but a DoS attacker could flood the endpoint. Mitigations:

  • HMAC failure returns 401 immediately (no DB lookup).
  • Per-tenant rate limiting via KV.
  • Unknown tenant (before DB lookup) returns 404 without HMAC verification.

Per-(tenant, forge_instance) sliding window:

  • Key: rl:{tenant_id}:{forge_host}.
  • On 429/5xx from forge: exponential backoff.
  • One tenant’s rate limit issues never impact another tenant.
  1. Never log plaintext tokens. Audit every log.*, fmt.*, slog.* call in any diff touching credentials.
  2. Fail safe. External dependency failures must not crash the process.
  3. Fail isolated. One tenant’s failure must not affect other tenants.
  4. Token rotation is atomic. No overlap window between old and new tokens.
  5. Encrypt all tenant credentials at rest. No plaintext storage.