In the last post I sketched why SCIM (System for Cross-domain Identity Management) won out over direct Azure Active Directory (Azure AD) and Google Directory integrations for a multi-tenant Laravel app I was working on. This one is the hands-on follow-up: how to actually get Azure pushing user-provisioning events to a Laravel application running on your laptop, for free. 🐳
The shape of the setup: open a port on your home router, point a free dynamic-Domain-Name-System (dynamic-DNS) hostname at it, and run Laravel Sail behind that hostname. It mirrors the production push flow closely enough to be a useful test rig, gives you a real Azure portal to point at when you’re tuning attribute mappings, and costs nothing beyond the hour or so of one-time setup.
Why not just use Laravel Expose?
Laravel Expose is the obvious answer — it relays public HTTPS requests to a process on your machine, with a friendly Laravel-shaped Command-Line Interface (CLI). On the free tier it works fine for a single-host app. The wrinkle for us was multi-tenancy: the app routes by subdomain, and every tenant lives at tenantname.app.example.com. To exercise that locally, you need a tunnel that gives you a wildcard subdomain, not just a single hostname.
Expose’s wildcard-subdomain feature is paywalled — $60 USD per year plus tax, which lands at roughly $96 once it clears the border. That’s not a lot of money, but it’s enough to think twice when you remember you’ve already got a static-ish home Internet Protocol (IP) address and a router with a port-forwarding screen. So I bypassed it.
Step 1: poke a hole through your home router
Most consumer routers have a “port forwarding” or “service hosting” section. Pick a public port on the Wide-Area-Network (WAN) side and point it at your laptop’s Local-Area-Network (LAN) IP on the port your Laravel Sail container listens on (port 80 by default inside the Sail web container, exposed however your docker-compose.yml maps it — often 80 or 8000 on the host).
One Internet Service Provider (ISP)-specific gotcha that bit me, and might bite you: some ISPs silently block inbound port 80 to residential connections. They don’t explicitly tell you this on the router admin page; the port-forward rule will save happily and then silently drop every connection. The fix is to forward a non-80 port like 8080 instead — most ISPs allow that, and Azure doesn’t care whether your SCIM endpoint lives at port 80 or 8080, since the Tenant Uniform Resource Locator (URL) field lets you specify the port explicitly. If your forward “works” on the router but a phone on cellular data can’t reach it, suspect a residential port-80 block.
Test from outside your network — phone with WiFi off is the easy version:
1 2 | # from your phone or any external machine curl -v http://<your-public-ip>:8080/ |
If you see your app’s HTML, the tunnel is open. If you get connection-refused or a timeout, it’s either the ISP, the router firewall, the OS firewall, or the wrong LAN IP — work through those in order.
Step 2: a hostname that isn’t your raw IP
Azure will happily accept an IP-address Tenant URL, but the multi-tenant subdomain routing in the app needs a real Domain Name System (DNS) name. no-ip.com gives you a free dynamic-DNS account: you sign up, pick a hostname from one of their domains (mine ended up on redirectme.net, but they have several to choose from), and either run a tiny daemon on your machine or update the IP via their web form whenever your home IP changes.
So now you have something like myapp.redirectme.net pointing at your home IP, and port 8080 on that IP forwarding to your laptop. Putting http://myapp.redirectme.net:8080/scim/v2 into the Tenant URL field on Azure works the same way it would if you were running on a cloud server. The dev-vs-prod difference is mostly: less uptime, and you have to remember to keep the laptop awake during a provisioning test. 💡
For the multi-tenant subdomain wrinkle, you also need per-tenant hostnames. The pragmatic shortcut is to set up one tenant whose domain matches your no-ip hostname exactly. So if your no-ip hostname is myapp.redirectme.net, you want a tenant in your database keyed to that domain. Two small code changes accomplish this.
Step 3: point your Laravel app at the public hostname
Two files need to know about the new hostname. First, .env:
1 | APP_URL=http://myapp.redirectme.net |
Second, your tenant seeder. If you’re using Stancl’s tenancy library (we were), there’s typically a manual-test seeder somewhere like database/seeders/Tenant/ManualTestSeeder.php that creates your fixture tenant and assigns it a domain. Change that domain to match the no-ip hostname:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | class ManualTestSeeder extends Seeder { private $testTenantId = 'test_tenant_one'; private $testTenantDomain = 'myapp.redirectme.net'; private $testTenantAdminEmail = 'test_user1@localhost.com'; public function run() { $tenant = Tenant::create([ 'id' => $this->testTenantId, ]); $tenant->domains()->create(['domain' => $this->testTenantDomain]); // ... seed an admin user, an API token, etc. } } |
Re-seed, then sanity-check that the app loads at http://myapp.redirectme.net:8080/ from a browser on a network that isn’t yours. If you see the tenant’s landing page rather than the central-app landing page, the subdomain routing is doing the right thing.
Step 4: the Azure portal walkthrough
Now to the Azure side. Sign in at portal.azure.com. A work account on Office 365 works; a personal Microsoft account (Hotmail / Outlook / Live) also works, since Azure gives you a free tenant attached to your consumer identity. Either way, you land on the Azure home page with a row of service tiles. The walkthrough is nine clicks.
- Click “Enterprise applications”. This is the catalog of apps that have been onboarded into your Azure AD tenant. You’re going to add a new one that represents the Laravel app.
- “+ New application” → “Create your own application”. A side panel slides in asking what you’re integrating. Pick “Integrate any other application you don’t find in the gallery (Non-gallery)”. The gallery is for vendors who pre-registered their Enterprise application templates with Microsoft; yours obviously isn’t there.
- Name your app. Whatever you like. I used scimtesterapp3 because I’d already created and torn down two others while figuring this out. The name is just a label inside the Azure tenant.
- Go back to the Azure home page → click “Users”. This is the directory of users in your Azure AD tenant. Out of the box, you have one user — yourself — and you’ll need at least one more to provision through SCIM. Self-provisioning the owner is a special case and won’t exercise the create-user path.
- “+ New user” → “Create new user”. Fill in the Basics tab (user principal name, display name, mail nickname, a password — none of these will ever be used to actually sign in, so don’t sweat the password complexity). Then the Properties tab: email, first name, last name, job title, company name, department, manager. The properties matter because they’re what Azure will hand to your SCIM endpoint when you provision. I usually create someone called Alice Smith (alicesmith@example.com), give her a job title like “Secretary,” a department, and a manager — enough fields populated that the SCIM payload looks realistic.
- Confirm the user shows up in the Users list. Two entries now: you, and Alice.
- Back to Enterprise applications → your app → “Users and groups” → “+ Add user/group”. Pick Alice from the picker and assign her to the application. This is the bit that says, in Azure-speak, “Alice is in scope for this app’s provisioning.” Without this assignment, even a perfectly-configured SCIM connection won’t push her anywhere.
- Open the “Provisioning” blade → “Get started”. Set Provisioning Mode to Automatic. Under Admin Credentials, fill in:
- Tenant URL: your full public SCIM endpoint, including the path. For our app that’s http://myapp.redirectme.net:8080/scim/v2.
- Secret Token: an Application Programming Interface (API) token you generated in the Laravel app’s profile-page token UI, with the SCIM ability checked. (See the prior post for how that’s wired up via Sanctum.)
Hit “Test Connection.” If everything’s right, Azure will get a 200 from your SCIM service-discovery endpoint and confirm that the supplied credentials are authorised. Don’t forget to click Save — Azure’s screen design here is genuinely sneaky, and the Save button is easy to overlook above the form. Without Save, your next step won’t know about the credentials.
- “Provision on demand” → pick Alice → run it. Azure walks through a four-step pipeline: import the user from the source, evaluate scoping rules, look up whether the target already has her, then perform the action (create, in this case). If all four steps come back green, your SCIM endpoint just got a real POST /Users from a real identity provider, and Alice now exists in the Laravel app’s tenant users table. 🎉
Once “Provision on demand” works end-to-end, you can flip the provisioning mode to scheduled and Azure will start pushing changes every 40 minutes or so — but for development the on-demand button is the one you’ll live in, because it gives you a per-step success/failure breakdown and lets you iterate on attribute mappings without waiting for the next cycle.
Quirks worth knowing about
A few things that aren’t bugs exactly, but are worth bracing for:
- Your dev environment vanishes when your laptop sleeps. Port 8080 stops answering, Azure’s next “Provision on demand” attempt fails with a connection error, and you’ll spend thirty seconds confused about what changed. Nothing changed; you closed the lid. I came to see this as a feature, not a bug — it’s a default-deny posture for the rest of the internet when I’m not actively testing.
- Your home IP changes occasionally. Most consumer ISPs hand out “dynamic” IPs that in practice change once every few months. no-ip’s daemon handles this transparently; if you go the manual-update route, expect to update the IP a couple of times a year when SCIM mysteriously stops connecting.
- HTTP, not HTTPS. The setup above uses plain HTTP because port-forwarding to a local TLS-terminating container is fiddly. Azure will accept this for SCIM — it complains in the UI but allows it — and for a local dev box the tradeoff is reasonable, because the only data flowing through is test users you made up. For staging or anything resembling production, terminate Transport Layer Security (TLS) at a proper public host. Don’t be the person who ships an HTTP SCIM endpoint with real customer data on it.
- Azure caches things. When you change attribute mappings in the Provisioning blade, sometimes the next “Provision on demand” run uses the old mapping. Wait a minute, retry, and don’t go diving into your code looking for the bug straight away.
Why this is worth doing
The point of all this is to get a real Azure portal pushing to your code. SCIM has enough vendor-specific edge cases — Azure’s quirks around externalId, the enterprise-extension schema for department and manager, the slight differences between how Azure and Okta encode multi-value attributes — that you really do want to be testing against the actual identity provider, not a stub. Once the tunnel-and-dynamic-DNS scaffolding is in place, the iteration loop is fast: tweak controller code, re-run “Provision on demand,” watch the Azure log and your Laravel log side by side. 🔐
An hour of one-time setup, no recurring cost, and you’ve got a SCIM integration test rig sitting on your dev machine. The next post in this thread will be the equivalent walkthrough for Okta, which differs from Azure in some interesting ways — but the home-network side of the setup stays the same.