SfSkills Domains Docs GitHub

Home / admin / connected-app-troubleshooting

connected-app-troubleshooting

Troubleshooting Connected App OAuth flows — IP relaxation vs IP restriction, refresh token policy traps (default kills the connection on first refresh), session-revocation semantics, the OAuth error-code catalog (`invalid_grant`, `invalid_client_id`, `unsupported_grant_type`), per-user vs admin-pre-approved flows, and the user-policy check (Connected App must be assigned to the user via profile / permset).

Open the skill folder on GitHub


Connected App Troubleshooting

OAuth via Connected Apps has a consistent set of failure modes that admins hit repeatedly: refresh tokens silently revoked, users not assigned to the app, IP restrictions blocking the caller, callback URL mismatches, and the error catalog being opaque ("invalid_grant" can mean five different things).

This skill is the diagnostic playbook. It assumes a Connected App exists and an OAuth flow is failing; the input is the error symptom, the output is the next action.

What this skill is NOT. Designing the OAuth architecture (which flow to pick, JWT setup, certificate management) is integration/oauth-flows-and-connected-apps, and authoring the app file itself is admin/connected-apps-and-auth. SAML / SSO debugging is security/sso-saml-troubleshooting. This skill is for the "OAuth-via-Connected-App is failing; what now" moment.


Before Starting


Questions to Ask Before Configuring

Ask these before changing a single Connected App field. A failing OAuth flow has one true cause and four plausible ones; every question below eliminates a branch of the decision table using evidence that already exists in the org.

AskWhy it mattersWhat a good answer adds
"Paste the response body, not your summary of it."The token endpoint's error string and the HTTP status are different signals. REST API documents 401 as "The session ID or OAuth token used has expired or is invalid", and 403 as a refusal to check permissions againstThe row of the runbook table to start on, and whether this is an auth failure or a post-auth authorization failure
"Which grant is failing — the first authorization, or a later refresh?"LoginSubType separates OauthWebServer from OauthRefreshToken, so Salesforce already recorded which half brokeWhether to inspect oauthConfig (initial exchange) or oauthPolicy (renewal) — they are different fields
"Did this ever work, and when did it last succeed?"A working-then-broken integration points at a policy change or a token lifetime, not at configuration; a never-worked one points at pre-authorization or callback URLThe SetupAuditTrail window to search and whether a LoginHistory success row exists to compare against
"Who is the authenticating user, by Id?"UserId is one of only twelve filterable LoginHistory fields, so it is the anchor of every evidence queryA query that compiles, instead of one rejected on WHERE Application or WHERE Status
"Can the caller enumerate its egress IPs, and does anything proxy the request?"ipRelaxation ENFORCE is evaluated against the org's IP restrictions, and SourceIp records the first proxy rather than the originWhether the IP branch is even testable from Salesforce-side evidence (gotcha 12)
"Who will run the verification query, and do they hold Customize Application?"OauthToken shows an under-privileged caller only their own rows, so a clean result can be an artefact of the reviewer's permissionsA named runner for the evidence step, so an empty result means something
"Is there a sandbox with the same policy values, and has My Domain been deployed in it?"The reproduction is only valid if the login host and the policy block match; callback URLs are host-specificA safe place to test the fix before touching the production app

What a proper configuration adds over just doing it: the fix is attached to a written diagnosis record — symptom, verbatim error, the evidence rows that ruled the other causes out, and a root-cause category — so the next person who sees invalid_grant on this app does not restart the same five-branch search from zero.


Core Concepts

The OAuth error catalog

UNVERIFIED (2026-09-04): the token-endpoint error strings below are not documented in this skill's source set — the REST API Developer Guide covers the HTTP layer (401 as an expired or invalid session/OAuth token, 403 as a refusal to check permissions against) but not the OAuth vocabulary. Treat each string as a hint and confirm the diagnosis with the evidence columns in references/metadata-examples.md § 1, which are grounded.

Error codeCommon causeFix
invalid_grantRefresh token revoked / expired / not issued for this clientVerify Refresh Token Policy on the Connected App; re-authorize the user
invalid_client_idWrong Consumer KeyCheck the Consumer Key matches the Connected App in the right org
invalid_client_credentialsWrong Consumer SecretReset / fetch the secret in Setup
unsupported_grant_typeConnected App's "Permitted Users" or "OAuth Policies" don't permit this flowEdit the Connected App; ensure the flow's grant type is enabled
redirect_uri_mismatchCallback URL in app doesn't match the request's redirect_uriUpdate the Connected App's callback URL list
inactive_userUser is deactivatedActivate the user OR use a different user
IP_RESTRICTEDConnected App's IP relaxation = "Enforce IP restrictions" + caller IP not on user's profile login rangeEither: relax to "Relax IP restrictions for activated devices", OR add IP to user's profile range
error=login_required (silent)Session expired but Connected App is configured for SSO bypassUser must re-authenticate; auto-refresh isn't applicable
OAUTH_APP_BLOCKEDSetup → Connected Apps Usage → admin blocked the appUnblock or pick a different app

Refresh Token Policy: the silent-killer setting

On the Connected App, Refresh Token Policy has four values:

ValueBehavior
Refresh token is valid until revokedToken works indefinitely until explicitly revoked.
Immediately expire refresh tokenRefresh token expires after one use.
Expire refresh token if not used for N daysSliding window; inactive refresh tokens expire.
Expire refresh token after N daysHard expiry from issuance.

In metadata the field is refreshTokenPolicy on ConnectedAppOauthPolicy, it is Required, and its documented values are zero, infinite, specific_lifetime:number:HOURS|DAYS|MONTHS and specific_inactivity:number:HOURS|DAYS|MONTHS (Metadata API Developer Guide, ConnectedAppOauthPolicy).

Correction to the row above, grounded: infinite is the documented default — "the refresh token is used indefinitely, unless revoked by the user or Salesforce admin. Default setting." The Setup UI in an older org may have presented a different pre-selected option. UNVERIFIED (2026-09-04): the claim that older orgs defaulted to "Immediately expire" is not in the Metadata API guide, which documents infinite as the default; treat it as a field observation and read the deployed value rather than assuming either default.

zero is the value that produces the classic "works once, then dies" report: "The refresh token is invalid immediately. The user can use the current session (access token) already issued, but can't obtain a new session when the access token expires." The failure therefore surfaces one access-token lifetime after the change that caused it — which is why the SetupAuditTrail search window has to start before the first failure, not at it.

Right answer for server-to-server: an explicit value you chose. infinite only when a revocation runbook exists; specific_inactivity: when the integration has a predictable cadence. The policy-vs-kill-switch distinction and the revoke procedure belong to admin/integration-admin-connected-apps.

IP Relaxation

SettingBehavior
Enforce IP restrictionsApply user's profile IP range; calls from outside fail with IP_RESTRICTED.
Relax IP restrictions for activated devicesFirst use prompts a device-verification email; subsequent calls from the verified device skip IP check.
Relax IP restrictionsSkip IP check for this Connected App.

For server-to-server integrations from cloud infrastructure (AWS, Azure, etc.) where the IP set is large or rotating, "Relax IP restrictions" plus a tightly-scoped integration user is the standard pattern.

User assignment to the Connected App

The user authenticating via the Connected App must be authorized to use it. Two models:

Server-to-server integrations should use admin-pre-approved + a dedicated integration user. The integration user has only the permsets needed; the Connected App is assigned to those permsets specifically.

Login History as the diagnostic source

Setup → Login History (or LoginHistory SOQL):

Status is the fastest discriminator, because it "[d]isplays the status of the attempted login. Status is either success or a reason for failure" — a sentence the Object Reference does not expand into a value list, so read the strings your org actually emits rather than matching against a remembered catalog.

LoginSubType is the field that names the grant: OauthWebServer, OauthRefreshToken, OauthClientCredentials, OauthUserAgent, OauthTokenExchange, OAuthDevice, OauthUsernamePassword and the hybrid variants. It tells you which half of the dance failed before you open a single Setup page.

Neither is filterable. LoginHistory publishes a closed list of twelve filterable fields, and Status, LoginSubType and SourceIp are outside it (gotcha 11). Anchor every evidence query on UserId and LoginTime, then read the rest off the returned rows. The ready-made queries are in references/metadata-examples.md.

Failures often show in Login History with a clearer cause than the client receives. Always check Login History before guessing.


Common Patterns

Pattern A — JWT Bearer Flow setup that "works once, then dies"

Symptom. First call succeeds; subsequent calls fail with invalid_grant.

Cause. Refresh Token Policy isn't relevant for JWT Bearer (JWT itself is the credential, no refresh token), but the JWT signature validation may fail if:

Right answer. Check certificate expiry. Match the certificate in the Connected App's Use digital signatures setting against the cert the client signs with. Test with a fresh JWT generated manually to isolate variables.

Pattern B — Server-to-server integration's refresh token dies on day 2

Symptom. Web Server flow integration works on Monday; fails Tuesday with invalid_grant.

Cause. Refresh Token Policy is "Immediately expire refresh token" (default in older orgs) or a short window.

Right answer. Set Refresh Token Policy = "Refresh token is valid until revoked." Re-authorize the integration user (initial authorization captures a new refresh token under the new policy).

Pattern C — User can't access the Connected App

Symptom. User OAuth flow fails with OAUTH_APP_BLOCKED or a silent fall-through.

Diagnosis. Setup → Apps → Connected Apps → click the app → "OAuth Policies" → "Permitted Users":

For "Admin approved" model: Setup → Profiles or Permission Sets → edit the user's profile/permset → "Connected App Access" → enable the app.

Pattern D — Cloud-hosted integration hits IP restriction

Symptom. Integration on AWS Lambda / Heroku fails with IP_RESTRICTED from a different IP each time.

Cause. Connected App IP Relaxation = "Enforce IP restrictions"

Right answer. Connected App IP Relaxation = "Relax IP restrictions" (cloud IPs are too dynamic for IP-range pinning). Compensate by tightening the integration user's permission scope and rotating credentials regularly.

Pattern E — Connected App admin-blocked for security review

Symptom. All flows for the app fail with OAUTH_APP_BLOCKED.

Cause. Setup → Connected Apps OAuth Usage → admin set the app to "Block" while reviewing or revoking access.

Fix. Unblock the app, OR (if the block was intentional) migrate the integration to a different Connected App that's been approved.


Decision Guidance

SymptomDiagnosisFix
invalid_grant after first dayRefresh token policy too aggressiveSet to "Valid until revoked"
invalid_grant immediately on first callRefresh token never issued (wrong scope, wrong user)Add refresh_token scope; re-auth
invalid_client_idWrong Consumer KeyVerify against Connected App
IP_RESTRICTED from cloud infraTight IP enforcementRelax IP restrictions
OAUTH_APP_BLOCKEDUser not assigned OR app admin-blockedProfile / permset assignment OR unblock
redirect_uri_mismatchCallback URL not in app's listAdd the URL to the app
Silent failure / popup blockedOAuth Web Server flow + popup blockerTest in incognito; or use device flow
unsupported_grant_typeFlow not enabled in Connected App's OAuth PoliciesEdit app; enable the grant type
Inactive user errorIntegration user deactivatedReactivate or migrate to new user
First call works, every subsequent call failsRefresh token policy = "Immediately expire"Set to "Valid until revoked"

  1. Open a diagnosis record and paste the raw failure into it. Copy the YAML skeleton from

    templates/connected-app-diagnosis-record.yaml. Fill symptom, error (verbatim response body, secrets replaced with [REDACTED]), app, user-id and first-failure. An error you have paraphrased is an error you have already started guessing about.

  2. Run the evidence query set in references/metadata-examples.md § 2 and record every result.

    LoginHistory for the user over the failure window, OauthToken for the app, SetupAuditTrail for recent Setup changes. Each evidence entry needs both a query and a result — including "0 rows", which is itself a finding. Run the OauthToken query as a user holding Customize Application, or the empty result proves nothing.

  3. Walk the runbook table in references/metadata-examples.md § 1 top to bottom. It goes

    symptom → error string → the LoginHistory Status / LoginSubType to look for → the exact ConnectedApp XML path to inspect → the fix. Stop at the first row whose evidence column matches what step 2 actually returned; assign the matching root-cause category.

  4. Inspect the deployed policy, not the Setup screen. Retrieve the app and compare its

    oauthConfig / oauthPolicy against the annotated excerpt in references/metadata-examples.md § 3. admin/connected-apps-and-auth owns the full file; this step only reads the four fields the runbook named.

  5. Reproduce in a sandbox before changing production using the procedure in

    references/metadata-examples.md § 4 — matching policy block, the sandbox login host, and the sandbox-suffixed username. Skip this only when the diagnosis is a user assignment, which is reversible in one click.

  6. Apply the fix, re-authorize from a clean state, and re-run the same queries from step 2.

    Paste the post-fix rows into verified-by. A refresh-policy fix is not verified until a LoginSubType = OauthRefreshToken row returns Success, because the initial authorization would have succeeded either way.

  7. Lint the record and the metadata.

    python3 scripts/check_connected_app_troubleshooting.py --manifest-dir force-app/main/default fails on an incomplete diagnosis record and on the three policy combinations that produce the classic failures. Commit the record next to the change.


Review Checklist


Salesforce-Specific Gotchas

  1. refreshTokenPolicy zero works for one access-token lifetime, then dies — and the documented default is infinite, not "immediately expire". (See references/gotchas.md § 1.)
  2. invalid_grant is overloaded — five different causes; check Login History to disambiguate. (See references/gotchas.md § 2.)
  3. User assignment to Connected App is required for "Admin pre-approved" mode. Profile or permset entry. (See references/gotchas.md § 3.)
  4. redirect_uri mismatch is character-exact — trailing slashes matter. (See references/gotchas.md § 4.)
  5. JWT sub claim must be the user's Username, not Email. (See references/gotchas.md § 5.)
  6. Connected App admin-block is a separate setting from disabling OAuth. (See references/gotchas.md § 6.)
  7. IP Relaxation interacts with the user's profile IP range — both must permit the caller. (See references/gotchas.md § 7.)
  8. LoginSubType names the failing grant but cannot anchor a WHERE clause — it is outside the twelve filterable fields. (See references/gotchas.md § 11.)
  9. ForwardedForIp is never populated for an OAuth login, so the documented way to see past a proxy does not exist for this flow. (See references/gotchas.md § 12.)
  10. SetupAuditTrail.Section and Display are not filterable — the "who changed the policy" query has to filter on Action. (See references/gotchas.md § 13.)
  11. Secret-required is two independent switches, so an app can pass its first token exchange and fail every refresh. (See references/gotchas.md § 14.)

Output Artifacts

ArtifactDescription
Error → Cause → Fix mappingTranslated explanation for the specific OAuth error
Connected App settings changeRefresh Token Policy, IP Relaxation, Permitted Users, Callback URL
User assignment updateProfile / permset Connected App Access entry
Verification via Login HistoryConfirms the test post-fix shows Success

Reference Files

FileRead it when
references/metadata-examples.mdYou are running a live diagnosis — the symptom-to-fix runbook table, the three evidence queries, the oauthPolicy excerpt to compare against, the sandbox reproduction procedure, and the diagnosis-record shape all live here
references/gotchas.mdAn evidence query returned an answer you don't trust, a fix didn't take, or the failure timestamp doesn't line up with the change that caused it
references/examples.mdYou want the worked narratives end to end, including a completed diagnosis record
references/well-architected.mdYou need the Security / Reliability framing for a post-incident write-up, or the official source behind a claim here
references/llm-anti-patterns.mdYou are reviewing AI-generated OAuth triage advice before acting on it
templates/connected-app-troubleshooting-template.mdAt the start of a triage, to capture the symptom, flow, user and evidence in one place
templates/connected-app-diagnosis-record.yamlThe record you fill during the triage and commit alongside the fix — linted by the checker
scripts/check_connected_app_troubleshooting.pyBefore deploying a policy fix, and again once the diagnosis record is written

Package files