Custom Domains in SaaS: Ownership Verification, CNAME Targets and TLS Traps
Serving a SaaS from a customer's own domain is not a single CNAME record. Tenant routing, proof of ownership and certificate issuance are three separate problems; which verification methods actually count as proof, the apex and CAA traps, and what keeps a departed customer's name from falling to another tenant.
Serving a SaaS product from a customer's own domain, so that portal.customer.com lands on your application, looks like a single CNAME record. It is not. Every request on that domain forces you to solve three separate problems: deciding which tenant the request belongs to, proving the domain really belongs to that customer, and presenting a TLS certificate the browser will accept. Get any of the three loose and you end up with either an outage or one tenant quietly receiving another tenant's traffic. This post walks through the three problems in order and points out where each one bites.
Routing: what the customer's record should point at
There are two options for the CNAME target you hand to customers. A shared target such as custom.example.net, or a per-tenant target such as t-4f9a2c.connect.example.net. The shared one is easier to explain, but the per-tenant one is better, because the target itself doubles as proof of ownership: only someone with access to that tenant's dashboard can learn a randomly generated target. It also solves offboarding. Delete the target when the customer leaves and the name stops resolving, so no other tenant can claim it later.
The apex domain (customer.com with no label in front) is its own headache. The DNS standard does not allow a CNAME at the apex. Unless the customer's provider offers an ALIAS or ANAME style record, the only option is an A record to a fixed IP address, and once you publish that IP you cannot change it for years. The healthiest decision is to ask for a subdomain in your documentation and to accept the apex only when genuinely required, backed by anycast or a deliberately long-lived address.
On the application side, routing is a table mapping domain to tenant. A Host header that is not in the table must return a clear 404, not fall through to a default tenant. Binding an unknown name to any tenant shows the wrong brand in the browser and, as described below, makes takeover much easier.
Proof of ownership: what each method actually proves
Three methods are common, and they are not equally strong.
HTTP file. The customer places content you provide at a well-known path on the domain. This is weak, because if the domain already points at you, you are the one serving that path. The party who needs to prove ownership passes without doing anything. It is fine for a certificate authority's HTTP-01 challenge, but useless for tenant ownership.
TXT record. The customer publishes a tenant-specific random value at a name such as _saas-verify.portal.customer.com. Whoever controls the DNS zone can do this; whoever merely receives the redirected traffic cannot. This is the correct proof. Query the domain's authoritative servers rather than your own resolver's cache, or the customer will see "not verified" for hours after fixing the record.
Per-tenant CNAME target. As noted above, an unguessable target is a proof on its own. Combined with the TXT record it is the lowest-friction flow: the customer adds one record and the system observes routing and ownership at the same time.
When designing a proof, ask one question: would this check pass even if the claimant did nothing? One method that skips this question is surprisingly common: checking whether the name servers are yours. If the domain sits under your own platform zone, say x.app.example.net, your name servers are authoritative by construction and the check says "verified" for every name in it. The result is one tenant adding another tenant's subdomain and being verified instantly. Never accept names under your own zone as custom domains, and compare suffixes with the leading dot: a rule rejecting anything that ends in app.example.net also rejects an unrelated domain like evil-app.example.net, while a rule on .app.example.net is the right one.
Proof must not be a one-time event. The customer can transfer the domain or delete the record. A daily or weekly re-verification, warning on the first failure and disabling after a grace period, keeps the table honest.
TLS: who obtains the certificate, and how
You obtain the certificate for the customer's domain, and HTTP-01 is your only option. DNS-01 requires writing to the customer's DNS, which you cannot do. HTTP-01 works naturally because the traffic already reaches you. The only requirements are an open port 80 and answering /.well-known/acme-challenge/ without redirecting it to HTTPS. A blanket "redirect everything to HTTPS" rule that also catches this path makes validation fail silently.
With cert-manager on Kubernetes, the whole thing comes down to one Ingress annotation:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: tenant-4f9a2c-custom
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- portal.customer.com
secretName: tenant-4f9a2c-portal-customer-com-tls
rules:
- host: portal.customer.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app
port:
number: 8080
Outside Kubernetes, Caddy's on-demand TLS does the same job on its own. The certificate is obtained during the first TLS handshake, and you decide which names are allowed through an HTTP endpoint:
{
on_demand_tls {
ask http://127.0.0.1:5555/allowed
}
}
https:// {
tls {
on_demand
}
reverse_proxy app:8080
}
Do not leave the ask endpoint out. It answers "does this domain belong to a verified tenant" with a 200 or a 403. Without it, a certificate is requested for every name that arrives via SNI, and both your rate limit and your disk fill up.
On rate limits, the good news is that the weekly caps of public certificate authorities are counted against the customer's registered domain, so a hundred customers do not consume your quota. The bad news is that repeatedly requesting a certificate for the same exact name has its own separate cap. A setup that does not persist certificate state durably re-requests the same name on every restart, and after a handful of deployments that customer is left without a certificate for days.
Traps
CAA records. If the customer's domain carries a CAA record that only permits a specific authority, yours cannot issue, and the error message rarely says so plainly. Querying CAA during onboarding and telling the customer up front is the only way to avoid the support ticket.
A proxy in front of the customer. When the customer routes the domain through a CDN, the CDN presents its own certificate and yours is used only between the edge and you. HTTP-01 can then be blocked by the CDN's HTTPS redirect. Document this scenario separately.
Requests before the certificate exists. The record is in place, the certificate is not yet issued. The server presents its default certificate and the browser warns. Telling the customer that the certificate arrives within minutes after adding the record, and showing the status in the dashboard, prevents the "it is broken" impression.
Cookies and caches. An application that scopes its session cookie to your primary domain cannot log anyone in on a customer domain. A cache key that does not include the Host header serves one tenant's page to another. Both surface only after routing works, on the first real user.
The departed customer. Removing the row from your table is not enough. If the customer forgets the CNAME in their own DNS, the name still resolves to you. The per-tenant target saves you here: delete it and resolution stops. With a shared target, another tenant can add the same name and, if verification is loose, receive the former customer's traffic.
When not to do this
If all the customer wants is to see their brand, a subdomain such as customer.app.example.net under a single wildcard certificate makes this entire machine unnecessary. No ownership check, one certificate obtained once, no CAA or CDN issues. Custom domain support brings a permanent support burden: DNS propagation, wrong records, expired domains. Reserving it for plans that pay for it is more sustainable than offering it to everyone.