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
- Capture the error code verbatim.
invalid_grantvsinvalid_client_idvsunsupported_grant_typemean different things. - Identify the flow. Web Server, JWT Bearer, User Agent,
Device, Refresh Token, Username-Password (deprecated for most uses).
- Identify the user. Known interactive user, dedicated
integration user, Connected App pre-approved user.
- Pull Login History. Setup → Login History filtered by the
user / time window. The Salesforce side captures every login attempt with status code; many failures show here even when the client only sees a generic OAuth error.
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.
| Ask | Why it matters | What 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 against | The 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 broke | Whether 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 URL | The 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 query | A 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 origin | Whether 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 permissions | A 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-specific | A 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 code | Common cause | Fix |
|---|---|---|
invalid_grant | Refresh token revoked / expired / not issued for this client | Verify Refresh Token Policy on the Connected App; re-authorize the user |
invalid_client_id | Wrong Consumer Key | Check the Consumer Key matches the Connected App in the right org |
invalid_client_credentials | Wrong Consumer Secret | Reset / fetch the secret in Setup |
unsupported_grant_type | Connected App's "Permitted Users" or "OAuth Policies" don't permit this flow | Edit the Connected App; ensure the flow's grant type is enabled |
redirect_uri_mismatch | Callback URL in app doesn't match the request's redirect_uri | Update the Connected App's callback URL list |
inactive_user | User is deactivated | Activate the user OR use a different user |
IP_RESTRICTED | Connected App's IP relaxation = "Enforce IP restrictions" + caller IP not on user's profile login range | Either: 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 bypass | User must re-authenticate; auto-refresh isn't applicable |
OAUTH_APP_BLOCKED | Setup → Connected Apps Usage → admin blocked the app | Unblock or pick a different app |
Refresh Token Policy: the silent-killer setting
On the Connected App, Refresh Token Policy has four values:
| Value | Behavior |
|---|---|
| Refresh token is valid until revoked | Token works indefinitely until explicitly revoked. |
| Immediately expire refresh token | Refresh token expires after one use. |
| Expire refresh token if not used for N days | Sliding window; inactive refresh tokens expire. |
| Expire refresh token after N days | Hard 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
| Setting | Behavior |
|---|---|
| Enforce IP restrictions | Apply user's profile IP range; calls from outside fail with IP_RESTRICTED. |
| Relax IP restrictions for activated devices | First use prompts a device-verification email; subsequent calls from the verified device skip IP check. |
| Relax IP restrictions | Skip 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:
- All users may self-authorize. Anyone with API access can use
the app; the user sees a consent screen on first use.
- Admin-pre-approved users only. The app must be assigned to
the user via a profile or permission set's "Connected App Access" entries. Without assignment, the user gets
OAUTH_APP_BLOCKEDor similar.
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):
Statuscolumn tells you "Success" / "Failed: User notassigned to Connected App" / "Failed: Restricted IP" / "Invalid Password" / etc.
SourceIpverifies the caller's IP.Applicationconfirms the Connected App in use.AuthenticationServiceIdidentifies the auth method.
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:
- Certificate expired.
- Wrong certificate referenced (Connected App keyed to one cert,
client signing with another).
- Username mismatch (
subclaim must be the username, exactly).
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":
All users may self-authorize— user gets consent prompt.Admin approved users are pre-authorized— user must beassigned via profile or permset.
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"
- user's profile has a tight IP range that doesn't match the
cloud IP pool.
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
| Symptom | Diagnosis | Fix |
|---|---|---|
invalid_grant after first day | Refresh token policy too aggressive | Set to "Valid until revoked" |
invalid_grant immediately on first call | Refresh token never issued (wrong scope, wrong user) | Add refresh_token scope; re-auth |
invalid_client_id | Wrong Consumer Key | Verify against Connected App |
IP_RESTRICTED from cloud infra | Tight IP enforcement | Relax IP restrictions |
OAUTH_APP_BLOCKED | User not assigned OR app admin-blocked | Profile / permset assignment OR unblock |
redirect_uri_mismatch | Callback URL not in app's list | Add the URL to the app |
| Silent failure / popup blocked | OAuth Web Server flow + popup blocker | Test in incognito; or use device flow |
unsupported_grant_type | Flow not enabled in Connected App's OAuth Policies | Edit app; enable the grant type |
| Inactive user error | Integration user deactivated | Reactivate or migrate to new user |
| First call works, every subsequent call fails | Refresh token policy = "Immediately expire" | Set to "Valid until revoked" |
Recommended Workflow
- Open a diagnosis record and paste the raw failure into it. Copy the YAML skeleton from
templates/connected-app-diagnosis-record.yaml. Fillsymptom,error(verbatim response body, secrets replaced with[REDACTED]),app,user-idandfirst-failure. An error you have paraphrased is an error you have already started guessing about. - Run the evidence query set in
references/metadata-examples.md§ 2 and record every result.LoginHistoryfor the user over the failure window,OauthTokenfor the app,SetupAuditTrailfor recent Setup changes. Eachevidenceentry needs both aqueryand aresult— including "0 rows", which is itself a finding. Run theOauthTokenquery as a user holding Customize Application, or the empty result proves nothing. - Walk the runbook table in
references/metadata-examples.md§ 1 top to bottom. It goessymptom → error string → the
LoginHistoryStatus/LoginSubTypeto look for → the exactConnectedAppXML path to inspect → the fix. Stop at the first row whose evidence column matches what step 2 actually returned; assign the matchingroot-causecategory. - Inspect the deployed policy, not the Setup screen. Retrieve the app and compare its
oauthConfig/oauthPolicyagainst the annotated excerpt inreferences/metadata-examples.md§ 3.admin/connected-apps-and-authowns the full file; this step only reads the four fields the runbook named. - 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. - 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 aLoginSubType = OauthRefreshTokenrow returns Success, because the initial authorization would have succeeded either way. - Lint the record and the metadata.
python3 scripts/check_connected_app_troubleshooting.py --manifest-dir force-app/main/defaultfails 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
- [ ] Error code captured exactly from the client.
- [ ] Login History checked for the user / time window.
- [ ] Connected App Refresh Token Policy is "Valid until revoked" for server-to-server.
- [ ] User is assigned to the Connected App via profile or permset.
- [ ] Callback URL list includes every URL the client uses.
- [ ] IP Relaxation matches the integration's IP source (relax for cloud, enforce for on-prem).
- [ ] Cert (for JWT Bearer) is current and matches client.
- [ ] Test post-fix verifies Success in Login History.
Salesforce-Specific Gotchas
refreshTokenPolicyzeroworks for one access-token lifetime, then dies — and the documented default isinfinite, not "immediately expire". (Seereferences/gotchas.md§ 1.)invalid_grantis overloaded — five different causes; check Login History to disambiguate. (Seereferences/gotchas.md§ 2.)- User assignment to Connected App is required for "Admin pre-approved" mode. Profile or permset entry. (See
references/gotchas.md§ 3.) redirect_urimismatch is character-exact — trailing slashes matter. (Seereferences/gotchas.md§ 4.)- JWT
subclaim must be the user's Username, not Email. (Seereferences/gotchas.md§ 5.) - Connected App admin-block is a separate setting from disabling OAuth. (See
references/gotchas.md§ 6.) - IP Relaxation interacts with the user's profile IP range — both must permit the caller. (See
references/gotchas.md§ 7.) LoginSubTypenames the failing grant but cannot anchor aWHEREclause — it is outside the twelve filterable fields. (Seereferences/gotchas.md§ 11.)ForwardedForIpis never populated for an OAuth login, so the documented way to see past a proxy does not exist for this flow. (Seereferences/gotchas.md§ 12.)SetupAuditTrail.SectionandDisplayare not filterable — the "who changed the policy" query has to filter onAction. (Seereferences/gotchas.md§ 13.)- 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
| Artifact | Description |
|---|---|
| Error → Cause → Fix mapping | Translated explanation for the specific OAuth error |
| Connected App settings change | Refresh Token Policy, IP Relaxation, Permitted Users, Callback URL |
| User assignment update | Profile / permset Connected App Access entry |
| Verification via Login History | Confirms the test post-fix shows Success |
Reference Files
| File | Read it when |
|---|---|
references/metadata-examples.md | You 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.md | An 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.md | You want the worked narratives end to end, including a completed diagnosis record |
references/well-architected.md | You need the Security / Reliability framing for a post-incident write-up, or the official source behind a claim here |
references/llm-anti-patterns.md | You are reviewing AI-generated OAuth triage advice before acting on it |
templates/connected-app-troubleshooting-template.md | At the start of a triage, to capture the symptom, flow, user and evidence in one place |
templates/connected-app-diagnosis-record.yaml | The record you fill during the triage and commit alongside the fix — linted by the checker |
scripts/check_connected_app_troubleshooting.py | Before deploying a policy fix, and again once the diagnosis record is written |
Related Skills
integration/oauth-flows-and-connected-apps— choosing and designing the OAuth grant flow; this skill is the troubleshooting half.admin/connected-apps-and-auth— authoring the fullconnectedApp/ External Client App / Named Credential file, and the artefact decision.admin/integration-admin-connected-apps— the operations half: hardening, revocation, key and secret rotation, monitoring, and the periodic review.security/oauth-token-management— token lifecycle and revocation semantics beyond a single failing flow.security/login-forensics— when the question is "who logged in and from where", not "why did this grant fail".security/sso-saml-troubleshooting— SAML-specific debugging (different runtime, different evidence).apex/apex-jwt-bearer-flow— JWT-specific setup including certificate management.integration/named-credentials-setup— External Credentials and Named Credentials, which interact with these OAuth flows.admin/org-setup-and-configuration— My Domain, trusted IP ranges and session settings, which the login host and the IP branch both depend on.