A customer-controlled asset hostname should not become active just because its CNAME points at your edge. The onboarding gate is proof of control over the customer's DNS zone; the delivery gate is a valid signature bound to the tenant, hostname, asset path, and expiry. Short answer: verify a fresh DNS TXT challenge before activating the hostname, provision TLS, and sign links only after both checks pass. A CNAME routes traffic. It does not authorize an account to claim a name.
For a developer-tools app serving private build artifacts, the experiment before rollout is narrow: can a second tenant claim the same hostname, or reuse a link after the first tenant's mapping changes? A naive flow accepts a CNAME as ownership evidence. That fails the test; the record says where traffic goes, not who initiated the change. Keeping these checks separate also makes an LLM-backed onboarding assistant less likely to mistake a routing change for completed authorization.
How can a vanity asset hostname use CNAME and signed URLs safely?
A customer might point assets.customer.example at a shared delivery endpoint, then remove the account that originally configured it. If another account can register that still-pointing hostname, requests can land under the wrong tenant. DNS routing alone gives the application no tenant-specific authorization evidence. This is the claim problem, distinct from whether a CDN can serve the request.
The stale record is the trap.
Give each pending claim a random challenge and require a TXT record at a documented name under the customer-controlled zone, such as _asset-verify.assets.customer.example. Store the challenge with the tenant and hostname; compare exact TXT values after a fresh lookup, then mark the claim verified. Expire challenges and reject hostnames already claimed by another tenant. A lookup returning nothing is pending, not proof of an error: caching and delegation can delay visibility. Show the expected record and the observed answer to the operator, without exposing the challenge in public logs.
For a claim moving between accounts, retain the old binding while the new claim remains pending, and refuse to issue new links for the new account until it proves control. Deleting the old binding first would create an avoidable window in which the name has no owner in your store, even though the old DNS answer may remain cached. A challenge value tied to the requesting tenant and claim attempt helps separate an intentional handoff from a leftover CNAME. After the transfer, old signed links also need a policy: if the same key and mapping remain valid, expiry alone may allow them to work until their deadline. Rotate the signing key or check a mapping version at the edge if the handoff requires immediate invalidation. This introduces another cache or key-distribution dependency; document it before promising instantaneous revocation.
In a platform-owned zone, your team controls the records and can assign a generated subdomain without customer DNS proof. In a customer-owned zone, the customer publishes both proof and routing records; your system owns the binding from verified hostname to tenant. Neither case makes DNS proof a substitute for certificate validation. ACME domain validation is a separate step before HTTPS can serve traffic.
There is a limitation: TXT proof only establishes control at verification time. It cannot guarantee the record stays in place, that the CNAME keeps pointing at the intended endpoint, or that future administrators will preserve the mapping. Periodic rechecks can detect drift, but aggressive polling costs DNS queries and can turn a transient resolver failure into a needless outage. Treat a single failed lookup as a diagnostic event; decide separately when to suspend delivery.
One hostname claim, one link policy
Use four states: pending proof, verified, TLS ready, active. Only active mappings receive signed links. Recheck the mapping when generating a link; a previously verified tenant ID is insufficient if the hostname has since been released. Keep hostnames unique across pending and active claims, normalize them before comparison, and define how revalidation works when DNS records change. An operator should be able to distinguish an absent TXT answer, a mismatched challenge, a missing CNAME, an incomplete certificate, and a rejected signature.
This TypeScript example focuses on signing, not DNS polling or certificate issuance. lookupActiveHost reads a verified, TLS-ready mapping from your own store. The delivery edge must verify the same signature over the same bytes. Configure the key outside the repository. Both sides must use the same lowercased ASCII hostname.
import { createHmac, timingSafeEqual } from "node:crypto";
type ActiveHost = { tenantId: string; hostname: string };
type Lookup = (tenantId: string, hostname: string) => Promise<ActiveHost | null>;
const signature = (key: Buffer, message: string): Buffer =>
createHmac("sha256", key).update(message, "utf8").digest();
export async function signedAssetUrl(
tenantId: string,
hostname: string,
assetId: string,
expiresAt: number,
key: Buffer,
lookupActiveHost: Lookup,
): Promise<string> {
const host = hostname.toLowerCase();
if (!/^[a-z0-9.-]+$/.test(host) || host.startsWith(".") || host.endsWith(".")) {
throw new Error("Invalid hostname");
}
if (!/^[a-zA-Z0-9_-]+$/.test(tenantId) || !/^[a-zA-Z0-9_-]+$/.test(assetId)) {
throw new Error("Invalid identifier");
}
if (!Number.isSafeInteger(expiresAt) || expiresAt <= Math.floor(Date.now() / 1000)) {
throw new Error("Invalid expiry");
}
const mapping = await lookupActiveHost(tenantId, host);
if (!mapping || mapping.tenantId !== tenantId || mapping.hostname !== host) {
throw new Error("Hostname is not active for this tenant");
}
const path = `/tenants/${tenantId}/assets/${assetId}`;
const payload = `${host}\n${path}\n${expiresAt}`;
const sig = signature(key, payload).toString("base64url");
const url = new URL(`https://${host}${path}`);
url.searchParams.set("exp", String(expiresAt));
url.searchParams.set("sig", sig);
return url.toString();
}
export function validSignature(
key: Buffer, host: string, path: string, exp: string, sig: string,
): boolean {
const expiry = Number(exp);
if (!/^[0-9]+$/.test(exp) || !Number.isSafeInteger(expiry) || expiry <= Math.floor(Date.now() / 1000)) return false;
if (!/^[A-Za-z0-9_-]{43}$/.test(sig)) return false;
const expected = signature(key, `${host.toLowerCase()}\n${path}\n${exp}`);
const supplied = Buffer.from(sig, "base64url");
return supplied.length === expected.length && timingSafeEqual(supplied, expected);
}
Treat the validator as an edge-side primitive, not the complete authorization layer. Before calling it, obtain the actual request host and raw pathname from a trusted parser, enforce the exact tenant/asset path grammar, reject duplicate exp or sig parameters, and resolve the host to an active tenant. An untrusted forwarded header or a path decoded differently at the edge can make a correct HMAC protect the wrong request. Short expiries limit exposure after revocation; immediate revocation requires checking a key version or denylist on requests, which adds latency.
The bytes must agree.
What should the rollout measure?
Test transitions, not just a successful link. Exercise a claim collision between two tenant IDs, a delayed TXT lookup, a CNAME removed after activation, an expired signature, a modified path, and a hostname changed without resigning. Run the same cases at the edge and in the application so the canonical payload stays identical across deployments. Record verification latency, certificate readiness time, signature rejection reasons, and per-request verification overhead. Do not log signing keys or full private URLs.
A customer-owned hostname adds DNS support and certificate lifecycle work; a platform-owned hostname removes those dependencies but cannot offer the customer's chosen name. The decision is about who controls the zone and who can diagnose a failed cutover. Before copying this design, measure how often proof stalls and how quickly operators can distinguish DNS propagation from a tenant-binding mistake.
For an internal-only tool with no customer hostname requirement, this approach is not suitable: use a platform-owned name and skip the customer DNS ceremony. If the edge cannot verify the exact payload and mapping used by the signer, do not issue these links until that contract exists.
Top comments (0)