Custom domain SSO: vanity endpoints for enterprise customers

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'],
  });
});
If a customer's OIDC client is configured to use their custom domain as the issuer, JWTs issued before the custom domain was set up (with your default domain as issuer) will fail validation. Document this clearly: custom domain configuration requires issuing new tokens. Existing sessions using the old issuer will expire naturally; new logins via the custom domain will have the correct issuer.

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.

← Back to blog Try Bastionary free →