How to Prove a Domain Is Yours: An Introduction to Domain Control Validation

Sorry for my English! English is not my native language. One of the reasons to create this blog is to improve my English writing. So I will be highly obliged if you will help me with this. If you find a grammar error on this page, please select it with your mouse and press Ctrl+Enter.

I ran into this topic while building my own application — the kind that gets installed on a user's site and works in tandem with a remote server. The scheme looked trivial at first glance: on installation, the module sends its domain and email to the server, the server registers a new client, generates a key pair, and returns it. From there, the module talks to the server, signing its requests with its own secret.

Everything looked simple right up until I started asking myself some uncomfortable questions.

How do I verify that a request actually came from the domain it claims? After all, a domain is just a string in a POST request body. I could put anything there. Even google.com.

What stops an attacker from registering someone else's domain? They send a request with a competitor's domain, get valid keys back — and from there either act on that domain's behalf, or simply squat on it, so the real owner hits "this domain is already registered" when they try to install the module.

What happens if a user loses their token? They updated the CMS, settings got wiped, they migrated the site, restored an old backup. The registration already exists on the server, but there are no local keys. Re-registering is blocked, and handing back the existing secret opens a hole: anyone can claim someone else's domain and walk away with their keys.

And what if the secret is compromised? There needs to be a way to revoke it and issue a new one — and by definition that mechanism has to be available to the user, which means it's also available to the attacker.

All four questions boil down to the same unsolved problem: I had no way to tie a claimed domain to whoever was actually making the request. Digging further, I found out this problem has an established name — Domain Control Validation (DCV).

The core idea

DCV boils down to one sentence: whoever controls the domain can do something on it that an outsider can't.

The rest is a matter of choosing that "something." There are a few classic options:

HTTP validation. The server asks you to place a file with a unique value at a known path, then fetches it itself. This is how the ACME http-01 challenge works, which is how Let's Encrypt issues certificates, and it's essentially how Google Search Console verifies site ownership too.

DNS validation. A TXT record with a token gets added to the domain's zone. Slower due to DNS propagation, but it doesn't require a working web server and works even before a site goes live.

Email validation. A message sent to an administrative address on the domain. Historically used by certificate authorities; now considered the weakest method, since it proves control over an inbox, not over the domain.

Callback (loopback). The most interesting option if you're writing modules or plugins. If your application is already installed on the site, the server can simply call back to that site and talk to it directly. The user doesn't have to do anything, which matters a lot for module installs — any manual step means dropped users and support tickets.

One nonce isn't enough

A naive callback implementation looks like this: the module generates a random value, saves it locally, and sends it along with the registration request. The server calls back to the domain, the module returns the saved value, the server compares.

The logic seems sound: an attacker can claim someone else's domain, but they can't force that site to return their random value.

But it's easy to get the implementation wrong here, and the mistake kills the whole scheme. If the endpoint on the module's side simply echoes back whatever it received in the request parameters, the check becomes theater: the server itself sends the victim's site the attacker's value, the victim obediently echoes it, the check passes. You must return only what's stored locally — never what arrived in the request.

A more elegant solution shows up in Jetpack, the plugin that connects self-hosted WordPress to WordPress.com. It uses a two-secret handshake:

  1. The site generates two random values — call them secret_1 and secret_2 — and sends both to the server along with the registration data.
  2. The server doesn't respond immediately. Instead, it makes a callback request to the site, passing only secret_1.
  3. The site checks that secret_1 matches what it stored, deletes both secrets, and responds with the second one — secret_2.
  4. The server verifies secret_2 and only then responds to the original request, issuing an ID and a token.

The elegance is in the separation of roles. secret_1 proves to the site that it's really the server that received the registration talking to it. secret_2 proves to the server that it's talking to the exact site that initiated the request — because secret_2 was never sent over the callback channel and can't be echoed back. Both sides authenticate each other in a single handshake.

The secrets live with a short TTL (10 minutes in Jetpack's case) and are deleted immediately after use.

Recovery: reissue, don't return

This turned out to be the biggest shift in how I thought about the problem.

As long as I was framing it as "the user lost their key, I need to give it back," I inevitably ended up designing an endpoint that hands out an existing secret. And that kind of endpoint is a gift to an attacker, no matter how many checks you wrap around it.

The right framing is different: a secret is never handed out twice. It is only reissued. Lost your key? Go through the same handshake again — the old token gets revoked, a new one gets issued. There's no "give me back my secret" endpoint to exist in the first place, so there's nothing to attack.

Making this work without losing user data required a change in the data model: separating a long-lived client entity (settings, history, plan) from an installation (a key pair, module version, status). An installation is disposable — revoke it, issue a new one. Settings live one level up and survive any number of reinstalls. Shopify and Slack, incidentally, are built the same way: the store or workspace lives long-term, while the access token is tied to a specific installation and gets rotated.

What I didn't expect

A few things came up along the way that turned out to matter just as much as the cryptography itself.

A callback is an SSRF primitive. You're literally making your server fetch a URL supplied by an unknown party. Private IP ranges, localhost, 169.254.169.254 (cloud metadata) all need to be blocked, redirects limited, and timeouts and response size capped.

Rate limiting is mandatory. Otherwise the reissue mechanism becomes a DoS tool — spam requests against someone else's domain and disrupt their service. A telling example: in one popular WooCommerce licensing solution, resetting activations required no secret from the software at all, and could be triggered right from a browser.

Domain normalization. www or not, http or https, port, trailing dot, punycode, case. Without canonicalization, example.com and www.example.com become two different clients, and you'll get a stream of support tickets.

Callbacks fail more often than you'd think. Cloudflare with a challenge enabled, basic auth on staging, an endpoint closed off for security reasons, a fresh domain whose DNS hasn't propagated yet. Automated verification covers most cases, but not all — so you need a fallback ladder: callback first, then an email to a verified address, and manual admin confirmation as the last resort.

Idempotency. A scenario I hadn't even considered: the server registers a client, but the response is lost to a timeout. The registration exists on the server; the user has no keys locally. A retry just hits "already registered." The fix is an installation identifier generated by the client, not the server — that way a repeated request is simply recognized as a repeat.

Takeaway

DCV is one of those topics you don't know exists until you start building your own registration system. And once you do, you find out the problem has already been solved many times over, with all the pitfalls long since mapped and labeled.

The main thing I took away: you should verify not what a client claims, but what it can demonstrate. Any string in a request is a claim, not proof. Proof only appears once you ask for an action that only the actual domain owner can perform.

Tags: 

Add new comment

CAPTCHA
Spam protection
Target Image