Enterprise SSO and SCIM for B2B SaaS: A Pragmatic In-House Implementation Without WorkOS

#enterprise SSO B2B SaaS
Sandor Farkas - Founder & Lead Developer at Wolf-Tech

Sandor Farkas

Founder & Lead Developer

Expert in software development and legacy code optimization

The conversation almost always follows the same script. Your enterprise prospect loves the demo, the champion has budget, the deal is close — then someone from IT security asks: "Do you support SSO?" A week later the same contact emails again: "We also need SCIM provisioning before we can go to legal."

For most B2B SaaS founders, that moment triggers a trip to the WorkOS pricing page. At €2–4 per seat per month, the math looks fine with 50 users. At 2,000 seats, you are paying €48,000–96,000 a year for a middleware layer that sits between your app and your customer's identity provider. That fee compounds every year and comes out of ACV you worked hard to earn.

The good news is that the 80% case — SAML 2.0 SSO with Okta, Microsoft Entra ID (formerly Azure AD), and Google Workspace, plus basic SCIM 2.0 group and user sync — is implementable in-house in two to four weeks of focused engineering. This post walks through how we approach it in Symfony and Next.js stacks, what the real edge cases are, and the two specific scenarios where a managed platform is actually worth the money.

Why Enterprise Buyers Require Enterprise SSO B2B SaaS

Before getting into implementation, it helps to understand what IT security teams are actually protecting when they require SSO. They want a single authoritative source of truth for user access — their Identity Provider (IdP). When an employee leaves the company, IT revokes access in Okta or Entra ID, and that revocation cascades instantly to every connected application. Without SSO, deprovisioning means logging into each SaaS tool individually, which is how access lingers for months after offboarding.

SCIM (System for Cross-domain Identity Management) extends that control to provisioning. Instead of employees self-registering accounts in your app, the IdP pushes user and group data to you via a standardised REST API. IT defines who gets access to what — your app just consumes the instructions.

This is not a nice-to-have for enterprise. It is a hard requirement in procurement checklists, ISO 27001 audits, and SOC 2 Type II controls. If you want to sell to companies with more than 500 employees, you need both.

What SAML 2.0 Actually Involves

SAML 2.0 is an XML-based standard for federated authentication. The flow has three actors: the Service Provider (your app), the Identity Provider (Okta, Entra ID, Google Workspace), and the user's browser, which ferries signed XML assertions between them.

The SP-initiated flow that most enterprise buyers expect works like this:

  1. User visits your app's login page and clicks "Sign in with SSO."
  2. Your app redirects the user to the IdP's SSO URL with an encoded AuthnRequest.
  3. The IdP authenticates the user (or uses an existing session) and returns a signed SAML Response to your app's Assertion Consumer Service (ACS) URL.
  4. Your app validates the signature against the IdP's public certificate, extracts the user attributes from the assertion, and creates or updates the user session.

The XML plumbing looks intimidating but is well-handled by the nbgrp/onelogin-saml-bundle package in Symfony. You configure one SP metadata entry per customer tenant, store the IdP metadata XML (or pull it from the IdP's metadata URL), and the bundle handles the redirect generation and response validation.

In a multi-tenant SaaS, the critical design decision is tenant routing — knowing which IdP configuration to load before you can issue the AuthnRequest. We typically route on a subdomain (acme.yoursaas.com) or on an email domain the user types into a pre-SSO field. The email-domain approach is friendlier for apps that use a single domain, but requires a step before the standard login form.

Setting Up SAML in Symfony

A practical Symfony setup involves four things: the bundle configuration, a SamlConnection entity per tenant, a service that resolves the correct connection from the incoming request, and a controller that handles the ACS callback.

// src/Entity/SamlConnection.php (simplified)
#[ORM\Entity]
class SamlConnection
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private int $id;

    #[ORM\Column(unique: true)]
    private string $emailDomain; // e.g. "acme.com"

    #[ORM\Column(type: 'text')]
    private string $idpMetadataXml;

    #[ORM\Column]
    private string $entityId; // Your SP entity ID for this tenant
}

The bundle routes the ACS callback to a single endpoint, so your controller must look up the tenant from the SAML InResponseTo ID or from a relay state parameter you embed in the AuthnRequest. Store the relay state as a short-lived cache entry (Redis works well) mapping a random ID to the tenant identifier.

One detail that catches teams out: Okta and Entra ID both send NameID in email format by default, but Google Workspace sends a Google-internal persistent identifier. Map NameID to your internal user ID only if you know it will be stable across IdP reconfigurations. Email is more portable, though users can change their email. A dedicated external_idp_id column per user, separate from their email, is the safer model.

SCIM 2.0: The Provisioning Side

SCIM 2.0 is a REST API your app exposes. The IdP calls it to push users and groups. The core endpoints you need to implement are:

  • GET /scim/v2/Users — list users, with filter support (filter=userName eq "jane@acme.com")
  • GET /scim/v2/Users/{id} — fetch a single user
  • POST /scim/v2/Users — create a user
  • PUT /scim/v2/Users/{id} — replace a user record
  • PATCH /scim/v2/Users/{id} — partial update (deactivation typically uses this)
  • DELETE /scim/v2/Users/{id} — deprovision
  • GET /scim/v2/Groups, POST, PUT, PATCH, DELETE — same shape for groups

The SCIM spec (RFC 7644) mandates specific JSON schema for requests and responses. Okta and Entra ID both validate this strictly. The most common failure in initial testing is non-compliant list responses — the ListResponse envelope with totalResults, startIndex, itemsPerPage, and Resources is required even for single-result queries.

Authentication between the IdP and your SCIM endpoint is handled with a bearer token you generate per customer tenant and paste into the IdP admin console. Store it hashed (bcrypt or Argon2, not SHA-256) alongside the SamlConnection entity. This token never leaves your infrastructure except during initial setup.

Implement SCIM as a Symfony API Platform resource or a plain controller — either works. API Platform gives you filter and pagination support nearly for free, which covers the filter parameter that Okta uses heavily during initial sync. If you go plain controller, budget time for building those query transformers by hand.

Just-in-Time Provisioning vs. Full SCIM

Here is where teams save time: JIT provisioning is often good enough for the first year. Instead of implementing the full SCIM API, you create or update a user record at SAML assertion time, using the attributes the IdP sends in the assertion (email, name, department, groups).

JIT is simpler and eliminates the IdP-polling problem, but it has a genuine limitation: deprovisioning is not instant. A terminated employee can still log in until their IdP account is disabled — but if your app checks the SAML assertion on every session renewal, that window is at most as long as your session lifetime. Configure short sessions (4–8 hours) and require re-authentication, and the risk is manageable for most enterprise buyers.

Full SCIM becomes necessary when:

  • The customer requires immediate deprovisioning at the IdP level (financial services, healthcare, government)
  • You need to sync group memberships that drive role-based access control before a user logs in for the first time
  • The customer's IT team wants to manage user access entirely from the IdP without relying on user-initiated logins to trigger provisioning

For SaaS targeting mid-market enterprise (200–2,000 seat deals), JIT first, SCIM later is a defensible sequencing.

Nested Group Sync: The Edge Case That Trips Everyone

Standard SCIM treats groups as flat. Okta pushes group membership as a list of user IDs inside a Group resource. Microsoft Entra ID, however, supports nested groups (a group can contain other groups), and by default pushes only the direct members of each group — meaning your app receives no signal about the transitive memberships your customer's IT team has carefully constructed.

Three approaches to handle this:

Option 1: Flatten at sync time. When you receive a Group PATCH from Entra ID, call back to the Microsoft Graph API to resolve transitive members. This requires an additional OAuth 2.0 Client Credentials token with GroupMember.Read.All. Complex, but gives you the full picture.

Option 2: Ignore nested groups and document the limitation. Tell the customer's IT team to push only flat groups to your application. Most enterprise IT teams are used to this constraint with third-party SaaS.

Option 3: Delegate role mapping to the assertion. Use SAML attribute statements (a groups attribute the IdP populates from group membership) for access control instead of SCIM group sync. This works well for role-based access where you map a specific group name to a role in your app. The IT team manages group membership in the IdP; your app maps the group name to a permission set.

Option 3 is the most pragmatic for the 80% case. Option 1 is the "correct" answer for enterprise buyers with complex org structures who have made nested groups a hard requirement.

When WorkOS Is Actually Worth It

None of this is to say WorkOS is never the right call. Two situations justify the platform fee:

Speed to revenue. If an enterprise deal is closing in three weeks and you do not have any SSO infrastructure, WorkOS gets you unblocked in two days. At €2–4 per seat on a 100-seat initial deal, that is €200–400/month — far less than the engineering cost of a rushed implementation. Build it properly in-house on the next sprint cycle and migrate the tenant off.

Directory sync with attribute mapping complexity. If your product's access model depends on fine-grained attributes synced from Active Directory — job titles, cost centres, manager hierarchies — and your customer base includes large enterprises with non-standard AD schemas, WorkOS's normalisation layer genuinely saves weeks. The €4/seat/month cost becomes defensible when it replaces one-off IT integration work for every new customer.

Outside those two scenarios, in-house is the better long-term investment. You own the data model, you control the compliance story, and you are not paying a tax that grows with your ARR.

Connecting the Next.js Frontend

The SSO flow is largely server-side, but your Next.js frontend needs to handle the pre-SSO email capture step and display appropriate error states when assertion validation fails.

We typically implement an /auth/sso route that renders a minimal form asking for the user's work email. On submit, a Next.js API route calls the Symfony backend to look up the SAML connection for that email domain and returns the IdP redirect URL. The frontend performs a client-side redirect to that URL with the encoded AuthnRequest. This keeps SAML logic entirely in Symfony while giving the Next.js app a clean integration surface.

Error handling deserves attention. SAML errors surface as opaque error codes in the assertion or as HTTP error responses from the IdP. Map them to user-friendly messages: "Your account is not assigned to this application" (Okta error USER_NOT_ASSIGNED), "Your session has expired, please sign in again," and so on. A confusing SSO error at 8 AM when a director is trying to demo your product to a prospect is a support ticket you do not want.

Practical Next Steps

If this is on your roadmap, the work breaks down into four sequential pieces: SAML connection management UI (so your team or customers can configure their IdP), the SP-initiated SAML flow with JIT provisioning, the SCIM endpoint for organisations that require it, and finally nested group handling if a specific customer demands it.

A focused backend engineer can have the first two pieces production-ready in two weeks. SCIM adds another week. Nested group sync is a separate project scoped to the specific IdP and use case.

If your team is stretched thin or you are preparing for an enterprise deal and need this done cleanly the first time, we are happy to take a look. Drop us a line at hello@wolf-tech.io or visit wolf-tech.io to book a short call. We have built this stack for several B2B SaaS products and can scope the work accurately after a 30-minute conversation.

For teams that have already shipped an MVP and are now facing enterprise procurement requirements, our code quality consulting and custom software development services cover exactly this kind of critical infrastructure build-out — including the security review that enterprise buyers will ask for once SSO is in place.