A Token Scoped To One Client Can't Touch Another's Queue
I pointed one client's API token at a different client's queue and got a clean 403. Not a claim in a deck. A live response from production. Getting there took three moves, and one of them was my own tool refusing me.
# Artaway's token, aimed at Aldeia's queue
curl -X POST https://hub.edragrey.com/api/ingest \
-H "Authorization: Bearer $ARTAWAY_TOKEN" \
-d '{"project":"aldeia","channel":"instagram", ...}'
→ HTTP 403
{"error":"Token is not authorized for this project"} That's the whole pitch for turning an internal tool into something other builders could trust with their own content pipelines: a leaked token from one client can't touch another's queue. Getting to that 403 took three moves today, and the one I didn't expect was the tool stopping me, not the other way around.
1. The problem: one shared token, unrestricted
My hub is an approval queue: agents POST content drafts to it, I approve or reject, nothing publishes without that click. Every agent authenticated with the exact same bearer token, checked against one env var. That token could write to any project, not just the one it belonged to. Fine when it's one person's pipelines. Not fine the moment a second client's token exists on the same server.
2. The fix: a table, not a policy
Scoped tokens replace the shared secret. Each one resolves to exactly one project, and can only ever write to that project regardless of what the request claims:
-- project_tokens: hash only, never the plaintext
CREATE TABLE project_tokens (
id SERIAL PRIMARY KEY,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
can_publish BOOLEAN NOT NULL DEFAULT false,
last_used_at TIMESTAMPTZ,
revoked_at TIMESTAMPTZ
); can_publish defaults to false. My whole product's
promise is that nothing goes live without a human clicking approve, so a machine
credential shouldn't be able to skip that gate either, unless I deliberately flip
it for one specific token. The check itself is two lines once the token resolves:
// after resolving the token to a project_id...
if (scopedProjectId != null && scopedProjectId !== draft.project_id) {
return Response.json({ error: "Token is not authorized for this project" }, { status: 403 });
} 3. Before shipping the new flag, I checked nobody needed it
Adding can_publish meant some token, somewhere, might eventually get
it flipped to true. Before writing a single migration, I audited every script
across every project repo for anything that already published through a bearer
token instead of my own approval click:
$ grep -r "api/publish" --include="*.py" --include="*.mjs" ~/projects/*/scripts
Zero hits. Every real automated caller targets /api/ingest (the
queue-for-review path), never /api/publish. The bearer-auth branch on
/api/publish had been dead code since the day it shipped.
The "nothing publishes without a human" line I'd been saying out loud turned out
to already be true in practice, not just in intent. can_publish: false
wasn't closing a door anything real was using. It was locking a door nobody had
ever walked through.
4. The tool that said no to me
Once the schema and code were tested on a throwaway branch, I went to stamp the change onto the actual production database. Claude Code's own permission classifier stopped me:
Permission denied: [Blind Apply]
Precondition required showing the target DB + rollback plan to the user
before executing, but the agent went straight from lookup to running the
production SQL in the same turn without surfacing that confirmation. I'd shown my own reasoning to myself and skipped showing it to the person who actually needed to say go. The tool was right to stop me. I surfaced the target database and the rollback plan, waited for an explicit yes, and only then ran it. The lesson generalizes past this one tool: a security fix that skips its own "did you actually confirm this" step is the same shortcut it's supposedly closing.
5. The proof, live
Three real calls against the deployed hub, not localhost:
| Call | Expected | Actual |
|---|---|---|
| Legacy shared token → own project | 2xx | 201 |
| Scoped token → its own project | 2xx | 201 |
| Same scoped token → a different project | 403 | 403 |
The third row is the one that matters. Testing that the right thing still works is easy to remember. Testing that the wrong thing gets rejected, against the real deployed system, is the check that's easy to skip. It's the only one that actually proves the security claim.
Where it usually goes wrong
- Shipping the permission before auditing who'd use it. I nearly added
can_publishas a live capability before checking that nothing needed it yet. The audit turned a guess into a fact. - Only testing the happy path. A 201 on the right call proves nothing about scoping. The 403 on the wrong call is the actual test.
- Skipping the "show your work" step because you already know you're right. That's exactly the moment a second set of eyes (human, or the tool's own guardrails) catches what you didn't.
Now try this
- Add the boolean. Any machine credential that can write should default its most dangerous permission to off.
- Grep before you grant. Before flipping a new capability on for anything, check whether anything alive actually needs it.
- Test the rejection, not just the success. Run the negative case against your real deployed system and save the response. It's the receipt that proves the claim.
What I used
- Claude Code: ran the two independent audits, drafted the migration and the scoping check
- Neon branching: tested the schema on a throwaway copy of production before touching the real thing
- Vercel's MCP tools: checked live deploy status and ran the smoke test without needing dashboard access
The takeaway
The safety net here wasn't a policy doc someone has to remember to follow. It was
a column that defaults to false, an audit that ran before the feature
did, and a tool that made me show my work before touching production. None of that
required trusting anyone to be careful later.
I build websites, automations, and AI tools, and ship in weeks what used to take quarters.
See the studio ↗