Federating Login Between Two Salesforce Orgs
Every guide I found for Salesforce-to-Salesforce SSO reaches for SAML. Enable the Identity Provider, generate a certificate, exchange metadata files, wrestle with assertion formats. It works, and if you need federated logout or SP-initiated flows out of the box, it's still the right answer.
But there's a second path that almost nobody writes about: OpenID Connect, using an Auth Provider on the consuming org and an External Client App on the identity org. Fewer moving parts, no certificate management, and a proper Apex hook for deciding who gets to log in. I built it end to end last week. Here's the full setup, plus the four errors I hit and what each one actually meant.
Throughout, I'll use two orgs:
IdP org = https://acme-idp.my.salesforce.com (holds the identity)
SP org = https://acme-app.my.salesforce.com (users log in here)
Why OIDC instead of SAML
SAML federates identity through a signed XML assertion. You manage certificates on both ends, and the trust relationship is baked into metadata files you exchange manually. Rotating a certificate means redoing part of the setup.
OIDC federates through an OAuth authorization code exchange. The trust is a consumer key and secret. There are no certificates, and Salesforce gives you a registration handler, an Apex class that runs on every login and decides which local user the incoming identity maps to. That hook is the real reason to pick this path. It's the difference between "usernames must match exactly" and "here's the actual logic for who gets in."
The trade-off is logout. SAML gives you single logout natively. With OIDC you wire it up yourself, or you accept that logging out of the SP org leaves the IdP session alive.
The setup order matters
This tripped me up before I wrote a line of config. The callback URL doesn't exist until you save the Auth Provider, and the Auth Provider form won't save without an existing registration handler class. So the sequence is fixed:
- SP org: deploy the registration handler
- SP org: create the Auth Provider with placeholder credentials
- SP org: copy the generated callback URL
- IdP org: create the External Client App using that URL
- IdP org: copy the real consumer key and secret
- SP org: paste them into the Auth Provider
- Test, then enable on the login page
Do it in any other order and you'll create something twice.
Step 1: The registration handler
I wanted match-only behavior. Users must already exist in the SP org; SSO federates the login, it doesn't provision accounts. If someone authenticates from the IdP org with no counterpart locally, they should get a clear failure, not a mystery user.
global class AcmeIdpRegHandler implements Auth.RegistrationHandler {
global User createUser(Id portalId, Auth.UserData data) {
if (String.isBlank(data.email)) {
throw new Auth.LoginDiscoveryException('No email returned by the identity provider.');
}
List<User> matches = [
SELECT Id, FederationIdentifier
FROM User
WHERE IsActive = true
AND (FederationIdentifier = :data.identifier OR Email = :data.email)
ORDER BY FederationIdentifier NULLS LAST
LIMIT 1
];
if (matches.isEmpty()) {
throw new Auth.LoginDiscoveryException(
'No active user matches ' + data.email + '. Ask an admin to create one first.'
);
}
User u = matches[0];
if (String.isBlank(u.FederationIdentifier)) {
update new User(Id = u.Id, FederationIdentifier = data.identifier);
}
return u;
}
global void updateUser(Id userId, Id portalId, Auth.UserData data) {
// The IdP org isn't authoritative for profile data here.
}
}
The method name is misleading. createUser doesn't have to create anything.
Return an existing User and the platform links that account instead. Throw
Auth.LoginDiscoveryException and the user lands on the Auth Provider's error
page with your message intact.
The first login matches on email and stamps the Federation Id. Every login after that matches on the Federation Id directly. Email addresses change; the identifier doesn't.
If you do want JIT provisioning, build and return an unsaved User with a
profile and the standard locale fields. Don't insert it. The platform handles
that, and inserting it yourself produces a duplicate.
Step 2: The Auth Provider
Setup, Auth. Providers, New. Provider type Salesforce.
| Field | Value |
|---|---|
| URL Suffix | Acme_Idp |
| Consumer Key / Secret | placeholder for now |
| Authorize Endpoint | https://acme-idp.my.salesforce.com/services/oauth2/authorize |
| Token Endpoint | https://acme-idp.my.salesforce.com/services/oauth2/token |
| Use PKCE Extension | checked |
| Default Scopes | openid profile email |
| Registration Handler | AcmeIdpRegHandler |
| Execute Registration As | an SP-org admin |
Pick the URL suffix carefully. It shows up in every generated URL, and changing it later invalidates the callback URL you're about to register on the other side.
Saving produces a Salesforce Configuration block with six URLs. Copy the callback URL exactly as rendered. I'll come back to why in the troubleshooting section.
Step 3: The External Client App
Connected apps are being restricted starting Spring '26, so new work goes through External Client App Manager. If your org hasn't been opted in, there's a toggle under External Client App Settings first.
Create the app with Distribution State Local, enable OAuth, and paste the
callback URL from step 2. For scopes, select openid and the identity URL
service scope. More on that below, because it's where I lost the most time.
Then the Policies tab, which is separate from Settings and versioned independently. Set Permitted Users to "Admin approved users are pre-authorized" and assign the profiles that should be able to log in. Skip this and the approval step has nothing to check against.
The consumer key and secret live under Settings, behind an identity verification prompt. Copy both back into the Auth Provider, replacing the placeholders.
Then wait. The app can take up to 30 minutes to propagate, and everything fails
with invalid_client_id until it does. I burned twenty minutes debugging
configuration that was already correct.
Step 4: Test in the right order
Salesforce generates four initialization URLs, and they are not interchangeable. Use them in this sequence, because each isolates a different layer.
Test-Only (/services/auth/test/Acme_Idp) runs the complete OAuth handshake
but skips the registration handler and creates no session. It dumps the raw
identity payload to your browser as XML. This is the single most useful URL in
the set. It tells you whether the handshake works before any Apex is in the way.
Existing User Linking (/services/auth/link/Acme_Idp) links whoever is
currently logged into the SP org to the IdP identity, bypassing createUser
entirely. For a match-only setup this is the cleanest way to establish the first
link.
Single Sign-On (/services/auth/sso/Acme_Idp) is the real flow. Run it in a
private window, with a debug log enabled on the Execute Registration As user.
OAuth-Only returns an access token without a session. Not what you want here.
Only after the SSO URL works should you enable the provider under My Domain, Authentication Configuration. Leave the Login Form checkbox on. That's the difference between a bad afternoon and a support case.
The four errors
missing required code challenge
The IdP org had PKCE enforced org-wide, which greys out the per-app checkbox and makes it look like a permanent constraint. It isn't a problem to solve on that side. The fix is on the consuming org: check "Use Proof Key for Code Exchange (PKCE) Extension" on the Auth Provider. It sits just below the token endpoint field and it's easy to scroll past.
Worth knowing that editing the Auth Provider blanks the consumer secret in some
releases. Re-paste it every time you touch that record, or you'll trade this
error for invalid_client and think you've made things worse.
OAUTH_APPROVAL_ERROR_GENERIC
Deliberately uninformative, and the message tells you to contact your administrator when you are the administrator. The real reason is logged on the IdP side: Setup, Login History, filtered by application. The Status column names the actual failure. Don't try to guess this one from the browser.
invalid_scope: the requested scope is not allowed
My Auth Provider requested openid profile email. The client app granted
openid. I went looking for profile and email checkboxes and there aren't
any. Both are bundled into a single scope called "Access the identity URL service
(id, profile, email, address, phone)". Select that one and the mismatch clears.
If scopes keep fighting you, blank out Default Scopes entirely. The Salesforce
provider type falls back to defaults that match what a Salesforce client app
grants. You lose refresh_token, which SSO doesn't need. The session comes from
the registration handler, not from a long-lived token.
The identifier isn't what you think
Once the handshake worked, the Test-Only URL returned this:
<user>
<full_name>Jane Doe</full_name>
<provider>Salesforce</provider>
<org_id>00Dxx0000000000</org_id>
<last_name>Doe</last_name>
<id>00Dxx0000000000AAA005xx0000000000AAA</id>
<email>jane.doe@example.com</email>
<username>jane.doe@example.com.acme</username>
</user>
Look at id. That's 36 characters, not 18. It's the org Id and the user Id
concatenated:
00Dxx0000000000AAA005xx0000000000AAA
└─── org id (18) ──┘└─ user id (18) ─┘
Auth.UserData.identifier carries that full string. My handler was writing it
into FederationIdentifier and matching against it, so it worked, but any
Federation Id an admin pre-populates by hand has to be the full 36 characters
too. If you'd rather store just the user portion, take .right(18), and apply
that consistently. Write one format and match another and every login creates a
fresh link.
Also note the payload's username and email differ. On my IdP user they
weren't close. If you're matching on email, that's the field that has to line up
in the SP org. The username is irrelevant.
What I'd tell someone starting this
Use the Test-Only URL first, always. Half of these errors are handshake problems, and debugging them through the full SSO flow means reading OAuth failures through a layer of Apex that hasn't even run yet.
Assume the propagation delay is real. If you just created or edited the client app, wait before you change anything else. Config churn during that window produces errors that look like your changes broke something.
Decide your identity matching strategy before writing the handler, not after. Email, Federation Id, or a custom field are all defensible. Switching later means unpicking links that already exist.
And keep the login form enabled until you've logged in through SSO successfully, in a private window, as a user who isn't you.
Should you use this over SAML?
If you need single logout or you're federating to something that isn't Salesforce, SAML is still the better fit. If you're connecting two Salesforce orgs and you want real control over which local user an incoming identity maps to, the registration handler makes this approach worth the setup. No certificates to rotate, and the matching logic lives in a class you can test.
Bidirectional SSO is two entirely separate configurations, incidentally. Nothing is shared between them. Build one direction, prove it, then start the second.
HEHE
Comments
Post a Comment