Chapter 6 of 8 · 15 min read
Per-user upstream grants
On this page
An incoming MCP token is minted by your authorization server, for your resource identifier, and it is useless outside that boundary. Salesforce's authorization server has never heard of auth.northwind-analytics.com, will not validate a signature from it, and would refuse the token in the unlikely event it ever saw one. There is no configuration that makes a token from your issuer work at a third party's API, which means the credential for that call has to come from somewhere else entirely.
Where it comes from is a second, independent OAuth grant in which your server is the client rather than the resource server: it holds a client id and secret registered in a Salesforce connected app, it runs authorization-code flows, and it stores the resulting access and refresh tokens per user. The two roles share nothing except a user identifier that links a row in your store to a subject in an incoming token.
Four ways to get a credential, and the three that fail
| Approach | Where the Salesforce credential comes from | Acts as | Fails because |
|---|---|---|---|
| Forward the incoming token | The MCP client | Nobody — Salesforce rejects it | Salesforce does not trust your issuer; if it somehow did, this is chapter 5's laundering across an org boundary |
| Token exchange (RFC 8693) | Your authorization server, exchanging the incoming token | The user, at Salesforce | Requires Salesforce's authorization server to accept your issuer as a subject-token source; cross-organisation federation of that kind is rare and has to be arranged, not assumed |
| Client credentials / integration user | One Salesforce service account for everything | A single robot identity | Every user's writes are attributed to one account, Salesforce's own sharing rules stop applying per user, and one compromise reaches every record the service account can see |
| Per-user authorization code grant | Salesforce, granted by each user to your server as an OAuth client | The user, at Salesforce | Nothing — this is the shape |
Those four exhaust the options, and seeing why is more useful than memorising the rows. Salesforce accepts exactly one kind of credential: one its own authorization server issued. The question is therefore not which token to send but how such a credential comes to exist, and there are only two ways for it to exist — minted for a machine, or minted for a person who consented. The machine case is the integration-user row. The person case is either granted directly by that person to your server, which is the fourth row, or obtained by your server exchanging something it already holds, which is the second row and requires Salesforce to accept your issuer as a source of subjects. The first row is the case where no Salesforce credential exists at all and one from elsewhere is offered in its place, which is why it fails at the first hop rather than the last. There is no fifth source, because there is no fifth way to persuade Salesforce's authorization server to issue anything.
Which leaves your MCP server holding both roles at once. To the MCP client it is a resource server, publishing metadata, issuing challenges and validating tokens exactly as chapter 4 described; to Salesforce it is an OAuth client with a registered app of its own. Keeping the two apart in code is what the rest of this chapter is about, because every failure below comes from one role reaching for the other's credential.
That diagram is the whiteboard answer. Grant 1 authorises the client to speak to your server on the user's behalf; grant 2 authorises your server to speak to Salesforce on the same user's behalf. The two tokens have different issuers, different audiences, different scopes and different lifetimes, and no request ever carries one where the other belongs.
Getting the second grant without a browser you control
The awkward part is that the second grant needs a browser and the tool call does not have one. Your MCP server is being driven by an agent over HTTP, and Salesforce is going to want the user to log in and approve a consent screen. The pattern that resolves this is to make the tool fail in a way that carries an actionable link, rather than erroring or hanging.
export async function createTask(ctx: ToolContext, args: TaskArgs) {
const grant = await store.find(ctx.subject, "salesforce");
if (!grant) {
const url = await beginConnect(ctx.subject, "salesforce");
return {
isError: true,
content: [{
type: "text",
text: `Salesforce is not connected for this account. ` +
`Open ${url} to authorise it, then run this tool again.`,
}],
};
}
const token = await store.freshAccessToken(grant);
return callSalesforce(token, args);
}beginConnect mints a one-time, short-lived connect ticket bound to the subject from the validated token, stores it with a five-minute expiry, and returns a URL on your own domain. When the user opens it, your server starts an ordinary authorization-code flow against Salesforce — with PKCE, state, and a redirect_uri registered in the connected app, all behaving exactly as chapter 2 described, because your server is the client here and those controls are yours to run. Salesforce redirects back to your callback with a code, your server exchanges it, and the resulting tokens are written to the store against that subject. The next tool call finds a grant and proceeds.
The lifecycle of that stored grant has more states than "present or absent", and handling each one is what separates an integration that works from one that works for a fortnight:
The transition worth writing code for is the one out of Connected. A refresh that returns 400 invalid_grant means the grant is gone for good — revoked in the Salesforce admin console, or aged out of the refresh token's maximum lifetime — and the correct handling is to delete the stored row and return the same connect prompt the tool returned when there was no grant at all. Retrying the refresh accomplishes nothing, and a background job that retries every minute across a few thousand users will get your connected app rate-limited by Salesforce.
Do not answer an upstream problem with your own 401
The status code your server returns when the Salesforce grant is missing decides whether the client can help. A missing upstream connection is not an authentication failure of the request you received: that token was valid, the caller is who they say they are, and the scopes they hold at your server are fine. Returning 401 with a WWW-Authenticate challenge tells the client that its credential is bad, so a well-behaved client does exactly what chapter 1 prescribed — refreshes, then re-runs the whole authorization flow against your issuer, opening a browser, taking the user through a consent screen they have already completed, and arriving back with a new token that fails in precisely the same way. The user experiences an integration that demands re-authentication in a loop and never mentions Salesforce.
The pull towards 401 is worth naming, because it is not laziness. The tool genuinely cannot proceed for a reason that is about authorization, the HTTP vocabulary has a status meaning exactly that, and returning the semantically precise status is normally the right instinct. What the instinct misses is that a status code on your response is a statement about your credential, since that is the only credential the client presented. The response has no field in which to name a third party, and no client is going to infer one. So the accurate answer for a condition your caller cannot repair is not a more precise authorization status; it is not a status at all, because the condition is not a property of the request. It is a property of the world the tool found when it ran, and a result is where a tool describes the world.
The tool-level error above avoids that by keeping the failure inside the JSON-RPC result, where the agent can read it and act on it. Servers that want something more structured than a sentence can put the link in the result's _meta, which lets a client render a button rather than hoping the model relays a URL. Clients that support elicitation — the MCP feature by which a server pauses a call to ask the client to put a question to the user and return the answer — can be asked directly, mid-call, to have the user complete a connection, which is the better experience where it is available and which still needs the text fallback for clients that do not implement it.
The same reasoning applies to a Salesforce call that fails for its own reasons after the grant exists. Salesforce returning 401 because its token expired mid-call is your problem to fix by refreshing, and Salesforce returning 403 because the user lacks a permission on the object is a fact about the user's Salesforce rights that the agent should be told plainly. Neither should be reflected back as an HTTP status on the MCP response; a client that sees 403 insufficient_scope from your server will try to obtain a scope from your authorization server, which has no ability to grant permissions inside somebody else's CRM. Upstream failures belong in the tool result, described in words, with the provider named.
Ask the provider for less than it offers
The scopes on grant 2 are chosen by you rather than by the user's client, which makes them easy to over-request and easy to forget. The default in most connected-app documentation is full refresh_token offline_access, because it works for every tutorial and never produces a confusing error. It also means a compromise of your token store hands an attacker complete read and write access to every connected user's CRM, including objects no tool in your server has ever touched.
Requesting the narrow set costs a mapping from tools to provider scopes, maintained in the same place as the tool-to-scope table from chapter 4, where each tool declares the scopes it requires, and it changes what a leaked store is worth. A server whose Salesforce tools only read accounts and create tasks needs api refresh_token with an integration-user profile restricted to those objects, and a store built that way cannot be used to export the opportunity pipeline no matter who obtains it.
Incremental authorization is the pattern for growth. When a new tool needs a scope the existing grant does not carry, the tool returns the connect prompt again with a URL that requests the enlarged scope set, and the user re-consents once. Storing the granted scope string in the row is what makes this checkable before the call rather than after a provider error, and it lets a connections.status tool tell the agent which providers are connected and what they can currently do — which is more useful to a model planning a multi-step task than discovering the gap on the third step.
Provider token lifetimes vary enough to matter for the refresh logic. Salesforce access tokens follow the org's session timeout, commonly 2 hours but configurable down to 15 minutes; Google's are a flat 3600 seconds; Microsoft Graph issues between 60 and 90 minutes with deliberate jitter. Refresh tokens are typically valid until revoked, though some providers expire them after a period of disuse — Google after six months of inactivity, Salesforce according to a per-app policy that an administrator sets. Treating expires_in from the response as authoritative and refreshing at 80 percent of it handles all of these without a table of provider-specific constants.
The reason one rule covers three providers is a proportion rather than a coincidence, and it is worth checking against the figures above. Refreshing at eighty percent of expires_in leaves a fifth of the lifetime as headroom, whatever the lifetime happens to be. Google's flat 3600 seconds gives forty-eight minutes of use and twelve minutes of headroom. Microsoft Graph at the short end of its range gives the same twelve. A Salesforce org on a two-hour session gives twenty-four. Because the fraction is fixed, the provider with the shortest lifetime sets the worst case for the whole system, and that is the only figure worth knowing — which for a Salesforce org configured down to fifteen minutes is three minutes. Three minutes is comfortably more than a refresh round trip and comfortably less than a coffee break, which is the honest statement of what the rule buys: it survives clock differences and a slow token endpoint, and it does not survive a process being suspended.
The key is the subject, and only the subject
The store is a table with one row per user per provider, and the column that decides whether the whole design holds is the one it is keyed on.
create table upstream_grants (
mcp_issuer text not null, -- which IdP asserted the subject
mcp_subject text not null, -- sub claim from the validated token
provider text not null, -- 'salesforce'
access_token bytea not null, -- encrypted with a KMS data key
refresh_token bytea not null, -- encrypted
expires_at timestamptz not null,
scope text not null,
connected_at timestamptz not null default now(),
primary key (mcp_issuer, mcp_subject, provider)
);mcp_subject comes from the sub claim of the token your middleware verified, and it must never come from anywhere else. The tempting alternative is a tool argument — create_task(user_id, ...) — which reads naturally, makes testing easier, and hands any authenticated user the ability to act as any other by passing a different id. It is the same class of mistake as trusting the token's own iss to select a verifier: the value that decides whose credential is used has to come from something the caller cannot set. The issuer belongs in the key alongside the subject because sub values are only unique within an issuer, and a multi-tenant server that keys on sub alone will collide the moment two tenants' IdPs both emit 00u12345.
Two operational details are easy to get wrong. Tokens go in encrypted with a key from a KMS — a key management service such as AWS KMS or Google Cloud KMS, which holds the master key and performs the encryption so your application never stores it — rather than in plaintext columns, because this table is now the single most valuable object in your deployment: it holds live, refreshable access to every connected user's Salesforce. And concurrent refreshes need a lock. An agent that fires four tool calls in parallel against an expired token will start four refreshes; with refresh-token rotation, the first succeeds and invalidates the token the other three are using, so three of them get invalid_grant and your code concludes the grant was revoked and deletes it. A row-level advisory lock around refresh, or a single-flight cache keyed on the grant, turns that into one refresh and three waits of a few hundred milliseconds.
The defect in that sequence is not the refresh and not the parallelism; it is that invalid_grant carries two meanings and the code cannot separate them. The authorization server returns it when a refresh token has been revoked, and it returns it when a refresh token has already been rotated away, because from its side those are the same fact — the token presented is no longer the current one. Your handler has to pick an interpretation, and the correct interpretation for a revocation is to delete the row, which is precisely why a correct handler destroys a healthy grant under concurrency. The lock does not make the error less ambiguous. It removes the case in which the ambiguity can arise, by ensuring only one caller ever presents a given refresh token. That is the general shape of the fix whenever an upstream error is overloaded and you cannot widen it: make the second meaning impossible rather than trying to detect it after the fact.
Two consent screens is the correct number
Users will ask why they had to authorise twice, and the honest answer is that they authorised two different things. The first consent said your MCP server may act for them; the second said Salesforce may be accessed by your MCP server on their behalf. Collapsing them would mean one of the two organisations granting access to the other's data without being asked, which is the thing the whole arrangement exists to prevent. The user-visible cost is one extra browser trip per provider per user, and it is a one-time cost per connection rather than per session.
This is not single sign-on, and the distinction gets muddled because both involve a browser and a redirect. SSO answers "who is this person" once and shares that answer across applications. What is happening here is delegation: two independent authorization decisions about what a piece of software may do, which happen to concern the same person. A deployment where your IdP is also the Salesforce IdP does make the second login invisible, since the user is already signed in, and the consent step still occurs.
Nor does the second grant obviate anything from chapter 4, on validating every incoming request before any tool runs. Every tool call still validates the incoming token before the store is touched, because the subject that keys the lookup is only trustworthy if the token that carried it was verified. Skipping validation and reading sub out of an unverified JWT would let anyone name any subject and receive that user's Salesforce data through your server, which is a more direct version of every problem in this topic.
Two grants, then, and the discipline is that neither credential ever does the other's job: the incoming token proves who is asking and is never sent anywhere, and the stored upstream token acts at the provider and never leaves your server. The link between them is a subject claim you verified, used as a database key, which is why the store is keyed on (issuer, subject, provider) and why the value can never come from a tool argument.
One question is left open, and it is about the client registration on the Salesforce side. Your server registered a connected app to get the client id it uses for every one of these grants — one client id, shared across all your users, driving flows on their behalf. That is the ordinary way to integrate with a SaaS, it is what the vendor documentation describes, and under one specific arrangement it is exploitable in a way none of chapter 2's controls detect.
MCP auth — read the current version at https://daviddominguez.dev/learn/en/mcp-auth/per-user-upstream-grants