# Resolving the Tenant for a Request — Multi-Tenant SaaS Architecture

Source: https://www.skillbyai.com/en/multi-tenant-saas/mt-tenant-resolution

> Identify the tenant of every incoming request from subdomains, custom domains, paths or token claims, and reject requests whose tenant cannot be resolved.

## Where does the tenant come from?

Before any business logic runs, the system must answer: *which tenant is this request for?* Common sources are the **subdomain** (`zen.example-saas.com`), a **custom domain** the tenant mapped to you (`app.acme.in`), a **path prefix** (`/t/zen/projects`), an explicit **header** set by a trusted gateway, or a **claim inside the access token** issued at login. Hostnames and paths say which tenant the caller *wants*; the authenticated token says which tenants the user is *allowed* into. Robust systems use both: resolve the requested tenant from the URL, then check that the signed-in user has a membership in it. Resolution should happen once, at the edge (gateway or middleware), and fail closed: if no tenant can be resolved or the user is not a member, return an error instead of falling back to some default tenant.

## The tenant resolution pipeline

A request is received, the host or path gives the requested tenant, the token proves membership, and only then is a tenant context attached for the rest of the request.

![Four stages: incoming request, tenant lookup from host or path, membership check against the token, tenant context attached to the request.](assets/figures/multi-tenant-saas/section-3-map.svg) — Figure 3.1: request -> resolve tenant from host/path -> verify membership from token -> set tenant context

## Resolving from the host name

Custom domains are checked first, then subdomains of the product's root domain. Anything else is rejected.

```python
CUSTOM_DOMAINS = {"app.acme.in": "acme"}
ROOT = "example-saas.com"

def resolve_tenant(host: str) -> str:
    host = host.split(":")[0].lower()
    if host in CUSTOM_DOMAINS:
        return CUSTOM_DOMAINS[host]
    if host.endswith("." + ROOT):
        sub = host[: -len(ROOT) - 1]
        if sub and "." not in sub:
            return sub
    raise LookupError(f"unknown tenant for host {host!r}")

for h in ["zen.example-saas.com", "app.acme.in:443", "evil.com"]:
    try:
        print(h, "->", resolve_tenant(h))
    except LookupError as e:
        print(h, "->", e)
```

Output:

```
zen.example-saas.com -> zen
app.acme.in:443 -> acme
evil.com -> unknown tenant for host 'evil.com'
```

## Tracing two lookups

Follow the highlighted line for a valid subdomain and then for an unknown host.

```python
CUSTOM_DOMAINS = {"app.acme.in": "acme"}
ROOT = "example-saas.com"

def resolve_tenant(host: str) -> str:
    host = host.split(":")[0].lower()
    if host in CUSTOM_DOMAINS:
        return CUSTOM_DOMAINS[host]
    if host.endswith("." + ROOT):
        sub = host[: -len(ROOT) - 1]
        if sub and "." not in sub:
            return sub
    raise LookupError(f"unknown tenant for host {host!r}")

for h in ["zen.example-saas.com", "app.acme.in:443", "evil.com"]:
    try:
        print(h, "->", resolve_tenant(h))
    except LookupError as e:
        print(h, "->", e)
```

- Line 14: The loop starts with the subdomain host. Nothing has been resolved yet.
- Line 5: The port is stripped and the host lower-cased, because DNS names are case-insensitive and ports are irrelevant to tenancy.
- Line 6: Custom domains are checked first so a tenant with its own domain is always matched exactly. This host is not one.
- Line 8: The host does end with the product's root domain, so it may be a tenant subdomain.
- Line 9: Slicing removes '.example-saas.com', leaving the label in front of it.
- Line 10: A nested label like 'a.b' is refused, so only a single subdomain level can name a tenant.
- Line 11: The tenant id is returned and printed.
- Line 5: Later in the loop, 'evil.com' is normalised the same way.
- Line 8: It is not a custom domain and does not end with the root domain, so both branches are skipped.
- Line 12: The function fails closed: it raises instead of guessing a default tenant.
- Line 18: The caller catches the error and reports it; a web app would return 404 or 400 here.

## Never default to a tenant

Code like `tenant = resolve(host) or 'default'` turns a typo or a scanner into access to a real tenant's data. Unknown tenant means an error response, every time.

**Quiz:** A request arrives for `acme.example-saas.com` with a valid token for a user who belongs only to the tenant `zen`. What should happen?

- [ ] Serve acme's data, since the hostname is valid
- [ ] Serve zen's data, since that is the user's tenant
- [ ] Silently redirect the user to a random tenant
- [x] Reject the request, because the user has no membership in acme

*Answer:* Reject the request, because the user has no membership in acme. The hostname names the requested tenant; the token proves which tenants the user may access. A mismatch must fail closed with an authorisation error.
