OAuth Redirect And Domain Strategy
Every OAuth failure in Salesforce that is not a permission problem is a string
matching problem. Three strings have to line up, and they are owned by three
different parties:
| String |
Owned by |
Where it lives |
| Login host |
The org |
https://<mydomain>.my.salesforce.com, or https://<mydomain>--<sandbox>.sandbox.my.salesforce.com |
redirect_uri |
The client |
Sent on the authorize request and again on the token request |
| Callback URL |
The connected app |
ConnectedAppOauthConfig.callbackUrl — "It's the OAuth redirect_uri" |
The failure mode is binary and leaves almost no Salesforce-side evidence: a browser
redirect carrying an error parameter, no debug log, and often nothing in Login
History because no login occurred. Client-side logging is the primary diagnostic and
has to exist before you need it.
Before Starting
Enumerate the applications, not the orgs. One logical application deployed to
four environments is one connected app with four callback lines — not four
connected apps.
Know that the consumer key is immutable. "In API version 32.0 and later, you
can set this field's value only during creation. After you define and save the
value, it can't be edited." It must also be globally unique.
Establish whether clients can send PKCE before requiring it. Turning it on is
a hard cut: "any authorization code flow variations that don't implement it fail."
Inventory what lives outside the org if a domain change is coming — IdP
configuration, webhook registrations, partner allow-lists. Their owners' lead time
is the cutover's real critical path.
Core Concepts
callbackUrl is a newline-separated list
A Connected App accepts multiple callback URLs, newline-separated (\r
programmatically), and matches the app's requested URL against the list at run time —
quoted in full in references/gotchas.md.
<callbackUrl>https://orders.example.com/oauth/callback
https://uat.orders.example.com/oauth/callback
https://dev1.orders.example.com/oauth/callback</callbackUrl>
A comma makes the whole field one malformed string and breaks every environment
on the app at once.
Matching is exact
There is no documented wildcard, prefix, or normalisation behaviour. Treat the match
as byte-identical. All of these are different strings from
https://orders.example.com/oauth/callback:
https://orders.example.com/oauth/callback/ trailing slash
https://Orders.example.com/oauth/callback host or path case
http://orders.example.com/oauth/callback scheme
https://orders.example.com:443/oauth/callback explicit default port
https://orders.example.com/oauth/callback?src=a library-appended query parameter
Use the protocol's state parameter for anything that needs round-tripping — it is
carried separately and is not part of the redirect URI.
PKCE defaults are opposites
| Type |
isPkceRequired default |
ConnectedApp |
false — "The default value is false." (API 59.0 and later) |
ExternalClientApplication |
true — "If set to true (default) Proof Key for Code for Exchange (PKCE) is required for OAuth integration." |
A client that worked against a connected app can fail immediately against an
External Client App, having changed nothing. Write the field explicitly on both.
Platform guidance: "we always recommend implementing PKCE for public clients. We
also strongly recommend that you implement PKCE for private clients."
Login host formats
| Environment |
Host |
| Production |
https://<mydomain>.my.salesforce.com |
| Sandbox |
https://<mydomain>--<sandboxname>.sandbox.my.salesforce.com |
login.salesforce.com and test.salesforce.com still work but redirect to My
Domain, and client libraries differ in how cleanly they follow that redirect. Point
clients directly at My Domain, from an environment variable — never a build-time
constant, because the sandbox name is part of the host.
Common Patterns
Pattern A — one app, every environment
One connected app definition deployed to every org, with every environment's
callback as a line in callbackUrl. One consumer key, one rotation, and the client
varies only SF_LOGIN_URL. Full metadata in
references/examples.md, Example 1.
Pattern B — separate apps where token isolation matters
Where production and sandbox tokens must be non-interchangeable, keep separate apps
— and write down the extra cost: N immutable keys, N rotations, N reconfigurations
after every sandbox refresh. Make it a decision, not an accident of the Setup UI.
Pattern C — the redirect matrix
One row per connected app per environment: login host, redirect_uri the client
sends, the stored callback line it matches, the owner, and the date it was last
verified. This is the artifact that turns a domain change from discovery into
review.
Pattern D — PKCE rollout in the safe order
Verify on the wire that the client sends code_challenge on authorize and
code_verifier on token exchange, then set isPkceRequired to true. Never the
reverse — there is no warning period.
Decision Guidance
| Situation |
Approach |
| One application, several environments |
One connected app, several callbackUrl lines |
| Production and sandbox tokens must not be interchangeable |
Separate apps, with the rotation cost documented |
| Client is a mobile or single-page app |
isPkceRequired true; the client cannot hold a secret |
| Client library is old and unverified |
Verify PKCE on the wire before requiring it |
| Redirect mismatch, configuration looks correct |
Log the client's redirect_uri on both requests and diff against the stored line |
| Several callback paths needed |
One line each — no wildcard, no prefix |
| Developers need local flow |
localhost in non-production apps only; if production, record why and a removal date |
| Enhanced domains cutover |
Redirect matrix + repo sweep + external-system owner list |
| Sandbox refresh scheduled |
Redeploy connected apps from source; reissue secrets; confirm SF_LOGIN_URL |
Recommended Workflow
- Build the redirect matrix: one row per connected app per environment, with
login host, client
redirect_uri, stored callback line, owner, and verification
date. Include integrations with no redirect (client credentials) so the inventory
is complete.
- Consolidate to one connected app per application where token isolation is not
required, moving every environment's callback into
callbackUrl as its own line.
- Set
isPkceRequired explicitly on every app of both types, after verifying
on the wire that clients send code_challenge and code_verifier.
- Move the login host into an environment variable in every client, pointing at
the org's My Domain rather than
login.salesforce.com.
- Sweep for hardcoded hosts across Apex, LWC, metadata, email templates, and
then across systems outside the org — IdP configuration, webhook registrations,
partner allow-lists — assigning an owner and a confirmation date to each external
item.
- Rehearse any domain change in a full sandbox, exercising every row of the
matrix rather than one representative flow.
- Add connected app redeployment and secret reissue to the sandbox post-refresh
runbook, and add "no
localhost callbacks in production" to the release
checklist.
Review Checklist
Salesforce-Specific Gotchas
Full detail with quotes in references/gotchas.md.
- The consumer key is immutable after save and must be globally unique.
- Callback matching is exact, and the near-misses are invisible on screen.
- Multiple callbacks are newline-separated, not comma-separated — a comma
breaks every environment at once.
isPkceRequired defaults are opposites between ConnectedApp (false) and
ExternalClientApplication (true).
login.salesforce.com works, then redirects, and libraries differ in how they
handle it.
- A sandbox refresh changes the host and can destroy the app.
localhost callbacks outlive development and nothing ever fails to flag them.
- The domain change is only partly inside Salesforce.
- The failure leaves no Salesforce-side evidence.
- One app per environment multiplies rotation and breaks on refresh.
Output Artifacts
| Artifact |
Description |
| Redirect matrix |
App × environment, with login host, client redirect_uri, stored callback line, owner, and verification date |
| Consolidated connected app metadata |
One definition per application with newline-separated callbacks and explicit isPkceRequired |
| Client configuration standard |
SF_LOGIN_URL as an environment variable, with the per-environment values and who sets them |
| Hardcoded-host sweep results |
Repo grep output plus the external-system inventory with owners and confirmation dates |
| Cutover rehearsal record |
Which matrix rows were exercised in the sandbox, and the result of each |
Related Skills
security/connected-app-security-policies — the session, refresh token, and IP
policies that sit alongside the OAuth configuration on the same app
security/oauth-token-management — token lifetime, refresh rotation, and
revocation once the flow works
security/api-only-user-hardening — the identity a client-credentials app binds
to, which has no redirect at all
devops/sandbox-refresh-and-templates — the refresh event that invalidates
connected apps and login hosts
1---2name: oauth-redirect-and-domain-strategy3description: Design Connected App OAuth callback URLs, My Domain naming, Enhanced Domains cutover, and cross-environment redirect handling. Trigger keywords: oauth redirect uri, connected app callback, my domain, enhanced domains, sandbox url change. NOT for end-user login flow UX, Experience Cloud branding, or SAML-only SSO configuration — use admin/connected-app-troubleshooting.4---56# OAuth Redirect And Domain Strategy78Every OAuth failure in Salesforce that is not a permission problem is a **string9matching** problem. Three strings have to line up, and they are owned by three10different parties:1112| String | Owned by | Where it lives |13|---|---|---|14| **Login host** | The org | `https://<mydomain>.my.salesforce.com`, or `https://<mydomain>--<sandbox>.sandbox.my.salesforce.com` |15| **`redirect_uri`** | The client | Sent on the authorize request and again on the token request |16| **Callback URL** | The connected app | `ConnectedAppOauthConfig.callbackUrl` — "It's the OAuth `redirect_uri`" |1718The failure mode is binary and leaves almost no Salesforce-side evidence: a browser19redirect carrying an error parameter, no debug log, and often nothing in Login20History because no login occurred. Client-side logging is the primary diagnostic and21has to exist before you need it.2223---2425## Before Starting26271. **Enumerate the applications, not the orgs.** One logical application deployed to28 four environments is one connected app with four callback lines — not four29 connected apps.30312. **Know that the consumer key is immutable.** "In API version 32.0 and later, you32 can set this field's value only during creation. After you define and save the33 value, it can't be edited." It must also be globally unique.34353. **Establish whether clients can send PKCE** before requiring it. Turning it on is36 a hard cut: "any authorization code flow variations that don't implement it fail."37384. **Inventory what lives outside the org** if a domain change is coming — IdP39 configuration, webhook registrations, partner allow-lists. Their owners' lead time40 is the cutover's real critical path.4142---4344## Core Concepts4546### `callbackUrl` is a newline-separated list4748A Connected App accepts multiple callback URLs, newline-separated (`\r`49programmatically), and matches the app's requested URL against the list at run time —50quoted in full in [`references/gotchas.md`](references/gotchas.md).5152```xml53<callbackUrl>https://orders.example.com/oauth/callback54https://uat.orders.example.com/oauth/callback55https://dev1.orders.example.com/oauth/callback</callbackUrl>56```5758A comma makes the whole field one malformed string and breaks **every** environment59on the app at once.6061### Matching is exact6263There is no documented wildcard, prefix, or normalisation behaviour. Treat the match64as byte-identical. All of these are different strings from65`https://orders.example.com/oauth/callback`:6667```text68https://orders.example.com/oauth/callback/ trailing slash69https://Orders.example.com/oauth/callback host or path case70http://orders.example.com/oauth/callback scheme71https://orders.example.com:443/oauth/callback explicit default port72https://orders.example.com/oauth/callback?src=a library-appended query parameter73```7475Use the protocol's `state` parameter for anything that needs round-tripping — it is76carried separately and is not part of the redirect URI.7778### PKCE defaults are opposites7980| Type | `isPkceRequired` default |81|---|---|82| `ConnectedApp` | **`false`** — "The default value is `false`." (API 59.0 and later) |83| `ExternalClientApplication` | **`true`** — "If set to `true` (default) Proof Key for Code for Exchange (PKCE) is required for OAuth integration." |8485A client that worked against a connected app can fail immediately against an86External Client App, having changed nothing. Write the field explicitly on both.8788Platform guidance: "we always recommend implementing PKCE for public clients. We89also strongly recommend that you implement PKCE for private clients."9091### Login host formats9293| Environment | Host |94|---|---|95| Production | `https://<mydomain>.my.salesforce.com` |96| Sandbox | `https://<mydomain>--<sandboxname>.sandbox.my.salesforce.com` |9798`login.salesforce.com` and `test.salesforce.com` still work but redirect to My99Domain, and client libraries differ in how cleanly they follow that redirect. Point100clients directly at My Domain, from an environment variable — never a build-time101constant, because the sandbox name is part of the host.102103---104105## Common Patterns106107### Pattern A — one app, every environment108109One connected app definition deployed to every org, with every environment's110callback as a line in `callbackUrl`. One consumer key, one rotation, and the client111varies only `SF_LOGIN_URL`. Full metadata in112[`references/examples.md`](references/examples.md), Example 1.113114### Pattern B — separate apps where token isolation matters115116Where production and sandbox tokens must be non-interchangeable, keep separate apps117— and write down the extra cost: N immutable keys, N rotations, N reconfigurations118after every sandbox refresh. Make it a decision, not an accident of the Setup UI.119120### Pattern C — the redirect matrix121122One row per connected app per environment: login host, `redirect_uri` the client123sends, the stored callback line it matches, the owner, and the date it was last124verified. This is the artifact that turns a domain change from discovery into125review.126127### Pattern D — PKCE rollout in the safe order128129Verify on the wire that the client sends `code_challenge` on authorize and130`code_verifier` on token exchange, *then* set `isPkceRequired` to `true`. Never the131reverse — there is no warning period.132133---134135## Decision Guidance136137| Situation | Approach |138|---|---|139| One application, several environments | One connected app, several `callbackUrl` lines |140| Production and sandbox tokens must not be interchangeable | Separate apps, with the rotation cost documented |141| Client is a mobile or single-page app | `isPkceRequired` `true`; the client cannot hold a secret |142| Client library is old and unverified | Verify PKCE on the wire before requiring it |143| Redirect mismatch, configuration looks correct | Log the client's `redirect_uri` on both requests and diff against the stored line |144| Several callback paths needed | One line each — no wildcard, no prefix |145| Developers need local flow | `localhost` in non-production apps only; if production, record why and a removal date |146| Enhanced domains cutover | Redirect matrix + repo sweep + external-system owner list |147| Sandbox refresh scheduled | Redeploy connected apps from source; reissue secrets; confirm `SF_LOGIN_URL` |148149---150151## Recommended Workflow1521531. **Build the redirect matrix**: one row per connected app per environment, with154 login host, client `redirect_uri`, stored callback line, owner, and verification155 date. Include integrations with no redirect (client credentials) so the inventory156 is complete.1572. **Consolidate to one connected app per application** where token isolation is not158 required, moving every environment's callback into `callbackUrl` as its own line.1593. **Set `isPkceRequired` explicitly** on every app of both types, after verifying160 on the wire that clients send `code_challenge` and `code_verifier`.1614. **Move the login host into an environment variable** in every client, pointing at162 the org's My Domain rather than `login.salesforce.com`.1635. **Sweep for hardcoded hosts** across Apex, LWC, metadata, email templates, and164 then across systems outside the org — IdP configuration, webhook registrations,165 partner allow-lists — assigning an owner and a confirmation date to each external166 item.1676. **Rehearse any domain change in a full sandbox**, exercising every row of the168 matrix rather than one representative flow.1697. **Add connected app redeployment and secret reissue** to the sandbox post-refresh170 runbook, and add "no `localhost` callbacks in production" to the release171 checklist.172173---174175## Review Checklist176177- [ ] One connected app per application, unless token isolation is a written decision178- [ ] Every environment's callback present as its own newline-separated line179- [ ] No commas, no wildcards, no prefixes in `callbackUrl`180- [ ] No trailing whitespace on any stored callback value181- [ ] `isPkceRequired` written explicitly on every connected app and External Client App182- [ ] PKCE verified on the wire before being required183- [ ] Client login host is an environment variable pointing at My Domain184- [ ] No `salesforce.com` string literals in client code185- [ ] No `localhost` callbacks in production apps, or a recorded reason and removal date186- [ ] Client-side logging of the authorize and token requests exists before it is needed187- [ ] Redirect matrix has an owner and a verification date for every row188- [ ] External systems (IdP, webhooks, partner allow-lists) enumerated with owners189- [ ] Sandbox post-refresh runbook includes connected app redeploy and secret reissue190191---192193## Salesforce-Specific Gotchas194195Full detail with quotes in [`references/gotchas.md`](references/gotchas.md).1961971. **The consumer key is immutable after save** and must be globally unique.1982. **Callback matching is exact**, and the near-misses are invisible on screen.1993. **Multiple callbacks are newline-separated**, not comma-separated — a comma200 breaks every environment at once.2014. **`isPkceRequired` defaults are opposites** between `ConnectedApp` (`false`) and202 `ExternalClientApplication` (`true`).2035. **`login.salesforce.com` works, then redirects**, and libraries differ in how they204 handle it.2056. **A sandbox refresh changes the host and can destroy the app.**2067. **`localhost` callbacks outlive development** and nothing ever fails to flag them.2078. **The domain change is only partly inside Salesforce.**2089. **The failure leaves no Salesforce-side evidence.**20910. **One app per environment multiplies rotation and breaks on refresh.**210211---212213## Output Artifacts214215| Artifact | Description |216|---|---|217| Redirect matrix | App × environment, with login host, client `redirect_uri`, stored callback line, owner, and verification date |218| Consolidated connected app metadata | One definition per application with newline-separated callbacks and explicit `isPkceRequired` |219| Client configuration standard | `SF_LOGIN_URL` as an environment variable, with the per-environment values and who sets them |220| Hardcoded-host sweep results | Repo grep output plus the external-system inventory with owners and confirmation dates |221| Cutover rehearsal record | Which matrix rows were exercised in the sandbox, and the result of each |222223---224225## Related Skills226227- `security/connected-app-security-policies` — the session, refresh token, and IP228 policies that sit alongside the OAuth configuration on the same app229- `security/oauth-token-management` — token lifetime, refresh rotation, and230 revocation once the flow works231- `security/api-only-user-hardening` — the identity a client-credentials app binds232 to, which has no redirect at all233- `devops/sandbox-refresh-and-templates` — the refresh event that invalidates234 connected apps and login hosts