How do I handle authentication on customers’ custom domains in my SaaS?
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 withHost: 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
Domainattribute. The session cookie ofyoursaas.comnever reachesapp.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 domain | One central login domain | |
|---|---|---|
| Callback URLs at the provider | One per custom domain | One, like login.yoursaas.com/callback |
| Session on the custom domain | Set by its own callback | Needs a hand-off from the login domain |
| Code you write | Register and remove domains | One-time hand-off tokens and target checks |
| Main risk | A stale domain left on the list | The 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:
- The customer enters
app.customer.comin your dashboard. You store it as pending. - 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.
- Your TLS layer gets a certificate, and you mark the domain active.
- In the same step, your server registers the domain with the identity provider through its management API.
- 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
Hostheader 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 -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.
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
Can one session cookie work on all my customers’ domains?
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
- Customer domainsHow 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.7 min read
- Customer domainsHow do I add a redirect URI automatically when a customer adds a domain?Call your identity provider’s management API from the code that marks a customer’s domain as verified and active, and add that domain’s callback URL there. Run the call from a job queue so it retries, make it safe to repeat, and remove the URL when the customer disconnects the domain. With loginos you register the host with one POST, or let loginos ask your server.6 min read
- Customer domainsCan I use wildcard redirect URIs with OAuth?Usually not, and you shouldn’t rely on them. RFC 9700, the OAuth 2.0 Security Best Current Practice, requires authorization servers to match redirect URIs exactly, because loose patterns have let attackers steal authorization codes. Google rejects wildcards, while Auth0 and Okta allow narrow ones with warnings. Register exact URIs through an API instead, or use a provider that verifies wildcard ownership or asks your server.7 min read
- Customer domainsAuth0 multiple custom domains is too expensive, what are the alternatives?The main alternatives are one shared login domain for all your brands, one Auth0 tenant per brand, or a self-hosted server such as Keycloak, Zitadel or Authentik, where every domain is yours. Auth0 offers Multiple Custom Domains only on Enterprise. If your real need is many app domains sharing one user pool, loginos fits, but its sign-in page always stays on loginos.io.5 min read