I run a read-only crypto portfolio tracker (last time I wrote about how far back each exchange's API actually lets you go), and I wanted people to ask ChatGPT, Claude or Cursor questions about their own portfolio. That meant an MCP server, and an MCP server means handing a token to a third party whose context window I do not control. This post is about the one design decision that matters most there: making sure that token can never be used as a login to the rest of the app.
The code is a Fastify API. Everything below is simplified from it.
The trap: any Bearer is a session
Our auth hook runs on every /api request. It reads a token from the session cookie, or failing that from Authorization: Bearer, and verifies it:
// simplified from the session path
const token = readAccessToken(req); // cookie first, then Bearer header
const claims = token ? verifyAccessToken(token) : null;
if (!claims) return reply.code(401).send({ error: 'authentication required' });
const row = await users.byId(claims.uid);
if (!row || row.token_version !== claims.ver) {
return reply.code(401).send({ error: 'session expired' });
}
req.user = toSafeUser(row);
req.tenantId = row.active_profile_id;
That is fine for a browser and for the iOS app. The problem is what it implies: a token that passes verifyAccessToken is accepted on every route. So if I had minted the MCP connector's token with the same signer, then handing it to ChatGPT would have handed ChatGPT a full session. "Read-only" would have been a description of what I hoped the client would do, not a property of the credential.
You can scope tokens with claims, but then every route has to remember to check the claim. I did not want a security property that depends on every call site remembering.
A separate HMAC domain
So MCP tokens are signed in their own domain. Same secret, different signed input:
const PREFIX = 'mcp_';
const DOMAIN = 'mcp.v1:';
function mac(body: string) {
return createHmac('sha256', authSecret())
.update(DOMAIN + body) // session tokens sign just `body`
.digest('base64url');
}
export function signMcpToken(claims: McpClaims) {
const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
return `${PREFIX}${body}.${mac(body)}`;
}
The mcp_ prefix is only a marker so we can reject obvious strangers before running any crypto. The real separation is the mcp.v1: string folded into the MAC. A session verifier computes its MAC over body, the MCP verifier over mcp.v1:body, so neither signature can satisfy the other. An MCP token presented to /api/trades is not a weaker session. It is not a session.
I wrote the tests in both directions, because only one of them is scary:
it('an MCP token is NOT accepted by the session verifier', () => {
expect(verifyAccessToken(signMcpToken(CLAIMS))).toBeNull();
});
it('stripping the prefix does not help', () => {
const stripped = signMcpToken(CLAIMS).slice('mcp_'.length);
expect(verifyAccessToken(stripped)).toBeNull();
});
it('a session token with mcp_ bolted on is not an MCP token', () => {
expect(verifyMcpToken(`mcp_${signAccessToken('u1', 3)}`)).toBeNull();
});
The honest tradeoff: one secret signs both domains. I chose domain separation over a second secret because it removes a rotation and storage problem, and the tests pin the property. If you would rather have two secrets, that is a reasonable call too.
The MCP endpoint authenticates differently
/api/mcp is branched out of the normal hook before the session check:
if (isMcpEndpoint(req.url)) {
if (req.method === 'OPTIONS') return; // preflights carry no credentials
await authenticateMcp(req, reply);
return;
}
authenticateMcp does four things, and the order is deliberate:
- Verifies the
mcp_token and its expiry. - Requires the read scope.
- Looks up the grant row on every call and rejects it if it is revoked or expired.
- Checks the user's
token_versionagainst the one in the token.
It never falls back to the session cookie. A browser signed in to the app should not be able to drive the MCP endpoint through ambient authority. The grant means "the user approved this connector", and a cookie carries no such approval. It would also be a CSRF surface on a POST endpoint.
Re-dispatch with a freshly minted session token
Here is the awkward part. The read tools do not query the database themselves. Almost every one calls an existing read endpoint in-process with app.inject(), so the MCP surface inherits tenancy scoping and Pro gating instead of reimplementing them. (The one exception, the portfolio overview, builds its summary directly and applies its Pro gate by hand, which is exactly why I wanted the rest to inherit theirs.) But that injected request runs the real auth hook again, and the hook would reject an MCP token, because it is deliberately not a session.
So the route mints a fresh, short-lived session token for the same user and forwards that:
function redispatchAuth(user, tokenVersion, profileId) {
return {
authorization: `Bearer ${signProfileScopedAccessToken(user.id, tokenVersion, profileId)}`,
};
}
// inside a tool
await app.inject({ method: 'GET', url, headers: injectHeaders(auth) });
The injected request is indistinguishable from the user's own browser, with the same tenant and the same capability gates, and it exists only inside the process for the length of one tool call. The MCP token itself stays useless anywhere but /api/mcp. The portfolio the call targets travels inside the minted token as a signed claim, and the hook re-checks ownership of it rather than trusting the signature alone.
Read-only, as a property of the executor
The server exposes 15 tools: 13 reads and 2 writes that can only propose. The reads are grouped by the question they answer, with a view argument that picks the endpoint, so get_money_flows with view capital_flow returns deposit and withdrawal totals in dollars. Every endpoint-backed view names a path, and every one runs through the same executor, which refuses any path outside an allowlist and hard-codes the method:
const res = await app.inject({ method: 'GET', url, headers: injectHeaders(auth) });
There is no method parameter to widen, and a test checks every view's path against the allowlist, so a new view cannot quietly reach a route that is not on it. The server instructions tell the model plainly that it cannot trade, move funds or read exchange API keys. Annotations are set per tool rather than as a blanket default: readOnlyHint: true on the reads, false on the two writes. A client reads that hint to decide whether to ask the user before calling, so stamping it on a write would switch off the very confirmation the next part relies on.
The two writes, set_target_allocation and propose_alert_change, still cannot write. They need a separate portfolio:write scope that the user approves on the consent screen, and even then the tool only validates, renders a diff, parks the payload and returns a link. The change applies only when the user opens that link in their own browser, under their own session cookie, reads a server-rendered before and after, and clicks Apply. The confirm routes are session-authed and origin-checked, and an MCP token is not a session (that is the whole point of the separate domain), so a connector cannot approve its own proposal. Prompt injection into the AI client can produce a perfectly authentic tool call. It cannot produce my browser session.
What this does not defend against is a user who clicks Apply without reading. It turns an invisible write into a visible one.
Revocation
Three directions, because "disconnect" has to mean disconnect:
- Per connector. Revoking one grant flips its row, and since the row is read on every call, the next request from that app fails. Refresh tokens are stored only as hashes and rotate on use.
-
Account-wide.
token_versionis bumped on "sign out everywhere", and it is checked against both session and MCP tokens. Otherwise logging out of everything would quietly leave an AI client still reading your portfolio. - Account deletion. The grant row cascades.
And it has to be findable. Grants are listed in Settings, each with its client label, scope, last used time and the portfolio it reads, with a revoke button. A grant with no visible off switch is a leak with a consent screen in front of it.
What I would check if you build one
- Do your session and connector credentials verify against each other? Test both directions, and test the stripped-prefix case.
- Is the grant checked per call, or only when the token is minted?
- Does "sign out everywhere" reach connectors?
- Can a connector reach the endpoint that approves its own writes?
- Does the redirect URI match exactly, with no prefix or wildcard matching?
If you want to try the connector, the public setup page is here: whereitgoes.money/connect-ai.
I build WhereItGoes; this article was drafted with AI assistance and checked against the code.
Top comments (0)