Skip to content
Linux Maintenance · 8 min read

Domain Transfer Rejected: 2304 clientTransferProhibited Despite an AuthCode

Moving 19 domains off a closing reseller account: zone exports, AuthCodes, RDAP lock checks, a stdlib Python script and why an AuthCode is not an unlock.

My reseller account at my old domain provider is being closed. About 19 domains sit on it, a mix of .de, .com, .at and .app. Some of them I bill to other people. Anything that doesn't get moved will expire and be deleted by the old provider at some point. So everything has to go to a new registrar, and none of the websites or mailboxes on those domains should notice.

The first batch of transfers bounced. All of them, same error. This post is about that error and the order I'm doing the move in.

TL;DR: Export every zone first and check it against live DNS with dig. Get AuthCodes for every domain. Lower the TTL to 300. Create the zones at the new registrar, then start the transfer with "keep current nameservers". An AuthCode does not mean the transfer lock is off. Check the status with RDAP before you order anything: client transfer prohibited means it will fail with 2304. Only active (without the transfer lock) goes through.

Check the lock before you order

for d in example.com example.net example.app; do
  j=$(curl -sL "https://rdap.org/domain/$d")
  status=$(echo "$j" | jq -r '.status | join(", ")')
  changed=$(echo "$j" | jq -r '.events[]? | select(.eventAction=="last changed") | .eventDate')
  printf '%-22s %-45s %s\n' "$d" "$status" "$changed"
done

Output looks like this (example values):

example.com            client transfer prohibited, active         2026-10-30T...
example.net            active                                     2026-10-31T...
example.app            client transfer prohibited                 2026-03-12T...

rdap.org is a redirector. It sends you to the right registry's RDAP server, so -L matters. The status array is what you want. The "last changed" event is a nice bonus: when you ask support to unlock a batch, that date tells you which domains they actually touched.

Old-school works too:

whois example.com | grep -i status

On a locked .com you'll see clientTransferProhibited in there. Not every ccTLD gives you the same output in whois or has RDAP behind the redirector, so for .de or .at the result can look different. Check what your TLD shows before you trust a missing line.

The plan, in this order

  1. Zone exports. I asked support for exports of all zones. Then I compared them with live DNS using dig on a few records per domain. Identical. That export is my safety net if the old account disappears mid-move.
  2. AuthCodes for every domain. Also called EPP codes or transfer codes.
  3. TTL to 300. So that the nameserver switch later takes minutes, not a day. Why that matters: lower your DNS TTL before a migration.
  4. Create the zones at the new registrar. Same records as the export.
  5. Transfer, with the option "keep current nameservers". The domain changes registrar, but DNS still answers from the old nameservers while the transfer runs.
  6. NS switch last. Only after the zone at the new registrar is imported and I've checked it with dig directly against the new nameserver:
dig +short MX example.com @ns1.new-registrar.example
dig +short TXT default._domainkey.example.com @ns1.new-registrar.example

If those match the export, I switch the NS records. If not, nothing has happened yet and nobody notices.

Mail cutover and pointing records at the new server come after all of this. One change at a time. If mail breaks during a registrar move and a server move at the same time, good luck finding out which one did it. How I set up mail auth on a fresh domain is in SPF, DKIM and DMARC from zero.

Some of the AuthCodes came as one-time secret links. Those links are gone the moment you open them. Even if you just click it to see if it works, the code is burned and you have to ask again.

So: open the link only when the transfer form or your script is ready, copy the code straight into where it goes, done. Not in a browser preview, not "quickly on the phone".

Attempt one: 7 domains, 7 rejects

On 30.10 late in the evening I ordered the transfer for 7 domains in the new registrar's web UI. Every single one came back with:

2304 clientTransferProhibited

2304 is the EPP code for "object status prohibits operation". The status that prohibits it is clientTransferProhibited, which is set by the current registrar (the "client" in EPP talk is the registrar, not you). The old provider had handed out the AuthCodes but never removed the lock.

That's the lesson of this whole post: having an AuthCode does not mean the domain is unlocked. These are two separate things. The AuthCode proves you're allowed to move it. The lock says whether the registry accepts a transfer at all. You need both.

I asked support to unlock. A while later RDAP showed 4 of the 7 as plain active, 3 still had client transfer prohibited. If I had checked RDAP first, I would have saved myself the first round of rejects.

clientTransferProhibited vs serverTransferProhibited

Both block a transfer, but different people own them:

  • clientTransferProhibited: set by the registrar. The old provider can remove it. In most panels this is the "transfer lock" toggle. If you're a reseller and the toggle is greyed out, support has to do it.
  • serverTransferProhibited: set by the registry. Your registrar can't just flip it. This shows up with disputes, legal holds or special registry rules. Then you're talking to the registrar, who talks to the registry.

And then there's the 60-day rule for gTLDs like .com: after a fresh registration or a previous transfer, a domain usually can't be moved again for 60 days. If a domain was only registered last month, a transfer will fail no matter what you unlock.

Attempt two: a script instead of the web UI

Clicking through 19 domains in a web UI, each with records and an AuthCode, is how you end up with a typo in a DKIM key. So on 31.10 I wrote a small Python script against the new registrar's JSON-RPC API. Only the standard library, urllib and json, nothing to install.

What it does per run:

  1. Log in. Credentials only from environment variables, and the 2FA one-time code from an env var too, if 2FA is on.
  2. Look up my contact handle, the owner contact the transfer needs.
  3. Per domain: create the DNS zone as type MASTER, with the records parsed from the exported zone file. TTL 300. SOA and NS are skipped, the new registrar sets its own.
  4. Start the transfer with the AuthCode.

The structure, sanitized (method names depend on your registrar's API, these are placeholders):

import json, os, re, sys, urllib.request, http.cookiejar

API = "https://api.new-registrar.example/jsonrpc/"
jar = http.cookiejar.CookieJar()
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))

def call(method, **params):
    body = json.dumps({"method": method, "params": params}).encode()
    req = urllib.request.Request(API, body, {"Content-Type": "application/json"})
    with opener.open(req, timeout=30) as r:
        return json.load(r)

def parse_zone(path, domain):
    recs = []
    for line in open(path, encoding="utf-8"):
        line = strip_comment(line).strip()   # ignores ; inside quotes, see below
        if not line:
            continue
        name, rtype, data = split_record(line, domain)
        if rtype in ("SOA", "NS"):
            continue
        if rtype == "TXT":
            # "v=DKIM1; k=rsa; " "p=MIIB..." -> one string, no spaces added
            data = "".join(re.findall(r'"([^"]*)"', data))
        recs.append({"name": name, "type": rtype, "content": data, "ttl": 300})
    return recs

DOMAINS = ["example.com", "example.net"]
go = "--go" in sys.argv
transfer_only = "--transfer-only" in sys.argv
only = next((a.split("=", 1)[1].split(",") for a in sys.argv if a.startswith("--only=")), None)

call("login", user=os.environ["REG_USER"], password=os.environ["REG_PASS"],
     otp=os.environ.get("REG_OTP"))
contact = call("contact.list")["contacts"][0]["id"]

for domain in DOMAINS:
    if only and domain not in only:
        continue
    recs = parse_zone(f"zones/{domain}.zone", domain)
    print(domain, len(recs), "records", "(dry-run)" if not go else "")
    if not go:
        continue
    if not transfer_only:
        call("zone.create", domain=domain, type="MASTER", records=recs)
    res = call("domain.transfer", domain=domain, authcode=os.environ["AUTH_" + domain.replace(".", "_").upper()],
               owner=contact, keep_nameservers=True)
    print(domain, res.get("code"), res.get("msg"))

split_record and strip_comment are left out. The first pulls name, type and data out of a zone line and makes names absolute, the second is below in the gotcha.

Three switches matter:

  • Default is dry-run. It parses everything, prints record counts, sends nothing. --go makes it real.
  • --transfer-only for re-runs. The zones already exist after the first run, I don't want every record created twice.
  • --only d1,d2 to retry just the domains that failed.

Gotcha: TXT records in zone files

Two traps in the parser. First, long TXT records like DKIM keys are split into several quoted strings in the zone file, because one string is max 255 characters. They have to be joined without a space in between, otherwise the key is broken and DKIM fails silently. Second, DKIM and SPF values contain ;, which is also the comment character in zone files. A naive "cut everything after ;" chops your DKIM record in half. The comment strip has to ignore ; inside quotes. I compared the parsed record counts per domain against the export before the first --go.

Where it stands

Result of the script run on 31.10:

Result Count Meaning
1001 3 Accepted, action pending. Transfer is running.
2304 3 Still clientTransferProhibited. Waiting on the old provider.
2202 1 Authorization error. The AuthCode was rejected, I need a fresh one.

1001 is EPP for "completed successfully, action pending". It's not done, it's in the queue. For .com the losing registrar can approve the transfer early. If it doesn't, it completes on its own after about 5 days. .de and .at registries have their own transfer rules, so I'm not counting on that 5-day window there.

2202 usually means the code is wrong or expired. Could be a copy issue, could be that the old provider generated a new code in the meantime. Either way: ask for a new one, then --transfer-only --only that domain.

So at the time of writing: 3 domains in flight, 3 waiting on an unlock, 1 waiting on a new code. The other 12 of the 19 I don't need anymore, those I let expire. DNS for the 7 still answers from the old nameservers, which is the point of doing it this way. Nothing is down, nothing has switched. I'll do the NS switch per domain once its transfer is through and the zone checks out against the new nameserver.

If you're in the same spot because a provider is closing your account: get the zone exports and the AuthCodes first, today. Everything else can wait, those can't. Same idea as in what a provider outage taught me about backups: get your data out while you still can.


Related posts