Azure AD, Google Directory, and SCIM: picking a user-sync story for a multi-tenant Laravel app

Late 2024 I spent a few weeks digging into how a multi-tenant Laravel platform I was working on should let tenant administrators pull users in from external identity providers. The customer asks were predictable — “we use Azure,” “we use Google Workspace,” “can you just hook into our directory?” — and the answer turned out to be more interesting than the question. After looking at Azure Active Directory, Google Directory, and the System for Cross-domain Identity Management (SCIM) protocol, we landed on SCIM as the primary path, with the two cloud-directory options reduced to footnotes. 🐳

This post is a tidied-up version of the investigation notes. If you’re picking a user-sync mechanism for a Software-as-a-Service (SaaS) app and the customer is pointing at one of these three things, the trade-offs below might save you a week.

Why not just speak LDAP to Azure AD directly?

Azure Active Directory (Azure AD) is Microsoft’s cloud identity service — it sits behind Office 365, handles sign-in for Microsoft cloud apps, and is what enterprise customers usually mean when they say “our directory.” The instinct, coming from a traditional on-prem world, is to point an Lightweight Directory Access Protocol (LDAP) client at it and start browsing users.

You can’t. Azure AD does not natively speak LDAP. What it offers instead is one of three pictures, depending on the customer’s deployment:

  1. On-prem AD synced to Azure AD via Azure AD Connect. Your server can speak LDAP to the on-prem Active Directory box, the way it always has. Azure AD is just a downstream replica used for cloud sign-in. The authoritative data still lives on-prem.
  2. Pure cloud Azure AD with no LDAP at all. No LDAP endpoint exposed, anywhere. You either talk to it via the Microsoft Graph REST API, or you don’t talk to it.
  3. Azure AD with Azure AD Domain Services (Azure AD DS) enabled. This spins up a separate managed domain in the cloud that does support LDAP. It’s a paid feature, and it’s a new domain rather than a view into the existing one — the customer would have to decide to migrate into it.

For our app to “just work” with an existing LDAP browser, the customer needed to be in case (1) or (3). Plenty of enterprise customers aren’t — they’re cloud-first, with no on-prem AD and no Domain Services subscription. For those, LDAP is simply not an option, and the realistic alternative is Microsoft Graph.

Graph is a fine API, but adopting it as a sync source means real development work: capture tenant ID, client ID, client secret, and the consented permission scopes (User.Read.All, Directory.Read.All); add a Create-Read-Update-Delete (CRUD) interface for those settings; bring in something like composer require microsoft/microsoft-graph; build the sync loop. None of it is exotic, but it’s all Azure-specific code we’d then have to write again for the next vendor.

One other footnote worth knowing: Azure AD’s free tier covers basic Graph reads, but stress-testing 20,000 users will hit throttling quickly. Azure AD Connect is free with any Azure subscription; Azure AD Domain Services is a premium feature with its own line item. The cost picture is benign for development, less benign for serious load testing.

Google Directory: same destination, different road

Google Directory is the directory layer of Google Workspace — same job as Azure AD, different ecosystem. It manages user accounts, groups, and devices for Workspace tenants and handles sign-in to Gmail, Drive, and the rest.

And just like Azure AD’s cloud-only mode, Google Directory does not speak LDAP. There is no LDAP browser story here at all — no equivalent of Azure AD Domain Services that opens an LDAP port. The only programmatic access is the Admin SDK REST APIs. So whatever LDAP-based extension you’ve been using on the Active Directory side (in our case directorytree/ldaprecord-laravel, which is genuinely lovely for AD work) is just dead weight here.

The Google API client for PHP (composer require google/apiclient) covers the API surface, and the call pattern is similar to Microsoft Graph: OAuth2 service-account credentials, scoped permissions, paginated list endpoints. The schema mismatch is a small extra annoyance — fields we care about, like “manager email” or “team lead,” aren’t always populated in a default Workspace setup, and the customer may need to extend their directory schema via the Admin SDK before our sync sees anything useful.

Cost-wise: Google Workspace doesn’t have a long-term free tier, just a 14-day trial. For development, that’s enough to wire things up; for sustained QA, someone has to pay.

So now we have two cloud directories that don’t speak LDAP, each with its own REST API, its own auth model, and its own schema quirks. If we want to support both, we write the integration twice. This is the moment SCIM starts looking obviously better.

SCIM: the protocol that lets the identity provider do the work

SCIM (System for Cross-domain Identity Management) is a standard for provisioning users between systems. The relevant Request for Comments is RFC 7643 (core schema) plus RFC 7644 (protocol). The pitch, in one sentence: your app exposes a small REST API in a fixed shape, and the customer’s identity provider pushes user changes to it.

That inversion of control is the whole point. Instead of our app polling Azure for users, then polling Google for users, then polling Okta for users — three different APIs, three different auth dances, three different schemas — Azure, Google, and Okta all push the same SCIM-shaped requests to the same endpoint on our side. We write the receiver once. The vendors compete to be good SCIM clients; we just have to be a correct SCIM server.

The terminology is worth getting straight, because it’s a bit counter-intuitive:

  • The identity provider (Azure AD, Google, Okta) is the SCIM client — it initiates requests.
  • Our application is the SCIM service provider — it receives them.

“Client” feels like it should be the consumer, but in SCIM the client is the pusher. Just memorise it; it’ll come up.

After a conversation with a couple of teammates in early December, we settled on SCIM as the path forward, with Azure AD Graph and Google API integrations parked as “maybe later, if a customer specifically asks.” Below is the shape of what we actually built. 🛠️

What the SCIM receiver needs to expose

For Laravel, the arietimmerman/laravel-scim-server package gives you most of the SCIM endpoint scaffolding for free — base routes, schema discovery, the right error envelopes. Standing it up takes an hour. Making it actually map to your domain takes much longer, because every SCIM server eventually becomes opinionated about what the incoming attributes mean.

The endpoints we needed:

  • /api/scim/v2/ — service root, returns capability metadata.
  • /api/scim/v2/Users — supports HTTP POST (create), GET (read/search), PATCH or PUT (update), DELETE (deprovision). A DELETE doesn’t actually nuke the user; it flips their status to suspended, same as our existing LDAP-based flow did.
  • /api/scim/v2/Schemas — schema discovery. The library generates this from your model definitions.
  • /api/scim/v2/Groupsdeliberately left out. Our internal group model doesn’t line up well with SCIM’s, and forcing the mapping would have been more painful than asking customers to manage group membership in-app.

Because the platform was multi-tenant — Stancl’s tenancy library, one database per tenant — we created a fresh route file routes/tenant_api.php rather than co-mingling SCIM with the central admin API. The controller lives at app/Http/Controllers/Tenant/Api/SCIMUserController.php, with the messier translation logic factored into app/Services/Tenant/ScimService.php. Vanilla shape, but worth being explicit because the SCIM library defaults assume a single-tenant Laravel app.

Auth tokens via Sanctum and Jetstream

The SCIM client side (Azure, Okta, etc.) expects a long-lived bearer token. We already had Jetstream + Sanctum wired up for the app, so the natural move was to let tenant admins mint API tokens from their profile page, with a specific SCIM ability scoped on each token. Sanctum handles the storage, expiry, and revocation; Jetstream handles the management UI; we just had to make sure the tokens lived in the tenant database rather than the central one, and reword the permissions checklist so the SCIM ability was discoverable.

The flow from an admin’s perspective:

  1. Open profile page → create API token → name it “Azure SCIM” or similar → tick the SCIM permission.
  2. Copy the one-time-displayed token.
  3. Paste it into the SCIM client configuration in Azure / Okta / Google, alongside the SCIM base URL (which the in-app guide page displays with a copy button).

The “shown only once” pattern is Sanctum’s default and it’s the right one — but you do have to write a sensible warning into the regeneration flow, because admins will absolutely lose the token and try to regenerate, and you want them to understand that the old token stops working the moment they do.

Schema: just two tables

The data model is unglamorous. Sanctum brings its own personal_access_tokens table (publish it with php artisan vendor:publish –provider=”Laravel\Sanctum\SanctumServiceProvider”), and we added one extra table to capture SCIM-specific overflow.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TABLE personal_access_tokens {
  id              INT        [pk, INCREMENT]
  tokenable_type  VARCHAR
  tokenable_id    INT
  name            VARCHAR
  token           VARCHAR    [UNIQUE]
  abilities       text
  last_used_at    TIMESTAMP
  expires_at      TIMESTAMP
  created_at      TIMESTAMP
  updated_at      TIMESTAMP

  indexes {
    tokenable_type_tokenable_id_index [tokenable_type, tokenable_id]
  }
}

The companion table stores everything that SCIM tells us about a user that we don’t have a first-class column for. Phone numbers, employee numbers, alternate addresses, locale — none of that is in our core users schema, but the SCIM contract is that we acknowledge it and return it on a subsequent GET. So we stash it as JSON.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
TABLE scim_users {
  id           BIGINT     [pk, INCREMENT]
  user_id      BIGINT     [NOT NULL]
  extra_fields json       [DEFAULT NULL]
  created_at   TIMESTAMP  [DEFAULT NULL]
  updated_at   TIMESTAMP  [DEFAULT NULL]

  indexes {
    user_id_index [user_id]
  }

  foreign_keys {
    user_id [REFERENCES users(id), ON DELETE cascade]
  }
}

Two tables, one foreign key, no surprises. Most of the complexity is in the controller layer, not the schema. 💡

The “no vendor actually batches” surprise

RFC 7644 defines a /Bulk endpoint for batch operations. I built scaffolding for it on day one, assumed it would be the hot path for the initial onboarding sync, and started planning a queued-job pipeline to handle the load.

Then I actually watched Azure AD push 50 users.

It sent 50 individual POSTs to /Users, each followed by a GET to confirm the new resource existed. No /Bulk call. Okta does the same. Google Workspace does the same. None of the major SCIM clients actually use the bulk endpoint, despite it being in the spec, because their internal architectures are already issuing one provisioning event per user and there’s no operational benefit to coalescing them. So we ripped out the batch scaffolding and the queued-job pipeline, and treated user creation as a straightforward sequential operation that returns the SCIM-shaped “user created” envelope synchronously. Much simpler. 🎉

Related simplification: we don’t need to keep our own sync logs. The customer’s SCIM client (Azure, Okta, Workspace) keeps a detailed provisioning log on its side, including every error response we return. Building a duplicate log on our side would have been busywork the customer would never look at.

The dev-tunneling problem

One genuinely annoying problem: how does Azure reach a SCIM server running on a developer’s laptop?

For an external-facing customer-installed app this isn’t an issue — Azure hits a public URL. For local development, you need a tunnel. A teammate suggested Laravel Expose, which is a nice piece of software in principle: it gives your local app a public HTTPS URL via a relay server, exactly what we needed. On their laptop it worked perfectly. On mine, the Vite-served UI elements rendered partially, page loads were broken, and SCIM requests kept timing out for reasons I never fully diagnosed. We worked around it for a while by sharing their laptop as the integration-test environment, but it’s the kind of friction you want to remove before more developers join the team. Cloudflare Tunnel and ngrok are the obvious alternatives if Expose doesn’t behave.

For staging, the equivalent question is “how does Azure reach our staging server?” In our case staging was behind the company training Virtual Private Network (VPN), which Azure obviously can’t see. The answer turned out to be straightforward — once the staging server got a real public DNS name and a public-facing route, Azure could talk to it like any other SCIM endpoint. The VPN was incidental to the staging architecture, not load-bearing.

The IP-restriction question

Worth mentioning because it’ll come up the moment a security-conscious customer reviews your SCIM setup: can we restrict the SCIM endpoint to specific source Internet Protocol (IP) addresses?

SCIM doesn’t require it, and arguably doesn’t want it — the protocol assumes the customer is the one initiating requests, and the bearer token is the security boundary. But Azure AD’s outbound IPs do change over time, and some customers will still ask for an allowlist out of habit. Our position was: not yet, ask us again when a customer makes it a deal-breaker. If we do implement it, it goes at the reverse proxy / firewall layer, not inside the app — keeping the controller agnostic about source IPs is the right call.

What I’d tell past-me

Three things, condensed:

  1. If the customer has any plurality of identity providers, SCIM is the answer. Writing one Azure Graph integration is fine. Writing Azure + Google + Okta is a maintenance tax that compounds. SCIM lets each vendor be responsible for translating its directory into a common shape on the wire.
  2. Build the receiver, skip the bulk endpoint, skip your own sync logs. Vendors push one user at a time and keep their own logs. Anything past that is over-engineering.
  3. The dev-environment story is half the work. Local tunneling, staging public reachability, token rotation flows, and “show this token once” UI are the parts that don’t appear in the protocol spec but absolutely show up in onboarding friction. Plan for them up front.

For a tenant-aware Laravel app, the actual code footprint of SCIM is small: a route file, a controller, a service, two tables, a UI page or two, and a Sanctum ability. The conceptual footprint — getting comfortable with “the customer’s directory is in charge, we just listen” — is the bigger shift, and the one that pays off across every future identity-provider integration. 🔐

This entry was posted in Laravel and tagged , , , , . Bookmark the permalink.

Comments are closed.