Part 1 of the MCP series.
- OAuth 2.1 authorization server
- DCR + PKCE
- one grant per site
- same rules for people and agents
The problem
A managed hosting platform for online stores wanted AI tools to work on a customer's store. Some of those tools were outside the product: Claude, Cursor, any MCP client a customer chose to connect. Others were inside it: the product's own AI features, running a turn on someone's behalf.
The first version of the in-product side gave each kind of agent a fixed set of permissions. Every user got the same set, whoever they were. So a collaborator with read-only access could start an agent that was allowed to change things they could not change themselves. Nothing was wrong with the agent. The model was wrong: permissions belonged to the agent type, not to the person.
I wanted one rule for everything: an agent can never do more than the person who connected it.
One issuer for everyone
I built an OAuth 2.1 authorization server inside the dashboard, on node-oidc-provider. It is the only thing that issues tokens for MCP, for both external clients and the product's own AI.
External clients go through the standard flow. They register themselves with Dynamic Client Registration (DCR), then log in with the authorization code flow and PKCE. They are public clients with no secret, so PKCE is what stops a stolen code from being used.
In-product AI does not go through a browser, so the dashboard mints its tokens directly. They are signed with the same key, by the same issuer, for the same audience. Downstream, an in-product token and a token from Claude look the same and are checked the same way. There is no second path to secure.
What goes in a token
A token carries who the user is, which site it is for, and what they may do there. The permissions are the important part:
token permissions = (the user's live permissions on that site)
∩ (the ceiling for this agent type)
any failure (no user, no site, lookup error) → empty listThe ceiling is what the fixed set used to be. It still matters, since an analytics agent should never write code even for an owner. But now it is an upper bound, not a grant.
The failure rule matters as much as the formula. If the lookup fails, the token gets no permissions, never the ceiling. So a bug in the lookup can only make an agent weaker.
Consent: one grant, one site
The first consent screen offered "all my sites". I changed it so that every grant covers exactly one site: a single choice on the consent page, one grant record per site. Connecting a client to three stores means three explicit approvals. It costs the user a few clicks and makes every grant easy to understand and easy to revoke.
Revocation is live. Every external request checks the grant's status, cached for a short moment, so revoking a client takes effect within about a minute instead of at the next token refresh.
One token, two regions
The platform runs in two regions, and a token has one audience. When the second region's edge advertised its own resource URL, tokens from the shared issuer were rejected there for an audience mismatch. The fix was to make the issuer accept a resource only on an exact match against a known list, one per region, and to have each edge accept the audiences it is meant to serve. Store-specific query parameters are stripped before the resource is checked, so the audience stays canonical.
The DPoP lesson
I built the resource server by the book, with DPoP: each request carries a proof that the caller holds a private key, so a stolen token alone is useless. It had a proof verifier, a replay store and a revocation list.
Then I connected a real MCP client. Every login failed at the token endpoint, because the client did not send DPoP proofs, and other clients had the same gap. I removed DPoP and leaned on strict audiences, one grant per site and live revocation instead.
Other surprises were similar, like metadata served at the wrong well-known URL. Each was found by running a real client and reading what it sent.
What I'd do differently
- Start from "agent = person ∩ ceiling". I added the intersection after a fixed set had shipped. Starting from it would have avoided a migration for existing users.
- Pick live revocation over short lifetimes sooner. I first tried shorter token lifetimes, and clients had to log in again far too often. A live grant check gave the same safety without the friction. I'd have gone there first.
- Run real clients in CI. Most of the spec surprises would have been one failing test.
Takeaway
Don't give agents their own permission model. Give them the person's permissions, capped by what that kind of agent should ever do, and make every failure an empty list. Then "what can this agent do?" has the same answer as "what can this person do?", minus whatever the ceiling takes away.