Skip to main content

Security model

Project Brain encrypts stored integration credentials and emits audit events for privileged activity and credential access.

Summary

  • All integration credentials are encrypted at rest with AES-256-GCM. This covers:
    • OAuth tokens on platform_integrations (access + refresh, for Atlassian).
    • OAuth app client secrets on platform_oauth_apps (the Atlassian client_id/secret pair shared by Jira and Confluence).
    • GitHub App credentials on platform_integrations (client secret, webhook secret, and the App's PEM private key — captured through the in-app manifest flow).
  • BRAIN_MASTER_KEY is the encryption key. It must be 32 random bytes, base64 encoded.
  • Losing BRAIN_MASTER_KEY makes stored credentials unreadable. Each integration must be reconfigured (Atlassian OAuth re-pasted, GitHub App re-registered via manifest, every provider reconnected).
  • In production, prefer BRAIN_MASTER_KEY_FILE over inline BRAIN_MASTER_KEY.
  • Credential decrypts and refreshes are emitted as structured audit logs.

What the key protects

ThreatProtected?
Stolen Postgres backupYes. Tokens are ciphertext without the key.
pg_dump exfiltrationYes. Dumps contain encrypted token blobs.
Read-only database accessYes. Credentials are stored as opaque bytea values.
Tampered ciphertextYes. AES-GCM rejects modified ciphertext during decrypt.

What the key does not protect

ThreatWhy
Remote code execution in the appThe running app has the key in memory.
Access to the container environmentInline env vars can be visible through runtime metadata.
Key baked into a container imageImages can be copied. Never bake secrets into images.
Malicious super-adminThis is an RBAC issue, not a cryptography issue. Audit logs record privileged actions.
Compromised OAuth providerRevoke the grant in the provider console.

Provide the master key

Use one of these methods.

Set BRAIN_MASTER_KEY_FILE to a mounted secret file:

services:
web:
image: projectbrain/web:latest
environment:
BRAIN_MASTER_KEY_FILE: /run/secrets/brain-master-key
secrets:
- brain-master-key

secrets:
brain-master-key:
file: ./secrets/brain-master-key

The file must contain base64 for 32 random bytes. The app reads it once at boot and trims one trailing newline.

If both BRAIN_MASTER_KEY and BRAIN_MASTER_KEY_FILE are set, the file wins.

Acceptable: orchestrator secret env var

Most orchestrators can inject a secret as an environment variable.

Examples:

{
"name": "BRAIN_MASTER_KEY",
"valueFrom": "arn:aws:secretsmanager:us-east-1:...:secret:projectbrain/master-key"
}
gcloud run deploy projectbrain-web \
--set-secrets=BRAIN_MASTER_KEY=projects/.../secrets/brain-master-key:latest

This is easy to operate, but the final value is still an environment variable inside the container.

Development only: inline env var

BRAIN_MASTER_KEY=Yk5kV...

Generate a key with:

openssl rand -base64 32
warning

Do not use inline BRAIN_MASTER_KEY in production.

Ciphertext format

Every encrypted bytea blob in the database uses the same versioned format. Columns:

  • platform_integrations.oauth_access_token_enc, oauth_refresh_token_enc
  • platform_integrations.github_app_client_secret_enc, github_app_webhook_secret_enc, github_app_private_key_enc
  • platform_oauth_apps.client_secret_enc
v2: 0x02 || wrappedDekLen(2B) || wrappedDek || iv(12B) || tag(16B) || ciphertext
v1: 0x01 || keyId(1B) || iv(12B) || tag(16B) || ciphertext
v0: iv(12B) || tag(16B) || ciphertext
  • v1 is written in local mode (DEK == BRAIN_MASTER_KEY); keyId supports future multi-key rotation.
  • v2 is written in aws mode (KMS envelope): wrappedDek is the KMS-wrapped per-blob data key, unwrapped via kms:Decrypt at read time.
  • iv is 12 random bytes per encryption; tag is the AES-GCM authentication tag.

The app auto-detects v2, v1, and legacy v0 blobs during decrypt by the leading version byte.

AWS KMS mode (no plaintext keys)

Set KMS_PROVIDER=aws to remove plaintext key material from the deployment entirely. Two things change:

  • Credential encryption becomes envelope encryption. Each encrypted value gets a fresh data key from kms:GenerateDataKey under the CMK at KMS_KEY_REF; the payload is AES-256-GCM-encrypted with that data key and the KMS-wrapped data key is stored alongside it (ciphertext format v2, below). The plaintext CMK never leaves KMS. BRAIN_MASTER_KEY is not needed for new writes.
  • GitHub App signing stops holding the PEM. When the App is registered through the manifest flow, the PEM is imported into a non-exportable KMS RSA key (Origin=EXTERNAL) and only the resulting key ARN is stored in platform_integrations.github_app_signing_key_ref — the github_app_private_key_enc column stays null. App JWTs are signed in-KMS via kms:Sign (RSASSA_PKCS1_V1_5_SHA_256); installation tokens are then minted from the JWT and cached in-process.

Required setup:

KMS_PROVIDER=aws
KMS_KEY_REF=arn:aws:kms:us-east-1:ACCOUNT:key/UUID # symmetric CMK

IAM for the web and worker task roles:

ActionOn
kms:GenerateDataKey, kms:Decryptthe KMS_KEY_REF CMK
kms:Signthe GitHub App signing key
kms:CreateKey, kms:GetParametersForImport, kms:ImportKeyMaterialaccount (one-time, at App registration)

@aws-sdk/client-kms is an optional dependency, lazily loaded only in aws mode — local deployments never install or load it. Region and credentials come from the standard AWS provider chain (task role / env / profile).

One unavoidable exposure: GitHub generates the App private key and you download it, so the PEM is plaintext in process memory once, during the import call. After import it is non-exportable and never persisted.

Provide secrets from files (Secrets Manager / Vault)

Any sensitive env var can be injected from a file instead of an inline value using the <NAME>_FILE convention — the file wins over an inline value and the secret never appears in docker inspect or /proc/<pid>/environ. The resolver mutates process.env at startup, so libraries that read it directly (Auth.js, the pg driver) see the file-backed values too.

File-backed names: BRAIN_MASTER_KEY, DATABASE_URL, AUTH_SECRET, INTERNAL_SERVICE_TOKEN, GRAPHIFY_SIDECAR_TOKEN, GOOGLE_GENERATIVE_AI_API_KEY, GOOGLE_API_KEY, GEMINI_API_KEY, ANTHROPIC_API_KEY, OPENAI_API_KEY, AUTH_AZURE_AD_CLIENT_SECRET.

Example with the postgres image's native POSTGRES_PASSWORD_FILE and an app-side DATABASE_URL_FILE (e.g. populated by an AWS Secrets Manager sidecar or Vault Agent):

services:
postgres:
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres-password
secrets: [postgres-password]
web:
environment:
DATABASE_URL_FILE: /run/secrets/database-url
AUTH_SECRET_FILE: /run/secrets/auth-secret
secrets: [database-url, auth-secret]
secrets:
postgres-password: { file: ./secrets/postgres-password }
database-url: { file: ./secrets/database-url }
auth-secret: { file: ./secrets/auth-secret }

Rotate the key

local mode

The data format supports key rotation, but local mode loads only BRAIN_MASTER_KEY as key id 1.

Until multi-key loading is implemented, rotation requires one of these approaches:

ApproachTrade-off
Re-encrypt all stored credentialsKeeps integrations connected, but requires a controlled migration.
Disconnect and reconnect integrationsSimpler operationally, but requires provider admins to reconnect.

To re-encrypt:

  1. Generate a new key.
  2. Decrypt every encrypted column with the old key (oauth_access_token_enc, oauth_refresh_token_enc, github_app_client_secret_enc, github_app_webhook_secret_enc, github_app_private_key_enc, platform_oauth_apps.client_secret_enc).
  3. Re-encrypt each value with the new key and write it back.
  4. Replace BRAIN_MASTER_KEY.
  5. Restart the app.

aws mode

Routine rotation is just CMK rotation in KMS — v2 rows store the wrapped data key, so rotating the CMK (or enabling automatic annual rotation) needs no re-encrypt.

Migrating an existing local install to aws is a one-time re-encrypt of every v1 row to v2 plus importing the GitHub App PEM into KMS. Run it with both the old BRAIN_MASTER_KEY and the aws KMS env set; afterward BRAIN_MASTER_KEY can be dropped (keep it only while any v1 rows remain):

KMS_PROVIDER=aws \
KMS_KEY_REF=arn:aws:kms:us-east-1:ACCOUNT:key/UUID \
BRAIN_MASTER_KEY=<old base64 key> \
DATABASE_URL=postgres://... \
pnpm --filter @projectbrain/worker reencrypt:kms

Audit visibility

Project Brain emits credential and admin events through two channels:

ChannelWhat it contains
/admin/audit and the audit_log tableAdmin actions such as integration connect, disconnect, role changes, and project lifecycle events.
Structured stdout logsCredential decrypts and refreshes with kind: "audit".

Credential and configuration events include:

platform_integration.token_decrypted
platform_integration.token_refreshed
platform.integration.connect
platform.integration.disconnect
platform.integration.app_configured
platform.oauth_app.configure
platform.oauth_app.remove

Filter your SIEM for:

kind=audit

For SOC 2 or ISO 27001 evidence, filter for:

kind=audit AND action=platform_integration.token_*

Graphify sidecar boundary

Knowledge graph builds use a dedicated graphify-sidecar container. The worker prepares the Git checkout, scrubs the authenticated remote, verifies the scrub by re-reading origin, and only then calls the sidecar with a bearer token. If the scrub or its verification fails, the worker fails the run rather than handing off a workspace that still embeds an installation token. The sidecar receives no GitHub App private key, no GitHub installation token, and no project write credentials.

GRAPHIFY_SIDECAR_TOKEN is required for all graphify builds. The sidecar rejects every request when the token is unset, so an unset value is fail-closed rather than fail-open.

The sidecar runs Graphify directly, not through OpenCode. It is attached only to an internal Compose network and uses graphify-egress-proxy for outbound LLM calls. Operators must set GRAPHIFY_EGRESS_ALLOWED_HOSTS to the public provider hostnames or, preferably for enterprise deployments, a customer-controlled LLM gateway hostname. The proxy validates each allowlist entry at startup and refuses to start on malformed input.

The sidecar container is hardened with a non-root user, dropped capabilities, no-new-privileges, read-only root filesystem, and tmpfs temp storage. The only persistent mount is the graphify workspace cache.

The sidecar's build output is non-sensitive: it writes graph artifacts under graphify-out/ (graph.json, GRAPH_REPORT.md, and wiki/) into the workspace, which the worker commits back to the project repo. The sidecar does receive the LLM provider API key it needs to call the model (in the run request, not via the repo), but its stdout/stderr are sanitized of that key and the bearer token before the worker logs them. See Knowledge graph builds for what each build produces and how to tune it.

Recommended enterprise settings:

GRAPHIFY_SIDECAR_TOKEN=<32+ random chars>
GRAPH_BUILDER_BASE_URL=https://llm.internal.example.com/v1
GRAPHIFY_EGRESS_ALLOWED_HOSTS=llm.internal.example.com

Authentication escape hatches

Project Brain ships two opt-in flags that enable the email-only dev-login credentials provider. Both must be off on any deployment with real customer data.

FlagHonored whenIntended use
DEV_AUTH_ENABLEDNODE_ENV !== 'production'Local development. Refuses to load on production images because Next.js inlines NODE_ENV at build time.
DEMO_AUTH_ENABLEDAlways (including NODE_ENV=production)Public demo and staging deployments built from the production image. Bypasses the NODE_ENV gate that protects DEV_AUTH_ENABLED.

The dev-login provider takes an email, upserts a user, and signs them in — there is no password check, no SSO, and no domain proof. Either flag set to true on a deployment that holds real customer data is equivalent to disabling authentication.

For production customer deployments: leave both flags unset (or false) and configure Microsoft Entra ID per Install Project Brain.

Cryptography choices

  • AES-256-GCM provides confidentiality and integrity in one standard primitive.
  • 12-byte IVs match the recommended GCM size.
  • 16-byte auth tags are verified before plaintext is returned.
  • OAuth state tokens use HMAC-SHA256 with constant-time comparison.

Dependency and base-image patching

The published images bundle third-party code — the Node dependencies from pnpm-lock.yaml and the OS packages in each base layer. Maintainers track vulnerabilities in both via weekly Dependabot updates plus Dependabot security updates (which open out-of-band PRs on a matched advisory), and every release ships signed CycloneDX SBOMs (per image and repo-wide) so you can scan a given tag with Trivy, Grype, or any SCA tool.

Fixes ship as new immutable image tags — there is no in-place hotfix. Apply them by upgrading to the patched tag (see Upgrade Project Brain). Remediation timelines by CVSS severity, supported versions, and how to report a vulnerability are defined in the Security Policy.

Planned improvements

  • GCP KMS and Azure Key Vault providers behind the same seam as the AWS provider
  • Multi-key rotation through BRAIN_MASTER_KEY_2, BRAIN_MASTER_KEY_3, and later keys (local mode)
  • Formal key custody documentation for compliance evidence