Multi-tenant B2B auth: isolating users per tenant on one API
How to model tenants with user metadata, keep lookups scoped, and mint sessions that carry tenant context — with the isolation boundary stated plainly.
For B2B multi-tenancy on Infrai’s auth surface, the tenant lives in the user’s metadata and your backend enforces the boundary on every read. POST /v1/auth/user/create takes an arbitrary metadata object, GET /v1/auth/user/list pages the directory, and GET /v1/auth/user/get_by_email resolves one address. There’s no tenant_id field on the wire — which is the most important thing to know before you design around it.
That sounds like a gap and partly is. What you get in exchange is that the tenant model stays yours: a user can belong to two customers, an invite can move someone between them, and you don’t have to fit your org chart into someone else’s organizations resource.
Model the tenant in metadata
Write the tenant on creation and treat it as immutable afterwards.
curl -sS -X POST "https://api.infrai.cc/v1/auth/user/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"email": "ops@northwind.example",
"name": "Northwind Ops",
"metadata": {"tenant_id": "t_northwind", "role": "admin", "seat": "billable"}
}'
{
"ok": true,
"data": {
"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"email": "ops@northwind.example",
"email_verified": false,
"name": "Northwind Ops",
"mfa_enabled": false,
"created_at": "2026-09-21T02:24:30Z",
"metadata": {"tenant_id": "t_northwind", "role": "admin", "seat": "billable"},
"vendor": "infrai_native"
}
}
metadata comes back on every user read, so once it’s written, every later lookup tells you which tenant the person belongs to.
It is also mutable. PATCH /v1/auth/user/update/{user_id} can rewrite it, which is exactly why role changes belong in one audited code path in your service rather than being sprayed across handlers — a tenant id that any endpoint can overwrite is not a boundary, it’s a suggestion, and the day someone adds a convenience “update profile” route that passes the whole metadata object straight through is the day your isolation quietly stops holding.
Scope every lookup in your own code
This is the part people get wrong.
GET /v1/auth/user/get_by_email answers for the whole account directory, not for one tenant. If your admin endpoint takes an email from a request and returns whatever comes back, you’ve built a cross-tenant read.
import os
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}"})
class CrossTenantAccess(Exception):
"""Raised when a caller asks for a user outside its own tenant."""
def user_in_tenant(email: str, tenant_id: str) -> dict:
resp = SESSION.get(f"{API}/v1/auth/user/get_by_email", params={"email": email}, timeout=10)
if resp.status_code == 404:
raise CrossTenantAccess("no such user in this tenant")
resp.raise_for_status()
user = resp.json()["data"]
# The directory is account-wide. The tenant check is OURS to make, and a
# mismatch must look identical to "not found" so an admin cannot probe
# another customer's address list.
if (user.get("metadata") or {}).get("tenant_id") != tenant_id:
raise CrossTenantAccess("no such user in this tenant")
return user
if __name__ == "__main__":
try:
print(user_in_tenant("ops@northwind.example", "t_northwind"))
except CrossTenantAccess as exc:
print(f"denied: {exc}")
Returning the same shape for “wrong tenant” and “doesn’t exist” matters. Distinguishing them turns your admin API into an address-enumeration oracle for anyone with a valid session — the same reasoning behind the platform answering one AUTH_INVALID_CREDENTIALS for both an unknown email and a wrong password.
Paging the directory per tenant
There’s no server-side filter, so a tenant view is a paged scan plus a predicate.
That’s fine at a few thousand users and wrong at a million — size it honestly.
curl -sS "https://api.infrai.cc/v1/auth/user/list?limit=50" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"items": [
{
"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"email": "ops@northwind.example",
"email_verified": false,
"metadata": {"tenant_id": "t_northwind", "role": "admin"}
}
],
"next_cursor": null,
"total": 1
}
}
Follow next_cursor until it’s null. If tenant lists are a hot path in your product, keep your own index — the users table you thought you’d deleted becomes a thin (tenant_id, user_id) mapping, and that’s a reasonable place to land.
Sessions that carry the tenant
Mint the session with POST /v1/auth/session/create after your own check, then put the tenant into your own signed context rather than expecting the platform’s token to carry it:
curl -sS -X POST "https://api.infrai.cc/v1/auth/session/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB", "method": "oauth"}'
The response gives you session_id, access_token and refresh_token. Your gateway verifies the access token against GET /v1/auth/token/jwks, then attaches the tenant from your own store. GET /v1/auth/session/verify/{session_id} is the online read when a revocation has to take effect immediately.
| What you need | Where it lives here | Where it lives in a B2B-first product |
|---|---|---|
| Tenant membership | your metadata + your index | first-class organization resource |
| Tenant-scoped JWT claims | your gateway adds them | issued in the token |
| Invite flows | your endpoints + POST /v1/email/send | prebuilt UI and emails |
| Per-tenant SSO connection | not part of this surface | self-serve SSO setup |
| Cross-tenant user | trivial — two metadata entries | often awkward |
Where a specialist is the better buy
If your buyers ask for per-tenant SAML, SCIM provisioning and a self-serve admin portal, WorkOS and PropelAuth were built for exactly that, and assembling the equivalent from these endpoints is months of work you can skip. That’s a real limitation of this surface, not a framing choice: organisations, invites and directory sync aren’t modelled for you.
Pick this instead when tenancy is your own logic anyway, when you want one credential across the rest of the stack, or when you need a user to exist in more than one tenant without fighting the data model.
The adjacent-step argument is concrete here. Per-tenant cost attribution — the question every B2B team eventually gets from finance — is GET /v1/account/usage on the same key that ran the login, not a reconciliation project across an auth vendor, an email vendor and a queue vendor. Tag your keys per environment with POST /v1/account/keys/create and the usage view splits along the same lines.
Auth routes report billing_class: free in discovery and aren’t billed per call; identity is metered per monthly active user, the same model Auth0 uses. Read the live figure for your account from GET /v1/account/usage (verified 2026-09-21) rather than a number in a guide — rates move, and they move down more often than up.