# How Paperclip Enforces Company-Scoped Data Isolation and Multi-Tenancy

> Discover how Paperclip enforces company-scoped data isolation and multi-tenancy using a `company_id` foreign-key for robust security across all layers.

- Repository: [Paperclip/paperclip](https://github.com/paperclipai/paperclip)
- Tags: architecture
- Published: 2026-08-16

---

**Paperclip implements single-tenant deployment with a multi-company data model, where every database record carries a `company_id` foreign-key that acts as a hard isolation boundary enforced across authentication, middleware, services, and storage layers.**

Paperclip's architecture separates customer data through a **company-scoped data isolation model** rather than running separate infrastructure per tenant. This approach allows the platform to host multiple organizations on shared compute and storage while maintaining strict guarantees that no company can access another's data. The enforcement relies on a `company_id` column present in every core table, propagated through request contexts, and validated at multiple architectural layers.

## The Schema Foundation: `company_id` as the Isolation Boundary

Every business record in PostgreSQL belongs to exactly one company. The schema design—documented in [`doc/SPEC-implementation.md`](https://github.com/paperclipai/paperclip/blob/main/doc/SPEC-implementation.md) §7.1—mandates that all tables except `companies` itself include a `company_id` foreign key referencing `companies.id`.

| Entity | Primary key | Company foreign-key |
|--------|-------------|---------------------|
| `companies` | `id` | — |
| `agents` | `id` | `company_id` |
| `projects` | `id` | `company_id` |
| `issues` | `id` | `company_id` |
| `cost_events` | `id` | `company_id` |
| `activity_log` | `id` | `company_id` |

Foreign-key constraints at the database level prevent orphaned records and enforce referential integrity. This schema-level guarantee is the first line of defense in Paperclip's multi-tenancy enforcement.

## Authentication Context: Establishing Company Identity

Two authentication paths populate the company context differently.

### Board Users

Board users authenticate via session cookies. The session contains the list of companies the board can administer. Board members implicitly have access to **all** companies in the deployment, making them super-tenant operators rather than single-tenant users.

### Agent API Keys

Agents authenticate with bearer tokens derived from API keys. The `agent_api_keys` table (§7.8 of the implementation spec) stores the `company_id` directly on the key record. The JWT validator in [`server/src/agent-auth-jwt.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/agent-auth-jwt.ts) extracts this value:

```typescript
// server/src/agent-auth-jwt.ts
const payload = verifyJwt(token);
req.agent = {
  id: payload.sub,
  companyId: payload.companyId, // bound to the key at creation time
};

```

The `companyId` is attached to the request object as `req.companyId`, making it available to downstream middleware and services.

## Request-Level Middleware: Resolving the Active Company

The middleware layer in [`server/src/paths.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/paths.ts) determines which company a request targets. It supports two resolution strategies:

1. **URL prefix parsing** — Routes like `/PAP/...` encode the company identifier in the path
2. **Auth context fallback** — Falls back to the company embedded in the authenticated session or JWT

The UI-side helper `normalizeCompanyPrefix` in [`ui/src/plugins/bridge.ts`](https://github.com/paperclipai/paperclip/blob/main/ui/src/plugins/bridge.ts) ensures consistent formatting:

```typescript
// ui/src/plugins/bridge.ts
export function resolveHostNavigationHref(to: string, companyPrefix: string | null) {
  if (!companyPrefix) return to;
  return `/${normalizeCompanyPrefix(companyPrefix)}${to}`;
}

```

Once resolved, `req.companyId` is populated and propagated throughout the request lifecycle.

## Service-Layer Enforcement: Query-Scoped Data Access

Every database query includes an explicit `company_id` filter. Services receive `companyId` from the request context and apply it unconditionally. The [`issue-service.ts`](https://github.com/paperclipai/paperclip/blob/main/issue-service.ts) pattern demonstrates this:

```typescript
// server/src/services/issue-service.ts
export async function getIssue(db: Db, issueId: string, companyId: string) {
  return db
    .select()
    .from(issues)
    .where(eq(issues.id, issueId))
    .and(eq(issues.company_id, companyId)); // hard boundary
}

```

This pattern repeats across all CRUD operations. There is no "global" query path that omits the company filter—every service function requires the parameter explicitly.

## Storage-Layer Isolation: Object Key Prefixing

File storage enforces company boundaries through key naming conventions. The `ensureCompanyPrefix` function in [`server/src/storage/types.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/storage/types.ts) validates that object keys begin with the correct `companyId/` prefix:

```typescript
// server/src/storage/types.ts
function ensureCompanyPrefix(companyId: string, objectKey: string) {
  const expectedPrefix = `${companyId}/`;
  if (!objectKey.startsWith(expectedPrefix)) {
    throw unprocessable(`objectKey must start with ${expectedPrefix}`);
  }
}

```

Attempting to access an object with a mismatched prefix fails with an `unprocessable` error. This prevents cross-company asset leakage even if higher layers were compromised.

## Authorization Matrix: Who Can Access What

Paperclip's permission model applies **after** company scoping, as documented in §9.2 and §9.3 of the spec:

- **Board**: Can read/write any record across all companies. All actions are logged to `activity_log` with the `company_id` preserved for audit trails.
- **Agent**: May only act on resources where `company_id` matches the agent's key-bound company. Even default-open permissions like `tasks:assign` are evaluated within this constraint.

The company check precedes permission evaluation—an agent with broad permissions cannot escape its company boundary.

## Defense in Depth: Multi-Layer Enforcement

| Layer | Enforcement Mechanism |
|-------|----------------------|
| Database schema | `company_id` foreign keys on all tables; FK constraints |
| Authentication | `companyId` embedded in JWT for agents; session list for board |
| Middleware | `req.companyId` resolution from URL or auth context |
| Services | Explicit `where company_id = :companyId` in every query |
| Storage | Object key prefix validation (`ensureCompanyPrefix`) |
| Authorization | Permission matrix evaluated within company scope |

No single layer constitutes the entire security model. Each layer provides redundant validation, ensuring that a bug or misconfiguration in one component cannot breach company isolation.

## Summary

- **Company-scoped data isolation** in Paperclip relies on a mandatory `company_id` foreign key present in every business table
- **Multi-tenancy enforcement** operates through four layers: schema design, authentication context, request middleware, and service-layer query filtering
- **Agents** are bound to a single company via their API key's `company_id`, embedded in JWT claims by [`server/src/agent-auth-jwt.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/agent-auth-jwt.ts)
- **Board users** operate across all companies but still respect the schema boundary for audit and organizational purposes
- **Storage isolation** uses `companyId/` key prefixes validated by `ensureCompanyPrefix` in [`server/src/storage/types.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/storage/types.ts)
- **Authorization** never overrides the company boundary—permissions are evaluated only within the scoped context

## Frequently Asked Questions

### How does Paperclip prevent one company from accessing another's data?

Every database query includes a `where company_id = :companyId` clause populated from the authenticated request context. This pattern is enforced in all services, with no exceptions for "admin" queries. The `company_id` is extracted from agent API keys during JWT validation or from the session for board users, then propagated through middleware to every database call.

### What happens if a request tries to access storage with the wrong company prefix?

The `ensureCompanyPrefix` function in [`server/src/storage/types.ts`](https://github.com/paperclipai/paperclip/blob/main/server/src/storage/types.ts) throws an `unprocessable` error before any storage operation executes. This prevents cross-company file access even if the application layer were misconfigured to request the wrong object key.

### Can a single agent work across multiple companies?

No. Each `agent_api_keys` record is bound to exactly one `company_id` at creation time. This value is embedded in the JWT payload and cannot be modified by the agent. A board member can act across companies, but agents are strictly single-company scoped.

### Does Paperclip use separate databases or schemas per company?

No. Paperclip uses **single-tenant deployment with a multi-company data model**—one PostgreSQL database with `company_id` columns partitioning the data logically. This differs from schema-per-tenant or database-per-tenant architectures, reducing operational complexity while maintaining isolation through application-layer enforcement.