Security
Headers, tenant isolation, 401 semantics, demo boundary, reporting
What this page is
The security model that actually ships in this codebase, with the source paths to check it against. What you will not find here: compliance certifications, audit badges, or service-level promises — we do not hold any yet and will not claim them until we do. Everything below is checkable from the demo or the source.
Security response headers
Every route responds with five baseline headers, configured in apps/web/next.config.ts:
| Header | Value | Covers |
|---|---|---|
X-Frame-Options | DENY | clickjacking |
X-Content-Type-Options | nosniff | MIME sniffing |
Referrer-Policy | strict-origin-when-cross-origin | referrer leakage |
Content-Security-Policy | frame-ancestors 'none'; object-src 'none'; base-uri 'self' | framing, plugin content, base hijacking |
Permissions-Policy | camera=(), microphone=(), geolocation=() | powerful browser features |
The CSP is deliberately conservative — no script-src directive, because Next.js inline bootstrap scripts must keep working. Measure it yourself:
curl -sI https://demo.buildgrain.com/ | grep -iE 'x-frame|x-content-type|referrer-policy|content-security|permissions-policy'Tenant isolation
Tenant reads and writes go through one sanctioned path: scopedQuery() in packages/db/src/tenant.ts. Every query against a tenant-owned table is forced to account_id = <server-side accountId>, where the account id comes from the authenticated session — never from client input. A client-supplied accountId that does not match the active scope throws TenantScopeViolationError instead of being trusted.
On top of that, the route guards in apps/web/src/middleware.ts separate /app/* and /admin/* scopes, and API handlers re-verify against the database — the middleware is the routing layer, not the only enforcement. System-admin cross-account reads bypass scopedQuery deliberately and live behind the admin guards only.
The adversarial case is covered in the e2e suite: e2e/specs/16-tenant-isolation-malicious-payload.spec.ts replays requests with foreign account ids and asserts they are refused.
401 semantics
Unauthenticated requests get a precise reason, not a generic bounce (PRD section 10.4, implemented in withApi() — apps/web/src/lib/api.ts):
- No session signal at all → 401 with reason
signin-required. - Credentials present but expired or invalid → 401 with reason
session-expired.
Both arrive in the canonical envelope with a req_ request id, and the UI routes them to /login?reason=signin-required or /login?reason=session-expired accordingly. 403 means authenticated but not permitted — the deny-by-default RBAC matrix said no (see ADR-002 in /docs/concepts).
Demo boundary
The public sample at demo.buildgrain.com runs with hard limits, enforced in code rather than by policy:
- No customer data. The workspaces are seeded samples plus visitor workspaces created on sign-in. Do not enter real or sensitive data.
- Cleanup is not automated. No workspace-cleanup route or deployment schedule currently ships. Protected seed accounts are guarded against deletion, but visitor deletion and seed restoration require an operator until automation has executable route, schedule, and test evidence.
- Secrets are hashed. Raw access-key tokens are shown once at creation and only their SHA-256 hash is stored (
apps/web/src/app/api/access-keys/access-key-helpers.ts). API responses never include the stored hash. - No charge path. Entitlement state is simulated; the billing provider slot stays disabled in demo mode.
- Mail never leaves. In demo mode every rendered message stays in a local preview queue (
packages/mail/src/local-preview-adapter.ts). - No login bypass. The test auth adapter activates only with
NODE_ENV=testand never in demo mode (see ADR-004 in /docs/concepts).
Reporting a finding
If you find a vulnerability, tell us through the contact form at /contact — include steps to reproduce and the req_ request id if you have one. We read every report. The machine-readable pointer lives at /.well-known/security.txt.
Being precise about what we do not offer: there is no bug bounty program, and we do not publish a response-time promise yet — publishing one before we can prove it would be a promise without a track record, and this product does not do those.