# How do I add login to a multi-tenant app where each tenant has its own domain?

> Resolve the tenant from the request’s Host header, send people to one identity provider to sign in, and check after the callback that the person is a member of that tenant. Keep one user pool for everyone, and store memberships and per-tenant roles in your own database, keyed by the provider’s stable user id. With loginos, every tenant domain goes under one platform.

Your app serves many tenants, and each one has its own domain: `acme.com`, `portal.globex.io`, `initech.yourapp.com`. A tenant is one customer organization, with its own data, its own members and often its own roles. On every domain, login has to answer two questions: who is this person, and may they use this tenant?

Keep the two questions apart. The identity provider answers the first, the same way on every domain. Your app answers the second, from its own tables. Many multi-tenant security bugs come from treating “signed in” as “allowed in this tenant”.

## How does the app know which tenant a request is for?

The app finds the tenant from the `Host` header, the part of every HTTP request that names the domain the browser asked for. Look the host up in your own table of tenant domains, and stop when you don’t find it. Never fall back to a default tenant.

```js title="Express"
// Runs before every route. The tenant comes from your records, never from the request alone.
app.use(async (req, res, next) => {
  const host = req.hostname.toLowerCase().replace(/\.$/, ""); // no port, no trailing dot
  req.tenant = await db.tenantDomains.findActive(host); // { id, name } or null
  if (!req.tenant) return res.status(404).send("Unknown site.");
  next();
});
```

A few details matter:

- **Resolve once.** Do it in middleware and let every route use `req.tenant`. Never accept a tenant id from the query string or a form for anything that needs permission.
- **Normalize hosts.** Lowercase them and drop the port and any trailing dot, both in the lookup and when you store domains.
- **Proxies.** Behind a load balancer, the original host may arrive in `X-Forwarded-Host`. Express only reads it when its `trust proxy` setting is on, and clients can send the header too, so turn that setting on only behind a proxy you control.
- **Index the column.** The lookup runs on every request.

## Should all tenants share one user pool?

A user pool is the set of accounts an identity provider signs in. You can give all tenants one shared pool, or each tenant a pool of its own.

| | One shared pool | One pool per tenant |
| --- | --- | --- |
| A person in three tenants | One account, three memberships | Three separate accounts |
| Switching tenants | No new sign-in form | A sign-in per tenant |
| Isolation | Your app scopes data by tenant | Accounts are separate by default |
| Different sign-in rules per tenant | Limited | Easy |
| What you operate | One configuration | One per tenant |

**One shared pool** fits most B2B products. Consultants, agencies and your own support staff often work with several tenants, and they keep one account. Isolation lives in your app: every query is scoped to the current tenant, and membership decides who gets in.

**A pool per tenant** fits when tenants need different sign-in rules, such as their own enterprise identity provider, or when contracts demand fully separate accounts. Keycloak realms work this way: each realm manages its own users and is isolated from the others. The cost is one account per person per tenant, and more configuration to run.

Some providers sit in between and group members into organizations inside one pool, with a role per organization. Auth0 Organizations and Clerk Organizations work this way, and Keycloak has an organizations feature too.

## Where should tenant membership and roles live?

Tenant membership and per-tenant roles belong in your own database, in a table like `memberships (tenant_id, user_sub, role)`. The key is the provider’s stable user id, `sub` in OpenID Connect, which never changes. Never key memberships by email address: people change addresses, and a provider that doesn’t verify them lets anyone sign up with someone else’s.

Your own table does things a token can’t:

- **Per-tenant roles.** Jane can be an admin in Acme and a viewer in Globex.
- **Changes that apply at once.** Removing Jane from Acme takes effect on her next request, without waiting for a token to expire.
- **Invitations.** Send a link with a single-use token. When the person opens it and signs in, attach the membership to the `sub` that signed in, not to the address the invitation went to.

Roles that are the same in every tenant can still come from the identity provider, like a “support” role for your own staff. For keeping roles consistent across several apps, see [how to manage user roles across multiple apps](https://loginos.io/answers/manage-roles-across-apps.md).

## What should the callback do in a multi-tenant app?

The callback is the route where the provider sends the browser back with a one-time code. In a multi-tenant app it does one more thing than usual:

1. Check `state` and exchange the code for the person’s identity, as in any OpenID Connect sign-in.
2. Look up the membership for this domain’s tenant and the person’s `sub`.
3. If there is one, start the session on this domain with the tenant id in it, and send the person back to the page they started on.
4. If there isn’t, say so, and offer to request access or to sign in with another account. Don’t create a membership just because someone signed in, unless the tenant allows open sign-ups.

Keep the tenant id in the session and compare it with the current tenant on each request. A cookie on `acme.com` never reaches `portal.globex.io`, but tenants that share a parent domain, like `acme.yourapp.com` and `globex.yourapp.com`, can share a cookie by mistake.

## How does one person use several tenants?

With a shared pool, a person has one identity and a separate session on each tenant’s domain. Say they signed in on `acme.com` and now open `portal.globex.io`. The browser has no session cookie there, so your app redirects to the identity provider. The provider recognizes them from its own session and sends them straight back with a code, and your callback checks their Globex membership.

A tenant switcher is a list of the person’s memberships from your database, each linking to that tenant’s domain. On a domain that names no tenant, like `app.yourapp.com`, show the same list after sign-in so they can pick one. Sign-out is per domain too. Each tenant domain clears its own session, and ending the identity provider’s session stops the silent sign-in on the next domain.

## How loginos does it

loginos is an OpenID Connect provider for products on many domains, and it fits this model with one rule: **one platform for the whole app**. A platform is one user pool with platform-wide roles, and it has no limit on domains, so every tenant domain goes under it. One account can own at most 20 platforms, so a platform per tenant isn’t the model.

- **Tenant domains.** Register each from your server with `POST /api/v1/domains` when a tenant connects it, or let loginos ask your server about new ones with [automatic domains](https://loginos.io/docs/domains.md#automatic-domains). [Authentication on customers’ custom domains](https://loginos.io/answers/saas-custom-domain-authentication.md) covers that step.
- **One identity.** `sub` never changes and is the same on all of your domains. Use it as the key of your memberships table.
- **Membership is yours.** Someone who joined your platform on one domain is already a member on the others and signs in there without a form. That is membership of your platform, not of a tenant, so your callback still checks the tenant.
- **Roles.** [Roles and permissions](https://loginos.io/docs/roles.md) in loginos tokens are platform-wide. Use them for what is true in every tenant, and keep per-tenant roles in your database.
- **Blocking.** `POST /api/v1/users/{sub}/block` stops sign-in on all of your domains. To remove someone from one tenant, delete their membership instead.

The callback, with the membership check. The redirect route that sets the `lo_flow` cookie is in the [quickstart](https://loginos.io/docs/quickstart.md):

```js title="Express"
app.get("/api/auth/login/single/callback", async (req, res) => {
  const [state, verifier] = (req.cookies.lo_flow ?? "").split(".");
  res.clearCookie("lo_flow");
  if (!state || req.query.state !== state) return res.status(400).send("Sign-in expired, try again.");

  const r = await fetch("https://loginos.io/oauth/token", {
    method: "POST",
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code: String(req.query.code ?? ""),
      redirect_uri: `https://${req.get("host")}/api/auth/login/single/callback`,
      code_verifier: verifier,
      client_id: process.env.LOGINOS_APP_KEY,
      client_secret: process.env.LOGINOS_APP_SECRET,
    }),
  });
  if (!r.ok) return res.status(401).send("Sign-in failed.");
  const { user } = await r.json();

  // loginos says who this is. Your own table says whether they may use this tenant.
  const member = await db.memberships.find({ tenantId: req.tenant.id, sub: user.sub });
  if (!member) return res.status(403).send(`You don't have access to ${req.tenant.name}.`);

  // Read the member's role from your table on each request, so changes apply at once.
  req.session.user = { sub: user.sub, tenantId: req.tenant.id, permissions: user.permissions };
  res.redirect("/");
});
```

loginos isn’t the right fit when tenants need their own sign-in rules. It has no SAML or enterprise connections and no organizations inside a platform, and the sign-in page on loginos.io shows your platform’s name and logo, not the tenant’s.

## Frequently asked questions

### Should each tenant get its own OAuth client?

Usually not. One client with every tenant domain registered keeps one user pool, one set of credentials and one secret to rotate. Separate clients make sense when tenants need different settings, and they bring separate accounts with them.

### How do I stop a member of one tenant from opening another?

Check membership in the callback, and scope every database query by the current tenant. The identity provider proves who someone is, but it knows nothing about your tenants. A person with a valid sign-in on `acme.com` must still get a “no access” page on `portal.globex.io` if Globex never invited them.

### Can the same email address belong to two tenants?

With a shared pool, yes: it is one person with two memberships. With a pool per tenant, the same address can be two unrelated accounts. Either way, link records by the provider’s user id, never by email.

### How do I add someone to a tenant before they have an account?

Send an invitation link with a single-use token, and store which tenant and role it grants. When they open it, they sign in or create an account at the identity provider, and your callback attaches the membership to the `sub` that came back. The email address only decides where the link goes.

---

Updated 2026-10-14. Web page: https://loginos.io/answers/multi-tenant-login-custom-domains
