WorkOS: Adding SSO Without Becoming an Identity Company
SSO used to mean weeks of SAML hell. WorkOS changed that calculus — here's what it actually looks like to integrate it.
The last time I integrated enterprise SSO from scratch, I lost two weeks of my life to SAML XML parsing, metadata endpoint confusion, and a client's IT department who couldn't explain why their IdP was returning assertions with the wrong NameID format. WorkOS made that problem mostly disappear — and I want to be specific about what "mostly" means.
What Problem WorkOS Actually Solves
If you're building a B2B SaaS app and a prospect's IT director says "we need SSO with our Okta," you have a few options. You can implement SAML yourself (painful), use a library like onelogin/php-saml and stitch it together (less painful, still annoying), or hand the whole thing to an abstraction layer.
WorkOS is that abstraction layer. It sits between your app and whatever identity provider the enterprise customer is using — Okta, Azure AD, Google Workspace, Ping, ADFS, the whole zoo. You integrate WorkOS once, and then connecting a new customer to their IdP is mostly a configuration problem, not a code problem.
That shift — from code problem to configuration problem — is the actual product. The API surface is small. The "Admin Portal" that WorkOS generates for your customers is genuinely useful. And the pricing model (free until you have paying enterprise customers) means I can build the integration before anyone has committed money.
I integrated this for a healthcare SaaS client last year. They were landing mid-market hospital system deals and SSO was a hard blocker on three of them. I had WorkOS in production in about four days, including the back-and-forth with their first customer's Okta admin.
The Integration in Plain Terms
WorkOS's SSO flow is classic OAuth-shaped:
- Your app redirects the user to WorkOS with a connection identifier
- WorkOS handles the SAML/OIDC dance with the IdP
- WorkOS redirects back to your callback URL with a code
- You exchange the code for a user profile
- You match that profile to a user in your system and issue a session
Step 5 is yours. WorkOS doesn't manage your users — it just tells you who authenticated and what attributes came back from the IdP. That's the right call. I don't want another system owning my user table.
Working Code
I use the official workos-inc/workos-php package. Here's the real flow in a Laravel app.
First, the redirect. I identify which WorkOS connection to use based on the organization the user belongs to — I store the WorkOS connection_id per-organization in my database.
<?php
namespace App\Http\Controllers\Auth;
use App\Models\Organization;
use Illuminate\Http\Request;
use WorkOS\SSO;
class SsoController extends Controller
{
public function redirect(Request $request)
{
$request->validate(['organization_slug' => 'required|string']);
$org = Organization::where('slug', $request->organization_slug)
->firstOrFail();
abort_if(empty($org->workos_connection_id), 422, 'SSO not configured for this organization.');
$state = bin2hex(random_bytes(16));
session(['sso_state' => $state, 'sso_org_id' => $org->id]);
$authorizationUrl = SSO::getAuthorizationURL(
connection: $org->workos_connection_id,
redirectURI: route('auth.sso.callback'),
state: $state,
);
return redirect($authorizationUrl);
}
public function callback(Request $request)
{
abort_if(
$request->state !== session('sso_state'),
422,
'Invalid state parameter.'
);
$profile = SSO::getProfileAndToken(
code: $request->code,
redirectURI: route('auth.sso.callback'),
);
$orgId = session('sso_org_id');
$org = Organization::findOrFail($orgId);
// Find or provision the user. WorkOS gives you id, email,
// first_name, last_name, and raw IdP attributes.
$user = $org->users()->firstOrCreate(
['workos_profile_id' => $profile->profile->id],
[
'email' => $profile->profile->email,
'first_name' => $profile->profile->firstName,
'last_name' => $profile->profile->lastName,
'role' => 'member', // your default
]
);
// Keep the email in sync — it can change in the IdP.
$user->update(['email' => $profile->profile->email]);
session()->forget(['sso_state', 'sso_org_id']);
auth()->login($user);
return redirect()->intended(route('dashboard'));
}
}
The getProfileAndToken return object isn't the most intuitively named thing in the world — the profile is nested under ->profile. You'll hit that the first time and spend two minutes in the source code. Now you don't have to.
For provisioning the Admin Portal link (so your customer's IT admin can configure their own IdP connection without involving you):
use WorkOS\Portal;
public function adminPortalLink(Organization $org)
{
abort_if(empty($org->workos_organization_id), 404);
$link = Portal::generateLink(
organization: $org->workos_organization_id,
intent: 'sso',
);
// This URL is single-use and expires. Don't cache it.
return response()->json(['url' => $link->link]);
}
You embed that link in your app's settings UI and your customer's admin clicks through to configure Okta, Azure AD, whatever — without you being on a Zoom call explaining what an ACS URL is. That alone saved me probably six hours on the healthcare project.
The Gotchas That Bit Me
State validation is your problem. WorkOS doesn't validate the OAuth state parameter for you — that's on your implementation. The code above uses session() for it. In a load-balanced environment with sticky sessions disabled, that'll break. Use the cache driver backed by Redis, or sign the state and pass it through the URL. Don't skip this; CSRF on your auth callback is a real attack surface.
WorkOS organizations are not your organizations. WorkOS has its own concept of an "organization" that maps to a connection. You have to maintain the mapping between your organization records and WorkOS's organization IDs. It's not automatic. I keep workos_organization_id and workos_connection_id columns on my organizations table and populate them when I provision the connection through the WorkOS dashboard or API.
The profile ID is stable, but email is not. I've seen enterprise IdPs where users get new email addresses after an org rename or acquisition. If you use email as your primary join key between WorkOS profiles and your user table, you'll create duplicate accounts. Use profile->id as the stable identifier (that's what workos_profile_id is in my example above) and keep the email updated on each login.
Attribute mapping is the customer's responsibility. WorkOS will tell you what came out of the IdP assertion, but whether their Okta is sending email or mail or userPrincipalName is their Okta admin's configuration problem. The Admin Portal helps, but I've still had a customer's IT team spend half a day figuring out why first/last name fields were blank. Budget time for that handoff.
The free tier limit is per-connection, not per-user. WorkOS is free until you start using it with paying customers, at which point you're paying per SSO connection per month. For a multi-tenant SaaS, that's per organization, not per seat. Make sure that math works in your pricing model before you're deep in the integration. It worked fine for my client — they charge enterprises enough that $125/connection/month is noise — but if you're selling to SMBs it's a different conversation.
Local development needs a real redirect URI. WorkOS's callback has to be an actual URL they can redirect to. I use expose or ngrok during development. localhost won't work in the OAuth flow unless you configure it explicitly in the WorkOS dashboard, and even then it's fiddly.
When I'd Reach for This
WorkOS is the right tool when:
- You're selling B2B SaaS and enterprise SSO is a sales requirement
- You don't have months to become a SAML expert
- Your customers' IT admins expect a self-service configuration experience
- You want to add Directory Sync (SCIM) later without re-architecting — WorkOS handles that too and the integration pattern is almost identical
I'd skip it when:
- You're building consumer software. WorkOS is priced and designed for enterprise B2B. For consumer auth I'd reach for something else entirely.
- Your "enterprise" customers are small enough to just use Google OAuth or GitHub OAuth. Don't pay for SSO infrastructure you don't need.
- You need deep custom claims logic or complex ABAC policies baked into the auth layer. WorkOS gets you the profile and raw attributes; complex authorization is still your application's problem.
- You're on a very tight budget and your enterprise deals are small. Do the connection-cost math first.
I also want to be honest: WorkOS is not the only option here. Auth0 Enterprise Connections and Okta's B2B identity products exist. I've used both. WorkOS feels more purpose-built for the "I just need my app to speak SAML to enterprise customers" use case — less framework, more sharp tool. Auth0 has more features and more complexity; whether that's good or bad depends on your situation.
The Bottom Line
SSO used to be a two-week project that turned into a four-week project the first time you hit a customer whose Okta was misconfigured in a creative way. WorkOS got that down to a few days of real work, and the Admin Portal genuinely offloads the customer-side configuration. It's not magic — you still own user provisioning, session management, and the inevitable IT admin support — but it's the right level of abstraction for a small team shipping a B2B product.
Need help shipping something like this? Get in touch.