Enterprise customers with security requirements often want your auth endpoints to live under their own domain: auth.acme.com instead of acme.yourapp.com. This satisfies procurement requirements about third-party cookies, simplifies network allowlisting (the customer's IT team can allow *.acme.com, not a list of your subdomains), and enables data residency configurations. Implementing it requires per-tenant TLS certificate provisioning and OIDC discovery documents that reference the custom domain.
CNAME-based routing
The customer creates a CNAME record pointing their subdomain to your infrastructure. Your load balancer receives traffic on the custom domain, determines which tenant it belongs to, and routes accordingly. The critical security requirement: verify the CNAME ownership before activating the custom domain.
# Customer DNS configuration (customer's registrar) # auth.acme.com CNAME tenants.yourapp.com # Your infrastructure: wildcard or per-domain certificate needed # Per-domain approach: Let's Encrypt via ACME protocol # Verify the CNAME is correctly configured before provisioning cert dig auth.acme.com CNAME +short # Expected: tenants.yourapp.com.
// Server-side: map incoming Host header to tenant
// Runs at the load balancer or application layer
async function resolveTenantFromHost(host) {
// Direct subdomain: acme.yourapp.com
const subdomainMatch = host.match(/^([^.]+)\.yourapp\.com$/);
if (subdomainMatch) {
return getTenantBySlug(subdomainMatch[1]);
}
// Custom domain: look up in database
const tenant = await db.query(
`SELECT org_id FROM custom_domains
WHERE domain = $1 AND verified_at IS NOT NULL AND active = TRUE`,
[host]
);
return tenant.rows[0] ? getTenantById(tenant.rows[0].org_id) : null;
}
// Custom domains table
CREATE TABLE custom_domains (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id),
domain TEXT NOT NULL UNIQUE, -- e.g., 'auth.acme.com'
verified_at TIMESTAMPTZ,
cert_provisioned_at TIMESTAMPTZ,
active BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
TLS certificate provisioning
You need a valid TLS certificate for the customer's custom domain. Let's Encrypt via the ACME protocol is the standard approach. The ACME HTTP-01 challenge requires your server to respond to a challenge at http://auth.acme.com/.well-known/acme-challenge/TOKEN. Since the CNAME already points to your infrastructure, you control that path. Certificate provisioning can be automated with libraries like acme-client (Node.js) or certbot.
import acme from 'acme-client';
async function provisionCertificate(domain, orgId) {
const client = new acme.Client({
directoryUrl: acme.directory.letsencrypt.production,
accountKey: await acme.crypto.createPrivateKey(),
});
// Register ACME account
await client.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:certs@yourapp.com'],
});
// Order certificate
const order = await client.createOrder({
identifiers: [{ type: 'dns', value: domain }],
});
const [authorization] = await client.getAuthorizations(order);
const httpChallenge = authorization.challenges
.find(c => c.type === 'http-01');
const keyAuthorization = await client.getChallengeKeyAuthorization(httpChallenge);
// Store challenge response (served at /.well-known/acme-challenge/{token})
await redis.setex(
`acme_challenge:${httpChallenge.token}`,
300,
keyAuthorization
);
// Trigger challenge verification
await client.verifyChallenge(authorization, httpChallenge);
await client.waitForValidStatus(httpChallenge);
// Complete the order and get certificate
const [privateKey, csr] = await acme.crypto.createCsr({
commonName: domain,
});
await client.finalizeOrder(order, csr);
const cert = await client.getCertificate(order);
// Store cert and key (use a secrets manager, not the database)
await storeCertificate(orgId, domain, cert, privateKey.toString());
await db.query(
'UPDATE custom_domains SET cert_provisioned_at = NOW(), active = TRUE WHERE org_id = $1 AND domain = $2',
[orgId, domain]
);
}
Per-tenant OIDC discovery documents
When using a custom domain for auth, the OIDC discovery document must reference the custom domain as the issuer. This is critical: JWTs issued through the custom domain must have an iss claim matching the custom domain's URL, and the JWKS endpoint referenced in the discovery document must also be accessible at the custom domain.
// Serve OIDC discovery at custom domain
// GET https://auth.acme.com/.well-known/openid-configuration
app.get('/.well-known/openid-configuration', async (req, res) => {
const tenant = await resolveTenantFromHost(req.hostname);
if (!tenant) return res.status(404).end();
const issuer = `https://${req.hostname}`; // use the request host, not your default
res.json({
issuer,
authorization_endpoint: `${issuer}/oauth/authorize`,
token_endpoint: `${issuer}/oauth/token`,
userinfo_endpoint: `${issuer}/oauth/userinfo`,
jwks_uri: `${issuer}/.well-known/jwks.json`,
end_session_endpoint: `${issuer}/oauth/logout`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
subject_types_supported: ['public'],
id_token_signing_alg_values_supported: ['ES256'],
code_challenge_methods_supported: ['S256'],
});
});
Login discovery: finding the right tenant
When a user navigates to your main login page at app.yourapp.com, you need to route them to their organization's SSO configuration. The standard approach: ask for the user's email address first, look up the domain, and redirect to the appropriate SSO or custom-domain login endpoint. This is the "email-first" login discovery pattern used by most enterprise SaaS platforms.
// Email-first login: discover SSO config from email domain
async function discoverLoginConfig(email) {
const domain = email.split('@')[1].toLowerCase();
const ssoConfig = await db.query(`
SELECT
o.id as org_id,
o.slug,
sso.type, -- 'saml' | 'oidc'
sso.discovery_url,
cd.domain as custom_domain
FROM organizations o
JOIN sso_configurations sso ON sso.org_id = o.id AND sso.active = TRUE
LEFT JOIN custom_domains cd ON cd.org_id = o.id AND cd.active = TRUE
WHERE o.id = (
SELECT org_id FROM verified_domains WHERE domain = $1
)
`, [domain]);
if (!ssoConfig.rows[0]) {
return { type: 'password', loginUrl: '/login' };
}
const config = ssoConfig.rows[0];
const baseUrl = config.custom_domain
? `https://${config.custom_domain}`
: `https://${config.org_slug}.yourapp.com`;
return {
type: config.type,
loginUrl: `${baseUrl}/oauth/authorize`,
orgId: config.org_id,
};
}
Certificate renewal is handled automatically by your ACME client: check certificate expiry 30 days ahead and trigger renewal. Store certificates in a secrets manager with versioning, and use your load balancer's certificate store to serve the correct cert for each domain. Cloudflare for SaaS, AWS Certificate Manager with wildcard, and Caddy's automatic HTTPS feature are common approaches to simplifying this operational complexity at scale.