Skip to main content
← Back to Blog
2026-10-09 · 18 min readSalesforceAI Agents

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.

Avnish Yadav
Avnish Yadav
Developer & Automation Builder
Day 11/15 — Security Architecture: OAuth, Permissions & Least Privilege

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.

Five layers of control: the client app, the External Client App, the MCP server choice, the user's permissions, and audit
The model is never the security boundary.

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, httpOnly session 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.

External Client Apps OAuth Usage: Headless Assistant Local and Claude MCP Test, one user each

External Client Apps OAuth Usage after signing out of the capstone: Headless Assistant Local now has User Count 0

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 tools getAccountHealth and createFollowUpTask, 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.
  • hasActivationRequired is false, so the permission set applies without session activation.
  • ApiEnabled is 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.
  • EditTask is 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."

External Client App policies: Admin approved users are pre-authorized, the Headless Assistant User permission set and the refresh token policy

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.

The capstone sign-in card showing Sign-in failed: OAUTH_APP_ACCESS_DENIED for a user without the permission set

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.

  1. Security Best Practices (Salesforce Hosted MCP Servers)
  2. How to Secure Salesforce Hosted MCP Servers — Salesforce Developers blog, June 30, 2026
  3. Create an External Client App (Salesforce Hosted MCP Servers) — production hardening options
  4. Create and Configure the ECA for the MCP Server
  5. Understand AIforce Impact — planned agent registration, September 17, 2026
  6. MCP connector (Claude API) — authorization_token, zero data retention
  7. Security and Governance in Headless Experiences
  8. Metadata API Developer Guide: PermissionSet
Day 11 · Cheat sheet

Everything from today on one page. Tap to zoom, or download it for later.

Download PNG
Day 11 cheat sheet: Security Architecture: OAuth, Permissions & Least PrivilegeOpen PNG
Day 11 cheat sheet: Security Architecture: OAuth, Permissions & Least Privilege
Day 11 cheat sheet: Security Architecture: OAuth, Permissions & Least Privilege
All 15 days in this series
Share
Discussion

Comments

Loading comments...

Add a comment

Comments are reviewed before they appear.