Files
paperclip/server
Jannes Stubbemann 70357b961f feat(security): per-company JWT signing keys for multi-tenant isolation (#5864)
## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies
> - Agents authenticate to the server with a JWT signed by the
deployment's master secret
> - In a multi-tenant deployment, all agents from every tenant are
signed with the *same* key, so a leak (CI/staging dump, hostile
contractor with infra access, supply-chain) lets the attacker mint
tokens for *any* tenant
> - The same master secret also issued tokens with a 48-hour TTL, giving
any leaked token a two-day window of validity even after rotation
> - This pull request derives a per-company signing key via
`HMAC-SHA256(master, "jwt:<companyId>")` and reduces the default TTL to
1h; the verifier tries the per-company key first and falls back to the
master secret only for tokens issued before this change so no agent gets
locked out on deploy
> - The benefit is multi-tenant key isolation (a leak of one company's
derived key cannot forge tokens for another) and a tighter blast-radius
on any leaked token, with zero local-first impact (single-tenant deploys
derive their one company's key the same way and continue to work
unchanged)

## Linked Issues or Issue Description

Refs #5288 — a separate key-hygiene finding in the same module
(`agent-auth-jwt.ts` falls back to `BETTER_AUTH_SECRET` as the JWT
signing secret). Related agent-JWT trust-model concern, but not fixed by
this PR — the master-secret fallback selection is unchanged here.

No existing issue covers this PR's problem directly — described in-PR:

- In a multi-tenant deployment, agents from every tenant get JWTs signed
with the *same* master key, so a single leak (CI/staging dump, hostile
contractor, supply chain) lets the attacker mint tokens for *any*
tenant.
- The same master secret issued tokens with a 48-hour TTL, giving any
leaked token a two-day validity window even after rotation.
- Fix: derive a per-company signing key via `HMAC-SHA256(master,
"jwt:<companyId>")` and reduce the default TTL to 1h, with a
master-secret verification fallback so pre-existing tokens are not
locked out on deploy.

## What Changed

- **`server/src/agent-auth-jwt.ts`**
- New `deriveCompanySigningKey(masterSecret, companyId)` — `HMAC-SHA256`
with domain-separated input (`jwt:<companyId>`) so the master secret can
be safely reused for other HMAC purposes in the future without
cross-protocol risk.
  - `signAgentJwt` always signs with the derived per-company key.
- `verifyAgentJwt` reads `company_id` from the token's (untrusted) claim
payload, looks up the candidate derived key, and verifies. If that fails
AND a master secret is set, it falls back to verifying with the raw
master secret — pre-existing tokens validate until they expire.
Verification still fails if the signature doesn't bind.
- Default TTL: `60 * 60 * 48` → `60 * 60`. Existing
`PAPERCLIP_AGENT_JWT_TTL_SECONDS` override still wins.
- **`PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK`** (optional, default
off) — operators set this ~one TTL after deploying to sunset the
master-secret verification fallback entirely, closing the window in
which a leaked master secret could forge arbitrary-`exp` tokens for any
tenant.
- **`server/src/__tests__/agent-auth-jwt.test.ts`** (6 new cases)
- Per-company isolation via tamper: token for company A fails when
verified for company B.
- Legacy-token verification path: tokens signed with the raw master
secret still verify.
  - Default TTL is 1h.
- Legacy fallback toggle: master-secret tokens accepted when unset,
rejected when enabled, and per-company tokens unaffected either way.

## Verification

- `pnpm --filter @paperclipai/server run typecheck` — clean.
- `npx vitest run agent-auth-jwt` — 11/11 pass (6 new + 5 existing).
- Manual: token signed for company A under per-company key fails when
verified against company B's derived key.

## Risks

- **Backward-compatible verification**, so no agent gets locked out on
deploy — but operators relying on hot-swapping the master secret should
note that pre-existing tokens *will* keep validating against the master
key until their TTL elapses, unless
`PAPERCLIP_AGENT_JWT_DISABLE_LEGACY_FALLBACK=true` is set to end the
fallback window explicitly.
- **TTL reduction is a default, not a hard cap.** Operators who relied
on the 48h window can override via env. If 1h is too aggressive for
upstream taste, happy to gate the change behind an env var.
- **No new required env vars.** Single-tenant local-first deploys derive
one company's key the same way and behave identically to today.
- **Domain-separated HMAC input** (`jwt:<companyId>`) means the master
secret can be safely reused for other future HMAC purposes without
cross-protocol risk.

## Model Used

Claude Opus 4.7 (1M context), extended thinking mode; rebase +
legacy-fallback sunset documentation by Claude Fable 5 (1M context).

## Checklist

- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] Thinking path traces from project context to this change
- [x] Model used specified
- [x] Checked ROADMAP.md — part of the multi-tenant hardening initiative
- [x] Tests run locally and pass (`agent-auth-jwt` 11/11)
- [x] Added per-company-isolation, legacy-fallback, and TTL-default
tests
- [x] No UI changes
- [x] Documented risks above
- [x] Will address all Greptile and reviewer comments before merge

Part of the multi-tenant hardening initiative — see also #3967
(cross-tenant 404 oracle) and #5865 (plugin tables `company_id`).

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-11 18:00:22 -07:00
..