info@altius-group.ch
Froideville, Vaud
FR

Certificates for domains that do not exist yet

/ 10 min de lecture / mis à jour 04.09.2026

A multi-tenant SaaS where customers bring their own domain has a certificate problem that has no good pre-provisioned answer.

You cannot issue certificates in advance, because the names do not exist yet — a customer buys cabinet-lambert.ch on a Tuesday afternoon and expects it to serve HTTPS on Tuesday afternoon. You cannot use a wildcard, because *.names.legal says nothing about cabinet-lambert.ch. You can automate DNS-01 per domain, and then you are maintaining an API integration with every registrar your customers might use, plus a renewal job, forever.

The answer is on-demand TLS: issue the certificate during the first TLS handshake for a hostname you have never seen. Caddy implements it in about eight lines of configuration, and those eight lines contain one that is the entire security of the arrangement.

This is the edge configuration of names.legal, where each customer runs on their own domain against an isolated PostgreSQL schema.

Eight lines

{
    email admin@names.legal

    on_demand_tls {
        ask http://nameslegal:8000/internal/tls-ask/
        interval 2m
        burst 10
    }
}

:80 {
    redir https://{host}{uri} permanent
}

:443 {
    tls {
        on_demand
    }
    ...
}

:443 with on_demand and no site list. Caddy accepts a TLS handshake for any SNI, and if it has no certificate for that name it obtains one, live, while the browser waits. The first visitor to a new customer domain pays a second or two. Everyone after that is served from cache, and renewal is Caddy’s problem.

What makes this possible rather than reckless is ask.

The ask endpoint is the whole security model

Without ask, a server configured this way is a free certificate mint. Anyone can point whatever-they-like.com at your IP address, open a TLS connection, and Caddy will dutifully obtain a publicly-trusted certificate for a domain that has nothing to do with you, using your ACME account.

That is bad in three distinct ways, and only the first is obvious:

  • You are issuing certificates for names you have no relationship with.
  • You burn your rate limits. Let’s Encrypt limits per account and per registered domain. A few thousand junk issuances and your real customers cannot get certificates — a total outage for every new domain, caused by someone else, with a recovery time measured in days.
  • Every issuance is a disk write, an ACME round trip and a lock. A flood is a denial of service against the handshake path itself.

So ask is called before every issuance, and it decides:

@csrf_exempt
@require_GET
def caddy_tls_ask(request):
    domain = (request.GET.get("domain") or "").strip().lower().rstrip(".")
    if not domain:
        return HttpResponseForbidden("missing domain")

    if domain == "names.legal" or domain.endswith(".names.legal"):
        return HttpResponse("ok")

    if _is_known_domain(domain):
        return HttpResponse("ok")

    return HttpResponseForbidden("unknown domain")

A 200 means issue; anything else means refuse. Two sets are allowed: the platform itself, and any hostname that already routes to a tenant.

The second set is the important one, and it is what makes the design coherent: the answer to “should this name get a certificate?” is the same as the answer to “does this name route anywhere?” A domain enters customers.Domain when a customer buys it through the registrar integration or connects one they own — that is a deliberate, authenticated act. The certificate follows from it automatically, and from nothing else.

Note the normalisation on the first line, because each part earns its place. .strip() for whitespace. .lower() because DNS is case-insensitive and SNI is not guaranteed to arrive lowercased. And .rstrip(".") for the trailing dot — a fully-qualified name’s root label. example.com. and example.com are the same name to DNS and two different strings to Python, and a client is entitled to send either. Skip that one character and a legitimate customer domain is refused a certificate for a reason nobody will find quickly.

Defence by topology, written down

"""
Elle n'est volontairement joignable que depuis le réseau Docker interne (le
service `nameslegal` n'expose pas son port host en prod, cf.
docker-compose.prod.yml `ports: !reset []`) — pas d'authentification
supplémentaire nécessaire.
"""

It is deliberately reachable only from the internal Docker network — no additional authentication needed.

An unauthenticated endpoint that answers questions about your customer list is usually a finding. Here it is a decision, and the difference is entirely in whether the reasoning is recorded.

The endpoint is http://nameslegal:8000/... — a Docker service name, on the internal network. In production the application container does not publish a host port at all: ports: !reset [] clears whatever the base compose file declared. There is no route from the internet to that path, so the boundary is the network, not a token.

That is a legitimate design. Adding a shared secret would protect against exactly one scenario — an attacker already inside the Docker network — and an attacker there can query the database directly.

What makes it legitimate rather than lucky is the last clause: ports: !reset [] is named, in the docstring, with the file it lives in. The security property depends on a line in a YAML file three directories away, and the person who removes it will be debugging something unrelated. A comment that says this endpoint is safe because that line exists is the only thing standing between a convenience change and an exposed endpoint.

When your security boundary is topology, write down which line of configuration creates it.

The www bug

This is the part I would most want someone building this to read, because it is invisible until a customer reports it and infuriating to diagnose.

"""
`www.<domaine>` est accepté dès que `<domaine>` est connu : c'est exactement
la règle appliquée au routage par django-tenants (`remove_www` — www.x.ch et
x.ch résolvent le même tenant), et `customers.dns.seed_site_records` publie
lui-même un A `www` vers le VPS à l'achat. Sans cette équivalence ici, ce
www que NOUS publions n'obtenait jamais de certificat : la poignée de main
TLS échouait et le site était injoignable sur www, alors que l'apex marchait.
"""

Without this equivalence, the www that we publish never obtained a certificate: the TLS handshake failed and the site was unreachable on www, while the apex worked.

Follow the chain:

  1. When a customer buys a domain, the platform publishes two A records — apex and www — both pointing at the VPS. This is automatic and correct; people type www.
  2. django-tenants routes www.x.ch and x.ch to the same tenant, via remove_www. Also correct.
  3. The ask endpoint checked Domain.objects.filter(domain=domain) — an exact match. www.x.ch is not in the table. Only x.ch is.

So a visitor typing www.cabinet-lambert.ch resolved correctly to the VPS, Caddy asked, got a 403, and failed the TLS handshake. The browser showed a connection error, not a 404 — no HTTP response existed to carry an explanation. Meanwhile the apex worked perfectly, so every internal test passed and the customer’s own bookmark worked.

The fix mirrors the routing rule instead of restating it:

def _is_known_domain(domain):
    """Le hostname route-t-il vers un tenant ? (mêmes règles que le routage)"""
    from customers.models import Domain

    if Domain.objects.filter(domain=domain).exists():
        return True
    # Équivalence www — cf. django_tenants.utils.remove_www.
    return bool(domain.startswith("www.")
                and Domain.objects.filter(domain=domain[4:]).exists())

The docstring — same rules as the routing — is the actual fix. The code is the consequence.

There is a general principle here that costs people days:

Every component that answers “is this hostname ours?” must answer it the same way. The router, the certificate authoriser, the CORS check, the CSRF ALLOWED_HOSTS, the redirect validator. A hostname that one component accepts and another refuses produces a failure at whichever layer is lowest — and the lower the layer, the less it can tell you.

TLS is the lowest layer that can refuse. Its failures have no status code, no body, no logline in the application. That is why this particular disagreement was expensive, and why the rule is worth applying pre-emptively: when you add a fourth place that decides whether a hostname belongs to you, make it call the same function as the first three.

Rate limiting is availability, not politeness

interval 2m
burst 10

Ten issuance attempts per two minutes. It reads like courtesy toward the ACME provider. It is protecting you.

Every unknown SNI that reaches Caddy triggers an ask. An attacker sending random hostnames generates a request to your Django application per handshake — so without a limit, this configuration hands anyone a way to drive load into your application through the TLS layer, below every application-level rate limit you have, on a path that also does a database query.

burst 10 also bounds the damage from a misconfiguration on your own side. A bug that made ask return 200 too readily is contained at ten certificates per two minutes rather than as many as a script can request — which is the difference between an incident and an account-level rate-limit ban.

Set these before you need them. The moment you need them, you cannot deploy the change, because your edge is saturated.

Two Caddy behaviours that differ from nginx

Both are called out in the file, and both are migration traps.

# Host réel transmis au backend automatiquement par `reverse_proxy` (Caddy
# ne réécrit pas le Host par défaut, contrairement à d'autres proxys) —
# indispensable : django-tenants résout le tenant par le nom d'hôte.

Caddy preserves the Host header by default; nginx replaces it with the upstream name unless you write proxy_set_header Host $host. In a multi-tenant application resolving by hostname, losing the Host header means every request resolves to the same tenant — or none — and the failure is not an error. It is the wrong customer’s data, served successfully. Coming to Caddy this is a happy default; going the other way it is the thing to remember.

# Caddy n'impose pas de limite de taille de requête par défaut
# (contrairement à nginx) : pas besoin d'équivalent client_max_body_size.

The mirror image. nginx’s 1 MB default is the reason half the internet has hit 413 Request Entity Too Large on a file upload. Caddy has no such default, so the nginx workaround is unnecessary — and, worth noting, so is the accidental protection it provided. If uploads must be bounded, that bound now has to be stated somewhere deliberately rather than inherited from a proxy default.

Serving tenant media without serving XSS

handle_path /media/* {
    root * /app/media
    header X-Content-Type-Options nosniff
    file_server
}
# nosniff : empêche le navigateur de « deviner » un type actif à partir du
# contenu. Combiné à l'allowlist d'extensions (core.validators) qui bloque
# .svg/.html/.js à l'upload, referme le vecteur XSS via média servi sur
# l'origine du tenant.

Uploaded files are served from /media/ on the tenant’s own domain — the same origin as their application. Anything executable served from there runs with the tenant’s session cookies in scope.

Two independent layers close it:

An extension allow-list at upload, rejecting .svg, .html and .js. SVG belongs on that list and is the one people forget: it is an image everywhere in the interface and an XML document containing <script> in the browser.

X-Content-Type-Options: nosniff at serve time, so a file uploaded as .jpg whose bytes begin with <html> is not helpfully re-interpreted. Content sniffing is the mechanism that turns a passed allow-list check into an active document.

Either layer alone is a single point of failure — the allow-list can be bypassed by a new upload path that forgets to call the validator; nosniff can be undermined by a Content-Type set from a user-controlled value. Together they require two mistakes.

The general shape: serve untrusted files from a different origin if you can, and if you cannot, take away both the extension and the sniffing.

What to take away

  1. On-demand TLS is the right answer for customer domains. No wildcard, no per-registrar DNS integration, no renewal job.
  2. ask is not optional. Without it you are a public certificate mint and your rate limits belong to whoever finds you.
  3. Authorise exactly the names that already route somewhere. Certificate eligibility should be a consequence of a deliberate act, never its own decision.
  4. Normalise the hostname — lowercase, strip the trailing dot — before comparing it to anything.
  5. If topology is your security boundary, name the configuration line that creates it in the code that depends on it.
  6. Make every “is this hostname ours?” check share one rule. A disagreement surfaces at the lowest layer, and TLS failures carry no explanation.
  7. Set the issuance rate limit before you need it, because when you need it you cannot deploy.
Prêt à démarrer ?

Parlons de votre projet

Dites-nous vos besoins en IoT, SIG ou développement sur mesure — nous vous répondons sous 24 h.