Back
View source
Cloud Engineering··18 min

Azure Auth Series — Blog 5: Multi-Tenant Authentication

Let users from any Azure AD organization sign in. Build dynamic JWKS validation per tenant, a configurable tenant allow-list, and per-tenant data isolation — all while keeping the same RBAC system from Blog 3.

Azure Auth Series — Blog 5: Multi-Tenant Authentication#

In Blogs 14, the app only accepted users from your Azure AD tenant. If someone from another organization tried to sign in, they'd get rejected. That's fine for internal tools, but what about SaaS products? What about apps that serve multiple companies?

In this blog, we'll make the app multi-tenant — users from any Azure AD organization can sign in:

  • 🌐 Authority changes to /organizations — MSAL allows any Azure AD tenant
  • 🔑 Dynamic JWKS validation — each tenant has its own signing keys at a different endpoint
  • 📋 Tenant allow-list — control which organizations can access your app
  • 🏢 Per-tenant data isolation — tasks are nested by tenant ID, not just user ID
  • 🛡️ Admin scope is tenant-bound — Admins see all tasks in their org, never across tenants

The RBAC system from Blog 3 stays intact. The only changes are: how we validate tokens (dynamic per tenant) and how we store data (nested by tenant).


The Azure Auth Series#

BlogTopicWhat You'll Learn
1. Basic LoginFrontend authSign in with Microsoft Entra ID
2. Protected APIBackend authBuild a FastAPI backend that validates tokens
3. RBACAuthorizationControl access based on user roles
4. Managed IdentityZero secretsDeploy to Azure without storing credentials
5. Multi-TenantYou are hereLet users from any org sign in
6. Service-to-ServiceOBO + Client CredentialsAuthenticate services to each other
7. API GatewayAPIMCentralize auth with Azure API Management

What We're Building#

Architecture#

Tenant A user signs in
   → Entra ID issues JWT with tid="aaa-..."
   → JWKS fetched from login.microsoftonline.com/aaa-.../keys

Tenant B user signs in
   → Entra ID issues JWT with tid="bbb-..."
   → JWKS fetched from login.microsoftonline.com/bbb-.../keys

Both tokens validated by the SAME backend
   → Dynamic issuer: sts.windows.net/{tid}/
   → Dynamic JWKS: per-tenant key cache
   → Same audience: api://{your-app-id}
+-----------------------------------------------------------+
|  FastAPI Backend                                          |
|                                                           |
|  1. Peek at unverified token → extract tid                |
|  2. Check tenant allow-list                               |
|  3. Fetch JWKS for that tenant (cached)                   |
|  4. Verify signature + issuer + audience                  |
|  5. Route to tenant-isolated data store                   |
|                                                           |
|  tasks_db = {                                             |
|    "tenant-aaa": {                                        |
|      "user-1": [task, task],                              |
|      "user-2": [task],                                    |
|    },                                                     |
|    "tenant-bbb": {                                        |
|      "user-3": [task, task, task],                        |
|    },                                                     |
|  }                                                        |
+-----------------------------------------------------------+

What Changed from Blog 3/4#

Blog 3/4 (Single-Tenant)Blog 5 (Multi-Tenant)
Authority: /{tenant-id}Authority: /organizations
One JWKS endpointDynamic JWKS per tenant
Hardcoded issuerDynamic issuer from tid claim
No tenant gatingConfigurable allow-list
tasks_db[user_id]tasks_db[tenant_id][user_id]
Admin sees all tasks globallyAdmin sees all tasks in their org
signInAudience: AzureADMyOrgsignInAudience: AzureADMultipleOrgs

Live Demo#

Landing page — "Any Azure AD organization can sign in":

Multi-Tenant Landing The landing page highlights multi-tenant auth, tenant allow-list, and tenant-isolated data

Admin dashboard — Tenant ID visible, "All Tasks in My Organization":

Multi-Tenant Admin Dashboard Admin sees tenant ID in the profile card, and "All Tasks in My Organization" scoped to their tenant


How Multi-Tenant Auth Works#

In single-tenant mode, the MSAL authority is login.microsoftonline.com/{your-tenant-id}. Only users from that tenant can sign in, and the backend validates against one JWKS endpoint and one issuer.

In multi-tenant mode:

1. Frontend MSAL authority: /organizations
   → Any Azure AD user can authenticate

2. User signs in → Entra ID returns a JWT
   The token includes: tid = "their-tenant-id"

3. Backend receives the token
   → Peek at unverified claims to get tid
   → Check: is this tenant on the allow-list?
   → Fetch JWKS from that tenant's endpoint
   → Verify with dynamic issuer

4. Data is stored under the tenant's namespace
   → Tenant A can never see Tenant B's data

Why can't we use one JWKS endpoint? Each Azure AD tenant has its own signing keys. A token from Tenant A is signed with Tenant A's private key. To verify it, you need Tenant A's public key from Tenant A's JWKS endpoint.


Step 1: App Registration — AzureADMultipleOrgs#

The setup script creates both app registrations with multi-tenant audience:

File: setup.sh (key difference)

# Single-tenant (Blog 3/4):
az ad app create --display-name "Blog3-API"
# signInAudience defaults to AzureADMyOrg

# Multi-tenant (Blog 5):
az ad app create \
  --display-name "Blog5-MT-API" \
  --sign-in-audience AzureADMultipleOrgs

Both the API and SPA registrations use AzureADMultipleOrgs. This tells Entra ID to issue tokens for users from any Azure AD tenant, not just your own.

The App Roles (Admin/Editor/Reader) are defined the same way as Blog 3. Role assignments work per-tenant — each tenant's admin assigns roles to their users on the service principal in their tenant.


Step 2: Frontend — Authority /organizations#

One line changes in the MSAL config:

File: frontend/src/config/auth-config.ts

export const msalConfig: Configuration = {
  auth: {
    clientId: process.env.NEXT_PUBLIC_AZURE_CLIENT_ID || "",
    authority: "https://login.microsoftonline.com/organizations",
    redirectUri: "/",
    postLogoutRedirectUri: "/",
    navigateToLoginRequestUrl: false,
  },
  // ... cache and logger config unchanged
};
AuthorityWho Can Sign In
/{tenant-id}Only users from that specific tenant
/organizationsAny Azure AD work/school account
/commonAzure AD accounts + personal Microsoft accounts
/consumersPersonal Microsoft accounts only

We use /organizations (not /common) because App Roles only work with Azure AD organizational accounts, not personal accounts.

No NEXT_PUBLIC_AZURE_TENANT_ID needed — the frontend doesn't pin to any tenant. The tenant ID comes from the token at runtime.


Step 3: Backend Config — Tenant Allow-List#

File: backend/config.py

import os
import logging
from dotenv import load_dotenv

load_dotenv()

logger = logging.getLogger(__name__)

# Home tenant (your own Azure AD tenant — used for setup reference only)
HOME_TENANT_ID = os.getenv("AZURE_HOME_TENANT_ID", "")

# API app registration (same client ID works for all tenants)
API_CLIENT_ID = os.getenv("AZURE_API_CLIENT_ID", "")
AUDIENCE = f"api://{API_CLIENT_ID}"

# ── Tenant Allow-List ────────────────────────────────
# Comma-separated list of tenant IDs that are allowed.
# Set ALLOW_ANY_TENANT=true to skip the check.

ALLOW_ANY_TENANT = os.getenv("ALLOW_ANY_TENANT", "false").lower() == "true"

_raw_tenants = os.getenv("ALLOWED_TENANT_IDS", HOME_TENANT_ID)
ALLOWED_TENANT_IDS: set[str] = {
    t.strip() for t in _raw_tenants.split(",") if t.strip()
}

if ALLOW_ANY_TENANT:
    logger.info("ALLOW_ANY_TENANT=true — accepting tokens from any Azure AD tenant")
else:
    logger.info("Allowed tenants: %s", ALLOWED_TENANT_IDS)

# ── CORS ─────────────────────────────────────────────

ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:3000")

Two modes:

ModeConfigBehavior
Allow-listALLOWED_TENANT_IDS=aaa,bbbOnly listed tenants can access
OpenALLOW_ANY_TENANT=trueAny Azure AD org can access

The .env file starts with only your home tenant allowed. To onboard another organization, add their tenant ID to ALLOWED_TENANT_IDS.


Step 4: Backend — Dynamic Token Validation#

This is the core change. The validate_token function now handles tokens from any tenant:

File: backend/auth.py

from config import AUDIENCE, ALLOWED_TENANT_IDS, ALLOW_ANY_TENANT

# ── Per-tenant JWKS cache ────────────────────────────
# In single-tenant we cached one JWKS. Now we cache per tenant ID.

_jwks_cache: dict[str, dict] = {}


async def _get_jwks(tenant_id: str) -> dict:
    """Fetch (and cache) the JWKS for a specific tenant."""
    if tenant_id not in _jwks_cache:
        url = (
            f"https://login.microsoftonline.com"
            f"/{tenant_id}/discovery/v2.0/keys"
        )
        async with httpx.AsyncClient() as client:
            resp = await client.get(url)
            resp.raise_for_status()
            _jwks_cache[tenant_id] = resp.json()
    return _jwks_cache[tenant_id]


def _check_tenant_allowed(tenant_id: str) -> None:
    """Raise 403 if the tenant is not on the allow-list."""
    if ALLOW_ANY_TENANT:
        return
    if tenant_id not in ALLOWED_TENANT_IDS:
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail=f"Tenant {tenant_id} is not allowed "
                   f"to access this application",
        )

Single-tenant vs multi-tenant JWKS:

Single-tenant (Blog 3):
  JWKS_URI = login.microsoftonline.com/{YOUR_TENANT}/keys
  _jwks_cache = { ... }  ← one global cache

Multi-tenant (Blog 5):
  JWKS_URI = login.microsoftonline.com/{TOKEN_TID}/keys
  _jwks_cache = {
    "tenant-aaa": { ... },
    "tenant-bbb": { ... },
  }  ← per-tenant cache

The Validation Flow#

async def validate_token(
    credentials: HTTPAuthorizationCredentials = Depends(security),
) -> dict:
    token = credentials.credentials

    # 1. Peek at unverified header + claims
    unverified_header = jwt.get_unverified_header(token)
    unverified_claims = jwt.get_unverified_claims(token)

    tenant_id = unverified_claims.get("tid", "")
    if not tenant_id:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Token missing tenant ID (tid) claim",
        )

    # 2. Gate: is this tenant allowed?
    _check_tenant_allowed(tenant_id)

    # 3. Fetch JWKS for this specific tenant
    jwks = await _get_jwks(tenant_id)
    rsa_key = _find_rsa_key(jwks, unverified_header.get("kid", ""))

    if rsa_key is None:
        # Key might have rotated — clear cache and retry
        _jwks_cache.pop(tenant_id, None)
        jwks = await _get_jwks(tenant_id)
        rsa_key = _find_rsa_key(jwks, unverified_header.get("kid", ""))

    if rsa_key is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Unable to find signing key",
        )

    # 4. Verify — dynamic issuer per tenant
    expected_issuer = f"https://sts.windows.net/{tenant_id}/"

    claims = jwt.decode(
        token,
        rsa_key,
        algorithms=["RS256"],
        audience=AUDIENCE,
        issuer=expected_issuer,
    )

    return claims

Step-by-step:

Token arrives: eyJhbGciOiJSUzI1NiIsImtpZCI6Ijk4...
    │
    ▼
1. Peek (unverified) → kid="98ab...", tid="aaa-..."
    │
    ▼
2. Is "aaa-..." in ALLOWED_TENANT_IDS?
    │ ✓ Yes (or ALLOW_ANY_TENANT=true)
    ▼
3. Fetch JWKS from
   login.microsoftonline.com/aaa-.../keys
   Find RSA key where kid="98ab..."
    │
    ▼
4. jwt.decode(
     token, rsa_key,
     audience="api://{app-id}",
     issuer="https://sts.windows.net/aaa-.../"
   )
    │ ✓ Signature, audience, issuer, expiry all valid
    ▼
5. Return verified claims

Why peek at unverified claims? We need the tid (tenant ID) before we can verify the token, because the JWKS endpoint and expected issuer depend on it. This is safe — we only use tid to determine which public key to verify against. The actual verification happens in step 4 with jwt.decode().


Step 5: Backend — Tenant-Isolated Data Store#

The task store is now nested: tenant_id → user_id → tasks.

File: backend/main.py

# Structure: { tenant_id: { user_id: [tasks] } }
tasks_db: dict[str, dict[str, list[dict]]] = {}


def _get_tenant_store(tenant_id: str) -> dict[str, list[dict]]:
    """Get or create the task store for a specific tenant."""
    if tenant_id not in tasks_db:
        tasks_db[tenant_id] = {}
    return tasks_db[tenant_id]

Read Tasks — Tenant-Scoped#

@app.get("/api/tasks")
async def get_tasks(
    claims: dict = Depends(require_role("Admin", "Editor", "Reader")),
):
    """Get tasks. Admin sees all in their tenant, others see own."""
    tenant_id = claims.get("tid", "")
    user_id = claims.get("oid", "")
    roles = get_user_roles(claims)

    tenant_store = _get_tenant_store(tenant_id)

    if "Admin" in roles:
        # Admin sees all tasks — but only within their tenant
        all_tasks = []
        for owner_id, user_tasks in tenant_store.items():
            for task in user_tasks:
                all_tasks.append({**task, "owner_id": owner_id})
        return all_tasks

    return tenant_store.get(user_id, [])

The critical difference from Blog 3: In Blog 3, Admin saw all tasks across all users globally. Now Admin sees all tasks within their tenant only. An Admin from Tenant A can never see Tenant B's data.

Create Task — Tagged with Tenant#

@app.post("/api/tasks")
async def create_task(
    task: dict,
    claims: dict = Depends(require_role("Admin", "Editor")),
):
    tenant_id = claims.get("tid", "")
    user_id = claims.get("oid", "")

    tenant_store = _get_tenant_store(tenant_id)
    if user_id not in tenant_store:
        tenant_store[user_id] = []

    new_task = {
        "id": _next_id(),
        "title": task.get("title", ""),
        "completed": False,
        "owner_name": claims.get("name", "Unknown"),
        "tenant_id": tenant_id,
    }
    tenant_store[user_id].append(new_task)
    return new_task

Delete Task — Tenant-Scoped#

@app.delete("/api/tasks/{task_id}")
async def delete_task(
    task_id: int,
    claims: dict = Depends(require_role("Admin")),
):
    """Delete any task within the admin's tenant."""
    tenant_id = claims.get("tid", "")
    tenant_store = _get_tenant_store(tenant_id)

    for user_id, user_tasks in tenant_store.items():
        tenant_store[user_id] = [
            t for t in user_tasks if t["id"] != task_id
        ]
    return {"status": "deleted"}

Every endpoint now starts with tenant_id = claims.get("tid", "") and scopes all data operations to that tenant.


Step 6: Frontend — Tenant Info in Dashboard#

The dashboard now displays the tenant ID and uses org-scoped language:

// Profile card shows Tenant ID
<div className="flex items-start gap-3 min-w-0">
  <Building2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
  <div className="min-w-0">
    <p className="text-xs text-muted-foreground">Tenant ID</p>
    <p className="truncate text-sm font-mono font-medium">
      {profile?.tenant_id || "Unknown"}
    </p>
  </div>
</div>
// Admin sees "All Tasks in My Organization"
<CardTitle>
  {isAdmin ? "All Tasks in My Organization" : "My Tasks"}
</CardTitle>
<CardDescription>
  {isAdmin
    ? "Admin view — showing all tasks in your tenant"
    : canCreate
      ? "Create and manage your own tasks"
      : "Read-only view of your tasks"}
</CardDescription>

The permission badge also changes from "View All Users" to "View All in Org" — reflecting that Admin access is now organization-scoped, not global.


Step 7: Onboarding Other Tenants#

The setup script generates an admin consent URL for onboarding new organizations:

CONSENT_URL="https://login.microsoftonline.com/organizations/adminconsent?client_id=$API_APP_ID"

Onboarding flow:

1. Share the admin consent URL with the other org's IT admin

2. They open the URL → Azure shows a consent dialog
   "This app wants to access your organization's data"

3. The admin clicks "Accept"
   → Creates a service principal for your app in their tenant
   → Their users can now sign in

4. You add their tenant ID to ALLOWED_TENANT_IDS
   in your backend/.env

5. Their admin assigns App Roles to their users
   via Azure Portal → Enterprise Applications → Your app

After onboarding, users from the new tenant sign in with their own credentials, get tokens with their own tid, and the backend validates against their tenant's JWKS. Their data is isolated in tasks_db["their-tenant-id"].


Running the App#

1. Register the Apps#

az login
./setup.sh

The script outputs the admin consent URL and test account credentials:

   Test accounts (home tenant):
   ┌──────────────────────────────────────────────┐
   │ Role    │ Username              │ Password    │
   ├──────────────────────────────────────────────┤
   │ Admin   │ testuser-admin@...    │ SecureP@ss  │
   │ Editor  │ testuser-editor@...   │ SecureP@ss  │
   │ Reader  │ testuser-reader@...   │ SecureP@ss  │
   └──────────────────────────────────────────────┘

   Admin consent URL (for onboarding other tenants):
   https://login.microsoftonline.com/organizations/adminconsent?client_id=...

2. Start the Backend#

cd backend
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
uvicorn main:app --reload

3. Start the Frontend#

cd frontend
npm install
npm run dev

4. Test#

  1. Sign in as Admin → see "All Tasks in My Organization"
  2. Create tasks → they're stored under your tenant's namespace
  3. Sign in as Editor/Reader → same RBAC behavior as Blog 3
  4. To test cross-tenant: share the consent URL with another org, add their tenant ID to ALLOWED_TENANT_IDS, and have their users sign in

Cleanup#

./cleanup.sh

Common Pitfalls#

1. "Tenant is not allowed to access this application"#

The user's tenant ID isn't in ALLOWED_TENANT_IDS.

Fix: Add their tenant ID to the comma-separated list in backend/.env. Or set ALLOW_ANY_TENANT=true if you want open access.

2. "Unable to find signing key" for External Tenant#

The JWKS cache might have stale data, or the tenant's keys rotated.

Fix: The code already handles this — it clears the cache and retries once. If it still fails, check that the tenant ID is a valid Azure AD tenant (not a B2C directory).

3. External Users Have No Roles#

App Roles must be assigned per-tenant. Your role assignments don't carry over to other tenants.

Fix: The external tenant's admin must assign roles to their users via Azure Portal → Enterprise Applications → your app → Users and groups.

4. "AADSTS50020: User account from identity provider does not exist in tenant"#

The user is trying to sign in but the service principal doesn't exist in their tenant.

Fix: The external tenant's admin must complete the admin consent flow first (using the consent URL from setup.sh).

5. Admin Sees Tasks from Other Tenants#

This shouldn't happen with the Blog 5 code. If it does, the data store isn't properly scoped by tid.

Fix: Verify that all endpoints use _get_tenant_store(tenant_id) and never access tasks_db directly.


Security Considerations#

Peeking at Unverified Claims#

We read tid from the unverified token to know which JWKS endpoint to call. Is this safe?

Yes, because:

  1. We only use the unverified tid to select the JWKS endpoint
  2. The actual verification (jwt.decode) checks the signature against those keys
  3. If someone forges a tid, the signature won't match the real tenant's keys
  4. The issuer check provides a second layer — it must match sts.windows.net/{tid}/

An attacker can't claim to be Tenant A by putting tid="aaa" in a forged token — the signature verification will fail because they don't have Tenant A's private key.

Cross-Tenant Data Leaks#

Every data operation is scoped by tenant_id = claims.get("tid", ""). The tid comes from the verified claims (after jwt.decode), not the unverified peek. So the tenant scoping is cryptographically guaranteed.

Tenant Allow-List vs Open Access#

ApproachUse CaseRisk
Allow-listB2B SaaS, known partnersLow — only approved orgs
Open (ALLOW_ANY_TENANT=true)Public SaaS, marketplace appHigher — any org can sign in

For most B2B scenarios, use the allow-list. Only go open if you have additional controls (billing, onboarding workflow, etc.).


What's Next#

In Blog 6: Service-to-Service, we'll:

  • Implement On-Behalf-Of (OBO) — call downstream services while preserving user identity
  • Use Client Credentials — app-to-app calls with no user context
  • Build a microservice architecture with three independently secured FastAPI services
  • Mix B2B (organizational) and B2C (consumer) auth in one app

Multi-tenant handles organizations. B2C handles individual consumers — a different identity model entirely.


Conclusion#

You've built a multi-tenant authentication system with Microsoft Entra ID:

  • AzureADMultipleOrgs — app registrations accept users from any Azure AD tenant
  • Authority /organizations — MSAL allows any organizational account to sign in
  • Dynamic JWKS — each tenant's signing keys fetched and cached independently
  • Dynamic issuersts.windows.net/{tid}/ derived from the token's tenant ID
  • Tenant allow-list — configurable gating with ALLOWED_TENANT_IDS or ALLOW_ANY_TENANT
  • Per-tenant data isolation — nested store {tenant_id: {user_id: [tasks]}}
  • Org-scoped Admin — "All Tasks in My Organization", never cross-tenant
  • Admin consent URL — one-click onboarding for external organizations

The RBAC system (Admin/Editor/Reader) works exactly the same within each tenant. Multi-tenancy adds the tenant dimension — every token is validated against the right keys, every data operation is scoped to the right tenant, and no organization can see another's data.


Resources#

Happy building!