Skip to main content

Connected apps and OAuth tokens: the integration attack surface

· 10 min read
Vulkro
Security research

Most Salesforce security reviews start with people: who is an administrator, who has MFA, who logged in from where. The data thefts of 2025 and 2026 mostly did not go through people's logins. They went through OAuth tokens, held by connected apps, acting as users who were never asked again.

The FBI's September 2025 alert explains why that matters: "Authorizing a malicious connected app bypasses many traditional defenses such as MFA, password resets and login monitoring, and because OAuth tokens are issued by Salesforce itself, activity coming from the malicious app can look like it's from a trusted integration." This post is about that surface: what a connected app is allowed to do, which settings decide how far a stolen token reaches, and how to review them.

Two ways a token gets stolen​

It is granted to the attacker. Since late 2024, callers posing as IT support have talked employees into authorising an attacker's app, often a modified Data Loader under another name, on Salesforce's connected app setup page (FBI, Salesforce). The employee types a short code, and the attacker's app holds a token for their account.

It is stolen from someone you trusted. Between 8 and 18 August 2025, OAuth tokens held by the Salesloft Drift integration were used to query customer orgs; Salesloft and Salesforce revoked them on 20 August. Public threat-intelligence reporting shows the attacker reading Accounts, Opportunities, Users and Cases, then searching the results for AWS keys, Snowflake tokens and passwords. Salesforce later disabled Gainsight-published apps (November 2025) and the Klue Battlecards connection (June 2026) after similar activity.

In both cases the org's own controls decided the damage: what the app was allowed to request, from where, for how long, and as whom.

What decides a token's reach​

A connected app (or its successor, the External Client App) is an OAuth contract. Five parts of it matter for security.

Scopes. Full "Allows access to all data accessible by the logged-in user", per the Metadata API reference. RefreshToken (the same as OfflineAccess) lets the app keep working when the user is not there. Together they are a standing, do-anything credential.

Refresh token policy. The default is infinite: the refresh token "is used indefinitely, unless revoked by the user or Salesforce admin". The alternatives expire it after a fixed lifetime or after a period without use, and refresh token rotation issues a new token on every refresh and kills the chain if an old one is replayed.

IP relaxation. ENFORCE is the default and applies the org's IP restrictions. BYPASS runs the app "without org IP restrictions", and ENFORCE_RELAXREFRESH bypasses them whenever a refresh token is used. A stolen token for a relaxed app works from the attacker's network.

Permitted users. With isAdminApproved false (the metadata default), "anyone in the org can authorize the app". Admin pre-approval limits it to users with an assigned profile or permission set.

The running user. A token acts as the user who authorised it, or for the client credentials flow, as a named run-as user that must be API Only. Whatever that user can read, the token can read. An integration user with View All Data makes every other setting secondary.

Here is what a risky app looks like in source:

<!-- connectedApps/Partner_Sync.connectedApp-meta.xml (excerpt) -->
<ConnectedApp xmlns="http://soap.sforce.com/2006/04/metadata">
<contactEmail>integrations@example.com</contactEmail>
<label>Partner Sync</label>
<oauthConfig>
<callbackUrl>https://sync.example.com/oauth/callback</callbackUrl>
<isAdminApproved>false</isAdminApproved>
<scopes>Full</scopes>
<scopes>RefreshToken</scopes>
</oauthConfig>
<oauthPolicy>
<ipRelaxation>BYPASS</ipRelaxation>
<refreshTokenPolicy>infinite</refreshTokenPolicy>
</oauthPolicy>
</ConnectedApp>

Any user can authorise it, its token can do anything that user can, from any network, for as long as nobody notices. The same app, tightened:

<oauthConfig>
<callbackUrl>https://sync.example.com/oauth/callback</callbackUrl>
<isAdminApproved>true</isAdminApproved>
<isPkceRequired>true</isPkceRequired>
<isRefreshTokenRotationEnabled>true</isRefreshTokenRotationEnabled>
<scopes>Api</scopes>
<scopes>RefreshToken</scopes>
</oauthConfig>
<oauthPolicy>
<ipRelaxation>ENFORCE</ipRelaxation>
<refreshTokenPolicy>specific_inactivity:30:DAYS</refreshTokenPolicy>
</oauthPolicy>

What Salesforce changed​

The platform has moved in the same direction. From September 2025, Salesforce restricts uninstalled connected apps: users need the new Approve Uninstalled Connected Apps permission to use one, and uninstalled apps that use the OAuth device flow are blocked even for users who authorised them before. In Spring '26, creating new connected apps was turned off by default in favour of External Client Apps, which Salesforce describes as "the new generation of connected apps".

Neither change reviews what is already there. Existing connected apps keep working, existing tokens stay valid, and anyone who holds Approve Uninstalled Connected Apps or the broader Use Any API Client is outside the new restriction.

A practical review checklist​

Work through it per org, production first.

  1. Inventory every app with a live token. Start from the Connected Apps OAuth Usage page in Setup. For each app, find out who authorised it, when its tokens were last used, and which user it acts as. Revoke tokens nobody has used in months.
  2. Shrink the list of people who can add apps. Approve Uninstalled Connected Apps and Use Any API Client belong to a handful of administrators. Public hardening guidance recommends API Access Control, which limits API access to an allowlist of approved connected apps.
  3. Take API Enabled off profiles. Grant it through a permission set to the few people and integrations who need the API.
  4. Replace Full with the scopes the integration uses. Ask for RefreshToken only where the integration genuinely runs unattended.
  5. Enforce IP restrictions on every integration app, add login IP ranges to integration user profiles, and enforce login IP ranges on every request, not only at login.
  6. Expire refresh tokens. Set a lifetime or an inactivity limit, turn on rotation, require PKCE, and leave the device flow off unless a device needs it.
  7. One integration, one user, least privilege. A dedicated API Only user per integration, no View All Data or Modify All Data, no interactive logins, and nothing it does not read.
  8. Get secrets out of records. The Drift attacker searched Cases for keys. Search your own free-text fields for access keys, tokens and passwords, and rotate what you find.
  9. Watch the API. Login history, API and bulk API events, permission set changes and the Setup Audit Trail show a new client, a token used from a second address, or an export far larger than usual.
  10. When you remove an app, revoke its tokens and sessions too. Removing the app alone can leave access behind.
  11. Plan the move to External Client Apps, and review each one against the same list as you migrate.

What Vulkro checks​

Vulkro for Salesforce covers this surface in three places. The ids are real checks from vulkro-sf checks (vulkro-sf 0.22.1).

In the repository, with vulkro-sf scan. Connected app and External Client App metadata is read like code: the Full scope (connectedapp-full-oauth-scope), Full combined with a refresh token (connectedapp-full-plus-offline-token), the full scope with no IP constraint and no admin approval (sf-oauth-scope-takeover-composite), weak refresh token policy (sf-eca-refresh-token-infinite), IP relaxation (sf-eca-ip-relaxation-bypass), no PKCE (sf-connapp-pkce-absent), the client credentials flow running as an administrator (sf-oauth-client-credentials-admin-run-as), a consumer secret committed to metadata (connectedapp-hardcoded-consumer-secret), and integrations still on the retired username-password flow (SF-READINESS-012). The VS Code extension shows the same findings on the line as you edit the file.

In the live org (Pro). vulkro-sf org oauth-risk reads every connected app, External Client App and app seen through its tokens, through your own sf CLI login, and never changes anything. It reports apps whose tokens are held by administrators (SF-OAUTH-APP-001), broad scopes without IP restriction (SF-OAUTH-APP-002), self-authorisation (SF-OAUTH-APP-003), refresh tokens that never expire or rotate (SF-OAUTH-APP-004), the device flow (SF-OAUTH-APP-005), dormant tokens (SF-OAUTH-TOKEN-001), who holds Use Any API Client and Approve Uninstalled Connected Apps (SF-OAUTH-PERM-001, SF-OAUTH-PERM-002), integration users with org-wide data access (SF-OAUTH-INTEG-001), and a revoke list as recommendations. It also matches login history and app names against the published indicators of the 2025 campaigns (SF-OAUTH-IOC-001 to SF-OAUTH-IOC-003); a match is a reason to investigate, not proof of a breach. vulkro-sf org integration-users covers integration and service accounts on admin profiles, used interactively, exempt from MFA with no IP lock, or able to log in from anywhere (SF-INTEG-USER-001, SF-INTEG-USER-002, SF-SVC-USER-001, SF-SVC-USER-004).

In Vulkro Cloud for Salesforce. Vulkro Cloud, available by invitation, runs the same checks against every org your team connects. Its Identity & Access section shows who and what holds access, and Changes compares each scan with the last, so a relaxed connected app policy or a new integration user shows up as a change at the next scan, not as a finding in next quarter's review.

To review the integrations in your own org, start here.

Sources​