Home Tenant Discovery: The Edge Lookup Layer with Cloudflare Workers and KV
In part one of this series I built a demonstration of multi-tenant routing using Auth0's Advanced Custom Universal Login (ACUL), with the email lookup handled directly inside the login-id screen. The lookup itself was a hardcoded Next.js route handler — sufficient for the demo but not a production architecture. In this post I will replace that with a Cloudflare Worker backed by Workers KV, move from domain-level routing to full email-address routing, handle the edge case of a user who exists across more than one tenant, close the loop with an Auth0 Post-User Registration Action that writes new users to the store at signup, and add Cloudflare Turnstile bot detection to both ACUL screens.
Before diving in it is worth clarifying the terminology. This pattern is often called Home Realm Discovery (HRD), a term borrowed from federated identity standards where the "realm" is an identity domain within a single provider. What we are building here is subtly different: we are routing users between separate Auth0 tenants — different regional deployments or business units, each with their own user store and configuration. Home Tenant Discovery is a more accurate description of that problem.
The steps we will cover in this post are as follows.
- Replacing the hardcoded lookup with a Cloudflare Worker and Workers KV
- Moving from domain-level routing to full email-address routing
- Handling users who exist in multiple tenants — the picker flow
- Writing new users to KV via a Post-Registration Action
- Adding Cloudflare Turnstile bot protection to the ACUL screens
- Challenges encountered
The Cloudflare Worker and Workers KV
The original lookup was a Next.js route handler with a hardcoded Record<string, LookupMatch[]> map. It worked, but the mapping lived in application code rather than a store, which means adding or removing users requires a redeployment, and there is no way to update it at runtime.
Workers KV is a globally distributed key-value store with reads served from Cloudflare's edge network — the same infrastructure that handles the rest of our Cloudflare deployment. Lookup latency is sub-30ms in most regions, and writes from the Auth0 Action propagate globally within a few seconds. Creating the namespace takes one command:
wrangler kv namespace create EMAIL_TENANT_MAPThe Worker itself is straightforward. It handles three methods on the same URL: POST for lookups (called by the ACUL screen), PUT for writes (called by the Auth0 Post-Registration Action), and GET for listing all entries (used by the admin viewer we will come to later).
// POST — lookup by full email, fall back to domain
const email = (body.email ?? '').toLowerCase().trim();
const domain = '@' + (email.split('@')[1] ?? '');
const matches =
await env.EMAIL_TENANT_MAP.get<LookupMatch[]>(email, 'json') ??
await env.EMAIL_TENANT_MAP.get<LookupMatch[]>(domain, 'json') ??
[{ connection: 'blue-connection', theme: 'blue', label: 'Blue Corp (default)' }];The PUT endpoint validates an X-Worker-Secret header before performing any write, and the secret is stored as a Worker secret rather than an environment variable so it never appears in source code or deployment logs.
Full Email-Address Routing
The part one implementation routed on email domain only — everyone at blue-corp.com went to the Blue Corp tenant. That is sufficient for a greenfield multi-tenant deployment where each company has its own domain and no crossover is expected. It is not sufficient for the migration scenario, where two users at the same company domain may be on different sides of a phased cutover.
The solution is a two-level lookup: try the full email address first, then fall back to the domain. The KV store holds entries at both levels:
| Key | Value |
|---|---|
alice@blue-corp.com | [{blue-connection}] |
@blue-corp.com | [{blue-connection}] |
both@dem0-corp.com | [{blue-connection}, {green-connection}] |
@dem0-corp.com | [{blue-connection}, {green-connection}] |
For a migrated user, you would add an individual email entry pointing at the new Auth0 tenant and leave the domain entry pointing at the legacy IdP. A user who has not yet been migrated resolves via the domain fallback to the legacy system; a user who has been migrated resolves via their specific email record to Auth0. No redeployment is required to move a user — a single KV write at the moment of migration is enough.
The lookup API response shape also changed from part one. Rather than returning a single { connection, theme, label } object, it now returns { matches: LookupMatch[] }. This is what enables the picker flow.
The Multi-Tenant Picker
For most users the matches array has one entry and the ACUL screen routes them silently. When a user exists in more than one tenant — for example during a period where their account has been provisioned in a new system but not yet decommissioned in the old one — matches has two entries. Rather than guessing, the screen replaces the email form with a picker:
if (matches.length > 1) {
setLoading(false);
showPicker(matches, email, currentTheme, loginId);
return;
}The picker renders a button per tenant, each styled with that tenant's brand colours. Selecting a matched tenant on the correct domain calls loginId.login() directly; selecting a tenant on the other domain triggers the cross-domain redirect introduced in part one, passing the email as login_hint so the identifier step is pre-filled at the destination.
This is worth demonstrating to customers because it is the exact scenario that occurs during a live migration: the migration team has created the new Auth0 account but the user still has an active session in the legacy system. The picker makes the choice explicit rather than silently breaking the user's existing access.
Closing the Loop: Post-Registration Action
The KV store is seeded manually for existing users, but new registrations after the system is live need to self-populate. An Auth0 Post-User Registration Action fires after each successful signup and writes the user's full email address to KV via the Worker's PUT endpoint:
exports.onExecutePostUserRegistration = async (event) => {
const email = event.user.email;
if (!email) return;
const connection = event.connection.name;
const theme = connection.startsWith('blue') ? 'blue' : 'green';
await fetch(event.secrets.LOOKUP_WORKER_URL, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-Worker-Secret': event.secrets.WORKER_WRITE_SECRET,
},
body: JSON.stringify({
email: email.toLowerCase(),
connection, theme,
label: theme === 'blue' ? 'Blue Corp' : 'Green Corp',
}),
});
};The Action is non-blocking — it wraps the fetch in a try/catch and logs failures without re-throwing, so a KV write error does not cause the registration itself to fail. The Action is bound to the post-user-registration trigger and deployed via the Auth0 CLI:
auth0 actions deploy <action-id> --tenant your-tenant.auth0app.com --no-inputOnce this is in place, the KV store is self-maintaining: every new registration writes its own routing entry, and no manual seeding is needed for users who sign up after go-live.
Cloudflare Turnstile Bot Protection
The lookup endpoint is unauthenticated and publicly reachable, which makes it an attractive target for automated enumeration even when the default-routing mitigation described in part one is in place. Cloudflare Turnstile provides a managed bot challenge that integrates cleanly with both the ACUL screens and the Worker.
Setting up the widget takes three steps: create the widget via the Cloudflare API to get a sitekey and secret, deploy the managed siteverify Worker, and add the widget to both ACUL screens. The widget is rendered using data-size="flexible" so it stretches to match the width of the form fields:
<div class="cf-turnstile"
data-sitekey="<sitekey>"
data-action="turnstile-spin-v1"
data-theme="light"
data-size="flexible">
</div>The Turnstile JS SDK is injected into the ACUL page at runtime by the screen script itself — there is no mechanism to add it via Auth0's ACUL head_tags configuration without also having the widget already on the page. On form submit, the screen reads the cf-turnstile-response token, verifies it against the siteverify Worker, and only proceeds with the lookup or credential submission if verification passes. The integration fails open on network errors: if the siteverify Worker is unreachable, the form proceeds anyway rather than blocking legitimate users.
Challenges Encountered
wrangler kv key put defaults to local. Running wrangler kv key put without the --remote flag writes to a local Miniflare KV instance rather than the live Cloudflare namespace. The keys appear to be written successfully but do not exist when the deployed Worker queries them. The fix is to always pass --remote when seeding production data.
workers.dev subdomain is not auto-provisioned. Deploying a Worker for the first time to a new Cloudflare account requires the workers.dev subdomain to be registered first. wrangler deploy will prompt for this interactively but fail in non-interactive mode. The subdomain can be registered via the Cloudflare API before the first deploy:
curl -X PUT "https://api.cloudflare.com/client/v4/accounts/<account_id>/workers/subdomain" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"subdomain": "<your-chosen-subdomain>"}'Turnstile widget width. By default, Turnstile renders at a fixed 300px regardless of its container width. The data-size="flexible" attribute overrides this, making the widget expand to fill its container. Without it, the widget appears narrower than the input fields above it.
Eye icon vertical centering. The password screen includes a show/hide eye icon positioned inside the input field using position: absolute; top: 50%; transform: translateY(-50%). This works correctly only if the wrapper <div> has no extra height beyond the input itself. Placing margin-bottom on the input rather than its wrapper adds to the wrapper's total height, causing top: 50% to resolve below the input's visual centre. Moving the margin to the wrapper div corrects the alignment.
Vercel CLI vercel env add preview requires a branch name. The current Vercel CLI (54.x) returns an action_required error when adding a preview environment variable without specifying a branch, even when the intent is to apply it to all preview branches. The workaround is to add preview variables via the Vercel REST API directly rather than the CLI.
Conclusion
The full source for this demo is available upon request. The live demo is at tenant-routing-demo.vercel.app. The admin viewer at /admin/kv shows the current KV routing table and updates in real time — worth pulling up during a live demo at the moment a new user registers, so the audience can watch the Post-Registration Action write the entry.
Together the two parts of this series cover the full stack of a Home Tenant Discovery implementation: Auth0 ACUL screens handling the in-flight routing decision, a Cloudflare Worker and Workers KV providing the edge lookup store, bot protection on the login screens via Turnstile, and an Auth0 Action keeping the store current as users register. For the part one walkthrough covering the Auth0 ACUL configuration see part one of this series.