Day 11/15 — Security Architecture: OAuth, Permissions & Least Privilege
An AI assistant on Salesforce acts with a real user's power. Here is the threat model, the layers of control from External Client App to audit log, where the OAuth tokens live and who sees them, and a least-privilege permission set plus an External Client App review checklist you can apply today.

Neha's pilot works. She asks Claude about Acme Global Tech, approves a follow-up Task, and gets on with her day. Now the sales director wants it for the whole team, and before the security team signs off they send Arjun five questions. Who is the assistant when it calls Salesforce? What can it touch? Where does the OAuth token live, and who else sees it? What stops a malicious record from steering it? And how would we know if something went wrong?
None of them is answered by "we use OAuth." The answers come from several layers, and from knowing which layer is missing today.
By the end of today you will have a threat model for your AI assistant, a least-privilege permission set you understand line by line, and an External Client App review checklist you can run against any app in your org.
On Day 10 we built a repeatable workflow, the read-only meeting brief, that calls Claude's API from a Next.js route with the signed-in user's token. Today opens the Architect stage of the series, and it starts where every production conversation about Salesforce Headless 360 (now called AIforce) starts: security.
A threat model for AI clients
Start with the facts that shape everything else. With Salesforce's hosted Model Context Protocol (MCP) servers, "Every MCP tool call runs with the same permissions as the user who authorized the connection": the user's object permissions, field-level security and sharing rules, with "All actions ... attributed to the named user in audit trails." The server side is predictable. Salesforce's FAQ says the processing "is completely deterministic" and "The LLM is entirely on the client side." So the reasoning, and most of the risk, lives in the client: Claude, your app, and whatever text the model reads.
That gives a simple model. The asset is your org's data and configuration. The actor is a language model holding a real user's permissions. The threats are the ways that combination goes wrong:
| Threat | How it happens | What contains it |
|---|---|---|
| Prompt injection through data | A case description or email body contains text written to look like an instruction, and the model follows it | Write tools off until a person approves (Day 9), read-only servers for read jobs |
| Over-permissioned users | The assistant runs as someone with far more access than the task needs | Small permissions for the people who use the assistant; Permitted Users on the External Client App |
| Over-broad tools | A client that only answers questions is connected to a server that can delete | Server choice and per-tool settings (Day 7) |
| Token leakage | An access or refresh token ends up in a browser, a log file or a third party you didn't plan for | Tokens kept server-side, short-lived, revocable |
| Untrusted tool metadata | A client trusts annotations or descriptions it shouldn't | The MCP specification: annotations "should be considered untrusted, unless obtained from a trusted server" |
| Invisible activity | Nobody can tell which changes came from the assistant | API logs, OAuth Usage and your app's own log |
One common assumption needs correcting: that the Einstein Trust Layer sits in front of these calls. Salesforce documents it for tools that run a prompt template or an Agentforce agent, not for soqlQuery and friends. Your client carries that responsibility.
Layers of control
A Salesforce Help page states the principle memorably: headless architecture means "open access, not open bypass." In practice that becomes five layers, and each one assumes the layer above it can fail.

1. The client app. Where the model runs and confirmation happens. Keep tokens on the server, keep write tools off until a person approves one (Day 9), and log every tool call. Salesforce's least-privilege list includes "Require human approval for high-impact actions."
2. The External Client App. This decides who may connect and how. Scopes are exactly Access Salesforce hosted MCP servers (mcp_api) and Perform requests at any time (refresh_token, offline_access). Salesforce created mcp_api "to avoid exposing the "Manage user data via APIs (api)" scope that grants full access to the Platform APIs (REST, Tooling, Metadata, etc.)." PKCE is "mandatory for all OAuth flows." Access tokens are JWTs. Permitted Users set to admin-approved plus a permission set limits which users can connect at all.
3. Server choice. Hosted servers are "disabled by default and require explicit administrative action to enable." Activate only the servers some client needs, and prefer sobject-reads or a custom server with exactly the tools a job requires. Be clear about the limit: "You cannot restrict access to a specific MCP server through the ECA configuration, but you control access to the tools that compose those servers."
4. User permissions and sharing. Everything the assistant does is bounded by what the user may do: "If the user can't do it in the Lightning UI or via the REST API, they can't do it via MCP." This is the layer no prompt can talk its way past.
5. Audit. Salesforce records hosted MCP traffic in its API logs, attributed to the user, and lets you revoke tokens per app. Records carry the same attribution: observed on 2026-10-04 in a Developer Edition org, a Task created through the capstone app showed Created By as the signed-in user, not an integration account. Your app adds what Salesforce can't see: "Salesforce monitoring stops at the Salesforce boundary, but headless requests often cross multiple systems."
Notice what isn't on the list. Today there is no layer that gives the assistant less power than the person it works for. The layer between "the user" and "the agent" is the one Salesforce has announced next, and we come back to it below.
Tokens: where they live and who sees them
An OAuth access token is the assistant's power in portable form. Three questions decide how exposed it is: where it is stored, how long it lives, and who else receives it.
In Claude's own apps. For claude.ai and Claude Desktop, you register the callback https://claude.ai/api/mcp/auth_callback, so the sign-in completes at Anthropic, not on your machine. Claude Code is different: it "runs its own OAuth flow on the user's machine ... and redirects to a loopback callback URL."
In your own app with the Claude API. The MCP connector needs the token in the request. Each mcp_servers entry takes an authorization_token, "OAuth authorization token if required by the MCP server," and Anthropic presents it to the Salesforce server on your behalf. So the user's Salesforce access token is sent to Anthropic with every request. "API consumers are expected to handle the OAuth flow and obtain the access token prior to making the API call, and to refresh the token as needed." And the connector's documentation lists zero data retention as "not-eligible."
That is a reason to design for it, not to avoid it. The capstone's README describes four measures:
- Tokens stay server-side, in an encrypted,
httpOnlysession cookie: "The browser keeps the chat transcript, never a token." - The access token is short-lived: it is refreshed when close to its JWT expiry or older than
SF_TOKEN_MAX_AGE_SECONDS(15 minutes by default). - The refresh token never leaves the server. Only the access token goes to Anthropic.
- Sign-out revokes the token at Salesforce.
The org test confirmed the last two. After 16 idle minutes, the trace began with "Salesforce access token refreshed before this request." And after Sign out, the app's User Count on the OAuth Usage page (below) dropped to 0. The refresh rule is three lines in web/lib/salesforce-oauth.ts:
export function needsRefresh(session: SessionData, config: AppConfig, now = Date.now()): boolean {
const { issuedAt, expiresAt } = session.tokens;
if (expiresAt !== undefined && expiresAt - EXPIRY_SKEW_MS <= now) return true;
return now - issuedAt >= config.salesforce.tokenMaxAgeSeconds * 1000;
}
Refresh tokens deserve a policy, not a default. Salesforce's sources disagree on the default lifetime: the developer docs say refresh tokens "remain valid indefinitely," while the June 2026 security blog says "The default token lasts for a year." Don't rely on either. Salesforce's own hardening guidance is "Refresh Token Validity: 30 days or less" with "Refresh Token Rotation." In the External Client App's OAuth policies, the heading "Refresh Token Policy (Idle Expiration Time Limit Enforced)" offered three options when we tested on 2026-10-03: "Immediately expire refresh token", "Expire refresh token after specific time" and "Expire refresh token if not used for specific time". There was no "valid until revoked" option. Choose Expire refresh token after specific time and set 30 days or less.
Revocation is your emergency brake. Salesforce's advice is to "search for OAuth Usage", but Quick Find shows two pages, and only one lists External Client Apps. External Client Apps → OAuth Usage has the columns Name, Description, User Count, Type, OAuth Status and App Status, and says "To revoke access to an external client app, click on the user count." The older Connected Apps OAuth Usage page doesn't list External Client Apps at all. User Count counts users, not tokens.


Revocation works best with "a dedicated ECA per MCP client (one for Claude, one for ChatGPT, one for Cursor, etc.)." Revoking a Cursor experiment shouldn't sign Neha out of Claude. There is a second reason: Salesforce allows 5 approvals per user per app, and approving a sixth revokes the oldest (Day 5 covers it). Several clients sharing one app can quietly disconnect each other.
Integration user or per-user access?
Many Salesforce integrations run as an integration user: one account, shared by a system. Hosted MCP doesn't allow it: "There are no service accounts, no machine-to-machine flows, and no autonomous operation outside of user context." The June 2026 security blog calls a shared integration user "an anti-pattern that customers should avoid," and adds: "At this time, there's no plan to allow machine-to-machine flows."
In September a Salesforce knowledge article announced a change: agent registration, "Targeting November." "Registration carves out a discrete identity for each agent instead of letting it operate under the identity of the person it assists. Additionally, admins can grant a narrower, reduced permission set to the agentic identity." Clients will present new OAuth credentials for the registered agent. No Setup path has been published yet, so treat the details as subject to change.
| Model | Available for hosted MCP? | Who the audit trail names | Use it for |
|---|---|---|---|
| Per-user OAuth (each person signs in) | Yes, and the only option today | The person | Every assistant in this series |
| Shared integration user | No: "an anti-pattern that customers should avoid" | One shared account | Nothing on hosted MCP |
| Machine-to-machine (no person) | No: "no plan to allow" (June 2026) | Not applicable | Nothing on hosted MCP; design jobs around a signed-in person |
| Registered agent identity | Announced, "Targeting November" (September 2026) | A discrete agent identity | Plan for it: fewer permissions than the person, new OAuth credentials |
The practical consequence today is uncomfortable. With per-user OAuth, the assistant has everything the signed-in user has. A permission set you create "for the assistant" cannot subtract from that. It can add what the assistant's tools need, and it can decide who may connect. Narrowing the agent below the user is the gap that agent registration is meant to fill. Until then, the tool layer (server choice, per-tool settings, approvals) is how you keep the assistant's reach smaller than the user's.
Hands-on: a least-privilege permission set for the assistant
The capstone ships one permission set, Headless_Assistant_User. It does two jobs. The External Client App admits only users who have it, and it grants the few things the assistant's custom tools need. Here it is in three parts, with each element explained using the Metadata API's own definitions.
Part 1: code, automation and user permissions
<classAccesses>
<apexClass>AccountHealthTool</apexClass>
<enabled>true</enabled>
</classAccesses>
<classAccesses>
<apexClass>CreateFollowUpTaskTool</apexClass>
<enabled>true</enabled>
</classAccesses>
<flowAccesses>
<enabled>true</enabled>
<flow>Create_Follow_Up_Task_Flow</flow>
</flowAccesses>
<hasActivationRequired>false</hasActivationRequired>
<userPermissions>
<enabled>true</enabled>
<name>ApiEnabled</name>
</userPermissions>
<userPermissions>
<enabled>true</enabled>
<name>EditTask</name>
</userPermissions>
classAccesses"Indicates which top-level Apex classes have methods that users assigned to this permission set can execute." These are the two invocable classes behind the custom MCP toolsgetAccountHealthandcreateFollowUpTask, which we build on Day 12. Salesforce says Apex actions exposed as MCP tools "run as the authenticated user," so the user needs access to them.flowAccesses"Indicates which flows can be accessed by a user assigned to this permission set": the declarative version of the follow-up tool.hasActivationRequiredisfalse, so the permission set applies without session activation.ApiEnabledis the "API Enabled" permission. Salesforce's object reference says "A user attempting to access the API must have the permission "API Enabled" selected. It's selected by default." No hosted MCP page names a user permission MCP requires, so treat this line as a sensible precaution rather than a documented requirement.EditTaskis there for "the Edit Tasks permission needed to create follow-up tasks": the capstone's assumption, not a documented requirement.
Part 2: object access without bypassing sharing
<objectPermissions>
<allowCreate>false</allowCreate>
<allowDelete>false</allowDelete>
<allowEdit>false</allowEdit>
<allowRead>true</allowRead>
<modifyAllRecords>false</modifyAllRecords>
<object>Account</object>
<viewAllRecords>false</viewAllRecords>
</objectPermissions>
The same block appears for Case and Opportunity. allowRead is the only permission granted: the assistant's tools summarize these objects and never change them. The two false values at the end matter most. viewAllRecords would let users see all records of the object "regardless of the sharing settings for the object," and modifyAllRecords would let them view, edit or delete them the same way. Leaving both off means record visibility still follows your org's sharing model, which is what the permission set's description says: "Record access still follows the org sharing model."
Part 3: field access
Four fieldPermissions entries grant read access, and no edit access, to fields the health tool reads:
| Field | Readable | Editable | Why |
|---|---|---|---|
Account.Industry |
true | false | Part of the account summary |
Case.Priority |
true | false | Open high-priority cases lower the health score |
Opportunity.Amount |
true | false | Pipeline value |
Opportunity.Probability |
true | false | Weighted pipeline |
Hosted MCP "respects field-level security restrictions," so a field that no permission grants the user is invisible to the assistant as well.
Deploy, assign and gate the External Client App
From the capstone's salesforce/ folder, deploy the classes, the Flow and the permission set together (they reference each other), then assign it:
sf project deploy start \
--source-dir force-app/main/default/classes \
--source-dir force-app/main/default/flows \
--source-dir force-app/main/default/permissionsets \
--test-level RunSpecifiedTests \
--tests AccountHealthToolTest --tests CreateFollowUpTaskToolTest \
--target-org headless360
sf org assign permset --name Headless_Assistant_User --target-org headless360
Then make the permission set the gate. Open the External Client App, go to Policies → Edit → OAuth Policies, and set Permitted Users. It offers two options, "All users can self-authorize" and "Admin approved users are pre-authorized"; choose the second, then pick Headless Assistant User under Select Permission Sets. Salesforce's Help is blunt about the alternative: "Don't select a self-authorization option: it skips the permission set gate," and "Without one [a permission set], every user in the org gets access to the ECA."

To prove the gate works, sign in as a user without the permission set and try to connect. Observed on 2026-10-04, a Standard User without it was sent back to the capstone app with an error, and the sign-in card showed "Sign-in failed: OAUTH_APP_ACCESS_DENIED". No token was issued.

Hands-on: review your External Client App
Run this review against every External Client App that an MCP client uses, and again whenever someone edits one.
| # | Check | Setting to look for | Why |
|---|---|---|---|
| 1 | Scopes | Exactly "Access Salesforce hosted MCP servers (mcp_api)" and "Perform requests at any time (refresh_token, offline_access)" | api "grants full access to the Platform APIs"; the beta scopes don't work with GA servers anyway |
| 2 | PKCE | Require Proof Key for Code Exchange (PKCE) extension for Supported Authorization Flows checked | "PKCE is mandatory for all OAuth flows" |
| 3 | JWT tokens | Issue JSON Web Token (JWT)-based access tokens for named users checked | Hosted servers require JWT access tokens |
| 4 | Callbacks | Only the callback URLs this client uses, exactly | "the callback (redirect) URL in your client exactly matches"; remove test callbacks such as Inspector's when done |
| 5 | Permitted Users | Admin approved users are pre-authorized, plus a permission set | Otherwise "every user in the org gets access" |
| 6 | One app per client | Separate apps for Claude, your app, Cursor | Revoke and audit one client without touching the others |
| 7 | Refresh tokens | "Expire refresh token after specific time", 30 days or less | Salesforce's hardening guidance; there is no "valid until revoked" option |
| 8 | Client secret | Required for server-side web apps only | Salesforce: "Web-Based Clients Only"; avoid for desktop apps |
| 9 | IP restrictions | Only if the client's IP ranges fit | "Some MCP clients operate from IP ranges that exceed Salesforce's allowlist capacity" |
| 10 | Single logout | Considered for your org's session settings | Listed in Salesforce's production hardening options |
| 11 | Sandboxes | Know how the app reaches each sandbox | "Local external client apps aren't copied to a new sandbox"; packaged ones are |
For the capstone's own app (Headless Assistant Local), the answers are: two scopes, PKCE and JWT on, one callback (http://localhost:3000/api/auth/salesforce/callback, the capstone's default), Permitted Users gated by Headless_Assistant_User, its own app separate from Claude's, and refresh tokens that expire after 7 days without use.
Monitoring: a preview of Day 14
Two places show you what the assistant did. External Client Apps → OAuth Usage shows how many users each app has, with revocation one click away on the user count. For activity, Salesforce says "agent actions appear in standard Salesforce API logs with full user attribution," and you can find them in Setup → Event Log File Browser by filtering Event Type on "API Total Usage" and looking for rows where API_CLIENT_CATEGORY is SALESFORCE_HOSTED_MCP, with columns such as STATUS_CODE, USER_NAME and CLIENT_IP. Day 14 builds the full monitoring and error-handling picture.
What can go wrong
| Symptom | Cause | Fix |
|---|---|---|
| Anyone in the org can connect the assistant | Permitted Users allows self-authorization, or no permission set is selected | Admin-approved users plus a permission set |
A beta-era app still carries api or sfap_api |
The app predates GA, or was copied from an old tutorial | New app with exactly the two scopes above |
| A token turns up in browser storage or logs | Tokens handled in client code, or logged for debugging | Keep tokens in a server-side session; never log them |
| Refresh tokens that never seem to expire | No refresh token policy set | "Expire refresh token after specific time", 30 days or less |
| A user sees "Sign-in failed: OAUTH_APP_ACCESS_DENIED" | They lack the gating permission set | Assign it, if they should have access |
| An app is missing from OAuth Usage | You opened Connected Apps OAuth Usage | Use External Client Apps → OAuth Usage |
| You can't revoke one client without breaking another | One External Client App shared by several clients | One app per client |
| A question about a case ends with a changed record | Injected text in a record and write tools enabled | Read-only server for read jobs; approvals for writes (Day 9) |
| Claude can't connect after you add an IP restriction | The client's IP ranges exceed what you allowed | Remove the restriction or scope it to clients you control |
Production note: what runs as whom
Keep one sentence in every design review: the model runs at the AI provider, the MCP server runs at Salesforce without a model, and every tool call runs as the signed-in user. Protect the user's token, keep the user's permissions small, keep the tools narrow, and put a person in front of every write until Salesforce's agent identities give you a smaller principal to work with.
Today's checklist
- I have a threat model for my assistant with a containing control for each threat.
- I can explain the five layers and which one doesn't exist yet (agent below user).
- I know where my tokens live, that the Claude API connector sends the access token to Anthropic, and that the connector isn't ZDR-eligible.
- My app keeps tokens server-side, refreshes short-lived access tokens, and revokes on sign-out.
- I know there is no integration user or machine-to-machine option for hosted MCP today, and that agent registration is announced.
- I deployed and assigned a least-privilege permission set and made it the External Client App's gate.
- I ran the review checklist against every External Client App an MCP client uses.
Frequently asked questions
Is my Salesforce access token sent to Anthropic?
With the Claude API's MCP connector, yes: you pass it as authorization_token so Claude can call the Salesforce server, and the connector isn't eligible for zero data retention. Keep the token short-lived and the refresh token on your server.
Can an External Client App be limited to one MCP server?
No. Salesforce says "You cannot restrict access to a specific MCP server through the ECA configuration." Control which servers are active, which tools each client uses, and what the users can do.
How long do Salesforce refresh tokens last for hosted MCP?
Salesforce's sources disagree on the default, so set it yourself. The policy offers "Immediately expire refresh token", "Expire refresh token after specific time" and "Expire refresh token if not used for specific time", with no "valid until revoked" option; Salesforce recommends 30 days or less.
What permission does a user need to use hosted MCP?
No hosted MCP page names one. Users need access to the External Client App (through its Permitted Users policy) and the object, field and record access for whatever the tools touch.
What's next
Today we contained the tools Salesforce gives you. Tomorrow we write our own. On Day 12, we turn business logic into custom MCP tools with invocable Apex and an autolaunched Flow: getAccountHealth and createFollowUpTask, with descriptions written for the model, with sharing and user-mode queries, tests that run as a restricted user, and a custom hosted server that publishes them.
Sources
Verified against the sources below on September 30, 2026. Salesforce ships Headless 360 changes often: check the linked docs if a screen looks different.
- Security Best Practices (Salesforce Hosted MCP Servers)
- How to Secure Salesforce Hosted MCP Servers — Salesforce Developers blog, June 30, 2026
- Create an External Client App (Salesforce Hosted MCP Servers) — production hardening options
- Create and Configure the ECA for the MCP Server
- Understand AIforce Impact — planned agent registration, September 17, 2026
- MCP connector (Claude API) — authorization_token, zero data retention
- Security and Governance in Headless Experiences
- Metadata API Developer Guide: PermissionSet
Everything from today on one page. Tap to zoom, or download it for later.
All 15 days in this series
- Day 11Security Architecture: OAuth, Permissions & Least Privilege
- Day 12Custom MCP Tools + Apex/Flow Business Logic
- Day 13Headless 360 + Agentic AI Architecture
- Day 14Production Architecture: Governance, Monitoring & Error Handling




Comments
Loading comments...