← Tutorials
BUILD · SECURITY · 6 MIN

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.

For
anyone whose agents write to a shared backend across multiple projects/clients
Needs
a Postgres DB and a per-request auth check you can extend
Time
6 minutes
# 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:

CallExpectedActual
Legacy shared token → own project2xx201
Scoped token → its own project2xx201
Same scoped token → a different project403403

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

Now try this

What I used

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.

BUILT BY EDRA

I build websites, automations, and AI tools, and ship in weeks what used to take quarters.

See the studio ↗