loginos

How do I handle authentication on customers’ custom domains in my SaaS?

loginos teamUpdated 7 min read

Short answer

Let each custom domain run its own sign-in: it sends people to your identity provider and gets them back on its own callback, where your app sets that domain’s session cookie. The provider must accept every custom domain as a redirect target, so register a domain once your records mark it verified and active, and remove it when the customer leaves. loginos does this with one API call.

Your customers want your product on their own address: app.customer.com instead of customer.yoursaas.com. They point the name at you in DNS, you serve a TLS certificate for it, and the pages load. Then sign-in breaks. Your session cookie doesn’t reach the new domain, and your identity provider refuses to send anyone back to an address it doesn’t know.

The fix has two parts. Each custom domain runs its own sign-in and keeps its own session, and your identity provider’s list of allowed domains follows your own records, in code.

What changes when a customer brings their own domain?

Three things change at once: DNS, TLS and cookies.

  • DNS. The customer adds a CNAME record, a DNS entry that makes one name an alias of another: app.customer.com CNAME customers.yoursaas.com. Requests then reach you with Host: app.customer.com, and your app looks up which customer that is.
  • TLS. Every custom domain needs a certificate. Caddy’s on-demand TLS and Cloudflare for SaaS can issue them for domains you don’t know in advance.
  • Cookies. Browsers send a cookie only to the domain that set it, and to its subdomains when the cookie has a Domain attribute. The session cookie of yoursaas.com never reaches app.customer.com, so each custom domain needs its own session.

The cookie rule settles most of the design. If the session must live on app.customer.com, the sign-in has to end there too.

Should people sign in on the custom domain or on a central login domain?

Both designs use an identity provider. They differ in where the provider sends people back.

Sign-in on each custom domainOne central login domain
Callback URLs at the providerOne per custom domainOne, like login.yoursaas.com/callback
Session on the custom domainSet by its own callbackNeeds a hand-off from the login domain
Code you writeRegister and remove domainsOne-time hand-off tokens and target checks
Main riskA stale domain left on the listThe hand-off becomes an open redirect

Sign-in on each custom domain is the standard OpenID Connect flow, repeated per domain. app.customer.com/login redirects to the provider, the provider sends the browser back to app.customer.com/callback with a one-time code, and your server exchanges the code and sets the session cookie there. Someone already signed in to the provider comes straight back, without a form.

A central login domain keeps one callback URL. After the sign-in, it creates a short-lived token and redirects to the custom domain, which trades the token for a session. You are then building part of an identity provider yourself: tokens that work once, expire in seconds and are bound to one domain. The target must come from your records too, or the login domain becomes an open redirect, a URL that forwards people to any address an attacker puts in it.

Most teams should sign in on each custom domain. The extra work is keeping the provider’s list current, which takes little code.

Why must every custom domain be an allowed redirect target?

The redirect URI, or callback URL, is the address where the identity provider sends the browser back with the code. Providers only use addresses you registered, and they compare them exactly: OpenID Connect Core 1.0 and the OAuth security best practice, RFC 9700, both require it.

The rule protects you. Your client ID is public, so anyone can start a sign-in for your app. If the provider accepted any return address, an attacker could send your users through a real sign-in and collect their codes on a domain of their own.

So each custom domain needs its callback URL on the list, and the callback has to run on that domain, the only place that can set its session cookie. A wildcard doesn’t help: https://*.yoursaas.com/callback covers your own subdomains at best, never app.customer.com. Can I use wildcard redirect URIs with OAuth? explains the risks.

How do you keep the provider’s list in step with your customers?

Tie the provider’s list to the lifecycle of a custom domain in your own database:

  1. The customer enters app.customer.com in your dashboard. You store it as pending.
  2. You check that they control it, for example that the CNAME points at you or that a TXT record holds a token you gave them.
  3. Your TLS layer gets a certificate, and you mark the domain active.
  4. In the same step, your server registers the domain with the identity provider through its management API.
  5. When the customer removes the domain or cancels, or the CNAME stops pointing at you, you mark it inactive and remove it from the provider.

Run steps 4 and 5 from a job queue that retries, and make the “add” call safe to repeat. How do I add a redirect URI automatically when a customer adds a domain? has the code.

The hands-off alternative is a provider that asks your server about an unknown domain the first time someone signs in there. Your server answers from its records, the way Caddy’s on-demand TLS asks your ask endpoint before it requests a certificate.

Which custom domains should be allowed?

Only domains your own records say are verified and active right now, however they reach the list.

  • Not what someone typed. Register a domain after the ownership check, not when the form is submitted.
  • Not what DNS says. A CNAME that points at you today can point elsewhere tomorrow. DNS shows where a name goes, not whose it is.
  • Not the request. The Host header and a provider’s question come from outside. Look the domain up in your records.
  • Not forever. If an expired domain stays on the list and someone else registers it, they control a valid return address for your sign-ins. Remove domains as soon as they stop being active.

Keep the usual defenses as well. Exchange codes on your server with your client secret, and use PKCE (Proof Key for Code Exchange). PKCE ties each code to a secret known only to whoever started the sign-in, so a code that reaches the wrong place is worth little on its own.

How loginos does it

loginos is an OpenID Connect provider for products on many domains. Your product is a platform, with no limit on domains, and all of them share one user pool. The callback path is the same on every domain, /api/auth/login/single/callback by default, and loginos only sends people back to https://<one of your domains><callback path>. So for each customer you register a host, not a URL.

When your records mark a domain active, register it from your server. Hosts that are already there are fine, so a retry is harmless:

curl
curl -X POST https://loginos.io/api/v1/domains \
  -u "$LOGINOS_APP_KEY:$LOGINOS_APP_SECRET" \
  -H "content-type: application/json" \
  -d '{"host": "app.customer.com"}'

# When the customer disconnects the domain or leaves:
curl -X DELETE https://loginos.io/api/v1/domains/app.customer.com \
  -u "$LOGINOS_APP_KEY:$LOGINOS_APP_SECRET"

{"hosts": [...]} adds up to 1,000 at once, and sign-ins from a deleted domain stop at once. See Domains: from your server.

For the hands-off option, turn on automatic domains. The first time a sign-in comes from a host that isn’t on your list, loginos posts {"host", "client_id"} to an endpoint on your server. Only a 200 with {"host": <the same host>, "allow": true} within 3 seconds adds the host. Anything else stops the sign-in.

Express
app.post("/loginos/domain-check", express.json(), async (req, res) => {
  const host = String(req.body.host ?? "");
  // Your own records: a customer connected it, you verified it, and it is active.
  const domain = await CustomDomain.findOne({ host, status: "active" });
  res.json({ host, allow: Boolean(domain) });
});

Before the feature turns on, loginos asks about a .invalid host, and your endpoint has to say no. An automatic domain is checked again when it is used a day after the last yes, and a clear no removes it.

Know the limits before you choose loginos:

  • Sign-in happens on loginos.io, under your platform’s name and logo, never on a customer’s domain.
  • One account per person. Someone in two of your customers’ workspaces has one loginos account, and your app decides which workspaces they may open. See multi-tenant login with a domain per tenant.
  • No SAML or enterprise connections, so customers can’t bring their own identity provider.

The quickstart has the redirect route and the callback for Next.js, Express and PHP.

Frequently asked questions

No. Browsers send a cookie only to the domain that set it, and app.customer.com and yoursaas.com are unrelated domains. Each needs its own session. People still sign in only once: the second domain sends them to the identity provider, which recognizes them and sends them straight back.

Do I need a separate OAuth client for each customer domain?

Usually not. One client can hold many redirect URIs, which keeps one set of credentials and one user pool. Some providers cap the number of redirect URIs per client, which pushes larger products toward several clients, so check the cap early.

What should happen when a customer’s domain expires?

Remove it from the provider’s list as soon as your records stop treating it as active. A lapsed domain can be registered by someone else, and any address still on the list can receive sign-in codes. Re-checking custom domains on a schedule catches the ones customers forget to remove.

Can my customers have the login page on their own domain?

Only with a provider that offers custom login domains, such as Auth0 with its Multiple Custom Domains feature. The alternatives to Auth0’s multiple custom domains compares the options. loginos doesn’t: sign-in always happens on loginos.io.

One account for every domain you run

loginos is an OpenID Connect provider with passkeys, a user pool shared by all of your domains, roles in every token, and account linking for the users you already have. Add your platform and its domains in a few minutes.

Keep reading

All answers