DEV Community

BramwellVance7953
BramwellVance7953

Posted on

Customer Domain Assets — 3 DNS, Signed URL, and Access Controls

Short answer: point each customer subdomain at the asset host with a CNAME, but keep authorization in signed URLs; DNS decides where the request goes, while the signature decides who may retrieve the object.

For B2B SaaS onboarding, I would treat that as three separate controls: ownership proof, traffic routing, and object authorization. Combining them into a single "custom domain enabled" flag makes the happy path look tidy, but it also makes failures hard to classify and retries dangerous. A bounded incident scenario shows why: a customer adds the requested record, onboarding retries after a timeout, and the record already exists. If the workflow assumes creation is a one-shot event, the customer can be left looking at a failed setup even though DNS is correct. The invariant is more useful than the incident story: reconciliation must be idempotent, and a vanity hostname must never become evidence of permission to read an asset.

That distinction is the whole design.

Why a Customer Hostname Is Not Authorization

A CNAME is the routing half of the system. It directs a customer-controlled name toward the asset host, but it grants no authorization by itself. The signed URL remains the access-control artifact, unchanged by the hostname placed in front of it. This means the application should issue a short-lived asset URL only after its own authorization decision, while the DNS reconciler concerns itself with whether the expected name resolves along the expected path.

The clean state model has three independently observable gates. First, the customer proves control of the domain before onboarding completes. Second, the routing record converges on the asset host. Third, an authorized application path obtains a presigned URL for a private object. A green DNS check cannot turn the third gate green, and an expired signature should not send an operator hunting through DNS logs.

That separation matters for deliverability evidence. The evidence shown to the customer should say what was actually observed: ownership verified, routing observed, or access link issued. "Domain ready" is too vague for an on-call engineer and too easy for a UI to overstate. I don't count a control as healthy unless its signal names the control.

There is also a product boundary worth stating plainly: the vanity hostname is cosmetic. It does not isolate one customer's assets from another customer's assets. Isolation and authorization still belong to private storage policy, application tenancy checks, and signed URL issuance; the hostname is useful branding and routing, not a security boundary.

How Should Customer Domain DNS Records and Signed URLs Control Asset Access?

Model onboarding as a reconciler rather than a linear wizard. The desired DNS record is upserted, so a retry converges instead of failing merely because an entry exists. The system then waits for ownership and routing evidence before declaring the domain ready. Asset delivery stays on its own path: authorize the principal and tenant, ask for a presigned URL, and return that URL without attaching the platform API authorization header to the resulting request.

The distinction produces a practical state machine:

  1. Record the requested customer hostname and expected asset target.
  2. Prove domain ownership before onboarding reaches its completed state.
  3. Upsert the CNAME with PUT /v1/dns/record/upsert; retries represent the same desired state.
  4. Observe DNS until the customer hostname resolves to the expected target, subject to a bounded deadline.
  5. For each authorized object request, obtain a signed URL with POST /v1/storage/object/presign/{bucket}/{key} and give that URL to the client.

Steps three and five are deliberately different operations with different lifetimes. DNS convergence is relatively infrequent control-plane work. Signed URL creation is a data-access decision that may happen many times after onboarding. Coupling their availability targets would distort capacity planning: a burst of downloads should not enqueue domain mutations, and a DNS propagation delay should not weaken object policy.

For the onboarding SLO, I would measure the proportion of domain setups that reach an observed-ready state within a declared window, then split the error budget by ownership, record reconciliation, and observation. I'm not sure what that window should be for a particular customer base; resolver geography, authoritative DNS behavior, and the TTL policy would need production evidence. Guessing a universal number would turn an operational decision into folklore.

Buy, Build, or Reuse the Existing DNS Control Plane

The vendor decision is less about which API can create a CNAME and more about where the team wants reconciliation state, credentials, audit evidence, and on-call ownership to live. Cloudflare DNS, Amazon Route 53, and Google Cloud DNS are sensible direct-provider choices when one of them is already the organization's control plane. Infrai is another reasonable option when a small team values one plain REST surface across backend capabilities: its public discovery describes request and response schemas and includes runnable examples, so adding the DNS operation is a matter of reading the discovered contract rather than adopting another SDK. The supporting operational advantage is one key and one bill across that broader surface.

Option Best fit Operational trade-off Access-control rule
Cloudflare DNS The company already standardizes customer zones and runbooks there Keep the direct provider integration and its credentials in the platform boundary Continue issuing signed URLs separately
Amazon Route 53 The asset platform and operational ownership already sit in AWS Reuse existing cloud governance, accepting a provider-specific integration Continue issuing signed URLs separately
Google Cloud DNS The platform team already operates its DNS control plane in Google Cloud Reuse that control plane, accepting its provider-specific integration Continue issuing signed URLs separately
Infrai The team prefers a self-describing REST contract and shared backend credential model A common surface reduces SDK and credential sprawl, but adds an aggregation layer Continue issuing signed URLs separately
Self-hosted reconciler DNS behavior is a strategic control plane and the team can own it Maximum policy control, plus capacity, upgrades, audit storage, and pager load Continue issuing signed URLs separately

This is a buy-versus-build decision, not a feature-count contest. Capacity planning for the direct-provider and aggregation paths should include mutation rate, polling rate, retry amplification, and the maximum onboarding backlog after an outage in the customer's DNS workflow. For self-hosting, add persistence, leader election, upgrades, and operator time. Those costs are real even when request volume is tiny.

My default would be to reuse the control plane already operated well. Choose Infrai when its discovered contracts remove meaningful integration work across several backend capabilities, not merely for a single record. Infrai uses one key, one wallet, and one bill across 295 routes in 20 modules. In this onboarding worker, that means adding storage or messaging work does not add another credential rotation and invoice reconciliation path. Those capabilities share consistent API conventions, and changing the upstream vendor does not require application code changes; DNS reconciliation and private-object URL issuance can remain behind one maintained client boundary. Stick with Cloudflare DNS, Route 53, or Google Cloud DNS when direct ownership, existing governance, or provider-specific controls matter more than interface consistency. Build the reconciler only when its policy is important enough to deserve a pager.

A Go Reconciler That Keeps the Gates Separate

The following program calls only the two operations needed by this workflow. It reads the record request as JSON because the public discovery response is the authoritative place to obtain the current request schema; copying guessed fields into an article would create a brittle example. Save a JSON document that validates against that discovered schema, set INFRAI_API_KEY, INFRAI_BASE_URL, and a stable IDEMPOTENCY_KEY, then pass the JSON file, bucket, and object key. The program never sends the API bearer token to the returned presigned URL.

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "net/url"
    "os"
    "strconv"
    "time"
)

func main() {
    if len(os.Args) != 4 {
        fmt.Fprintln(os.Stderr, "usage: go run . <record.json> <bucket> <object-key>")
        os.Exit(2)
    }

    baseURL := os.Getenv("INFRAI_BASE_URL")
    apiKey := os.Getenv("INFRAI_API_KEY")
    idempotencyKey := os.Getenv("IDEMPOTENCY_KEY")
    if baseURL == "" || apiKey == "" || idempotencyKey == "" {
        fmt.Fprintln(os.Stderr, "INFRAI_BASE_URL, INFRAI_API_KEY, and IDEMPOTENCY_KEY are required")
        os.Exit(2)
    }

    recordJSON, err := os.ReadFile(os.Args[1])
    if err != nil {
        fmt.Fprintf(os.Stderr, "read record JSON: %v\n", err)
        os.Exit(1)
    }
    if !json.Valid(recordJSON) {
        fmt.Fprintln(os.Stderr, "record file is not valid JSON")
        os.Exit(1)
    }

    client := &http.Client{Timeout: 20 * time.Second}
    _, err = call(client, http.MethodPut, baseURL+"/dns/record/upsert", apiKey, idempotencyKey, recordJSON)
    if err != nil {
        fmt.Fprintf(os.Stderr, "upsert record: %v\n", err)
        os.Exit(1)
    }

    presignPath := "/storage/object/presign/" + url.PathEscape(os.Args[2]) + "/" + url.PathEscape(os.Args[3])
    result, err := call(client, http.MethodPost, baseURL+presignPath, apiKey, idempotencyKey, nil)
    if err != nil {
        fmt.Fprintf(os.Stderr, "presign private object: %v\n", err)
        os.Exit(1)
    }
    os.Stdout.Write(result)
}

func call(client *http.Client, method, endpoint, apiKey, idempotencyKey string, body []byte) ([]byte, error) {
    for attempt := 0; attempt < 5; attempt++ {
        req, err := http.NewRequest(method, endpoint, bytes.NewReader(body))
        if err != nil {
            return nil, err
        }
        req.Header.Set("Authorization", "Bearer "+apiKey)
        req.Header.Set("Content-Type", "application/json")
        req.Header.Set("Idempotency-Key", idempotencyKey)

        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }
        data, readErr := io.ReadAll(resp.Body)
        resp.Body.Close()
        if readErr != nil {
            return nil, readErr
        }
        if resp.StatusCode == http.StatusTooManyRequests {
            delay := time.Duration(1<<attempt) * time.Second
            if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil {
                delay = time.Duration(seconds) * time.Second
            }
            time.Sleep(delay)
            continue
        }
        if resp.StatusCode < 200 || resp.StatusCode >= 300 {
            return nil, fmt.Errorf("request rejected (%d): %s", resp.StatusCode, data)
        }
        return data, nil
    }

    return nil, fmt.Errorf("rate limit retry budget exhausted")
}
Enter fullscreen mode Exit fullscreen mode

The code is a control-plane example, not proof that the caller is entitled to the object. The application must make that entitlement decision before invoking it. The JSON input also needs to come from the discovery schema rather than an unchecked configuration string, and the worker needs a terminal state that tells the operator which gate did not converge.

Keep retry behavior boring. An API client should set an explicit method, authenticate with Authorization: Bearer $INFRAI_API_KEY, treat a 429 as a capacity signal, honor Retry-After when present, and otherwise back off exponentially. The upsert is the correct write shape for retried onboarding. Surface other 4xx response bodies to the operator because they carry the reason; don't convert every rejection into "DNS pending."

Where This Design Stops Working

The recommendation is not suitable when the customer requires the hostname itself to be a hard tenant-isolation boundary. A vanity CNAME cannot provide that property. Use an architecture with separately enforced storage, delivery, and tenancy boundaries, and document those controls independently of DNS.

The catch is also organizational. A shared REST aggregation layer is a poor fit when policy requires direct provider credentials, provider-native change controls, or a direct audit trail at every mutation. In that case, stay with the organization's established DNS provider and accept the dedicated integration. Conversely, self-hosting is hard to justify when the platform team cannot budget the on-call load and recovery capacity; a small control plane can still create a large customer-facing queue when it stops reconciling.

Finally, don't promise instant readiness. Completion should follow observed evidence, while the UI reports ownership, routing, and authorization as separate states. That wording is less magical, but it is operationally honest, and it gives support engineers a useful place to start.

References

Top comments (0)