Diagnostic Domain Lifecycle
Transfer blocked by registrar lock near expiry
The domain is about to expire, you are trying to move it to a new registrar, and the transfer keeps getting rejected. The domain is not dead yet. The registrar is still holding a lock on it, and that lock does not care how many days are left on the clock. Here is why the lock and the countdown are two separate problems, and what order to fix them in.
Registrars set the clientTransferProhibited status on a domain by default, and registrants can also turn it on themselves, to stop unwanted transfers. If that lock is still on when the domain is close to expiring, an outbound transfer will be rejected even though the domain has not expired yet. The lock and the expiry date are independent settings, so nothing about being close to expiry removes the lock for you. Check RDAP for both the status list and the expiration date, log in to the current registrar to turn the lock off, then either let auto-renew finish first or unlock and transfer with enough days to spare before the deadline.
The problem in plain words
Think of the transfer lock as a separate switch from the expiration clock. The clock counts down toward the date the registration runs out. The lock is a flag that says "do not let this domain leave this registrar." Both can be true at the same time: a domain can be five days from expiring and also locked, and those two facts do not affect each other at all.
The trouble starts when nobody notices the lock is still on until they actually try to transfer the domain, usually right when the expiry date is close and the pressure is highest. The losing registrar, or the registry itself, will reject the transfer request outright while that status is set, no matter how urgent the expiry countdown feels. This is purely an account-level flag. It has nothing to do with your DNS zone or your nameservers, which is why the site keeps working fine while the transfer keeps failing.
Why it happens
ICANN's EPP status codes and its transfer policy explain the rule plainly: clientTransferProhibited is a registrar-set status meant to stop unauthorized transfers, and while it is present the registry will not process an inter-registrar transfer request. A few reasons people run into this specifically near an expiry date:
- The lock is on by default at most registrars and nobody ever turned it off, because most owners never plan to transfer until they suddenly need to.
- The domain was registered, transferred, or had its registrant contact changed recently, which triggers ICANN's mandatory 60 day post-change lock that no portal setting can remove early.
- The owner only discovers the lock when a renewal reminder pushes them to shop for a cheaper registrar at the last minute, so the lock and the deadline collide at the worst possible time.
- Some registries also reject transfers inside a short window right before expiration, commonly around the last five days, which stacks on top of any lock that is still set.
None of this touches the zone. dig +short NS example.com still returns the right nameservers the whole time, because the block sits at the registrar and registry layer, not in DNS delegation.
The lock and the expiry countdown are independent settings that only feel connected because they show up together. Unlocking the domain does not renew it, and renewing the domain does not unlock it. You may need to do both, and the safest order is usually to let the current registrar renew the domain first, then unlock and transfer afterward with no deadline pressure.
The fix, as a flow
Start by checking RDAP for both facts at once: the status array and the expiration event. If the lock is on and the expiry date is close, do not treat this as one problem. Decide whether to renew first or transfer first, based on how many days are actually left, then act at the registrar, since this is not something a DNS zone change can touch.
How to fix it
Confirm the lock and the expiry date together with RDAP
Query RDAP and look at the status array and the expiration event in the same response. This tells you whether you are dealing with a lock, a near-term expiry, or both at once.
curl -s https://rdap.org/domain/example.com | jq '{status: .status, events: [.events[] | select(.eventAction=="expiration" or .eventAction=="last changed")]}'
# A bad result looks like:
# {
# "status": ["client transfer prohibited", "active"],
# "events": [{"eventAction": "expiration", "eventDate": "2026-08-05T00:00:00Z"}]
# }
# with today's date only 25 days before that expiration date.
Cross-check with classic WHOIS and confirm DNS is untouched
WHOIS shows the same two facts in the older format, and a quick NS lookup confirms this is not a DNS delegation problem at all.
whois example.com | grep -i "Registry Expiry Date\|Domain Status"
# Bad result:
# Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
# Registry Expiry Date: 2026-08-05T00:00:00Z
# Delegation is fine even while the lock blocks the transfer:
dig +short NS example.com
Turn the lock off in the current registrar's dashboard
This is a registrar-portal action, not a zone change. Find "Domain Lock" or "Transfer Lock," usually on the domain overview page or an EPP status panel, and switch it off so the status changes from clientTransferProhibited to a plain unlocked state.
Before:
Domain Status: clientTransferProhibited https://icann.org/epp#clientTransferProhibited
After:
Domain Status: ok https://icann.org/epp#ok
; Where to toggle it off, by registrar:
; Namecheap: Domain List -> Manage -> "Registrar Lock" -> OFF
; GoDaddy: Domain Settings -> "Lock" -> OFF
; Cloudflare: Domain page -> "Locked" -> OFF
Check whether the mandatory 60 day lock applies instead
If the domain was registered, transferred, or had a registrant/WHOIS contact change in the last 60 days, ICANN's transfer policy blocks the transfer regardless of what the portal toggle shows. No setting removes this early. You have to wait out the remaining days from that triggering event before a transfer can go through.
Decide whether to renew first or transfer first
Since expiry is close, pick one path. Either let auto-renew process at the current registrar first, since renewal does not need the lock to be off, and transfer afterward with no deadline pressure. Or unlock immediately, request the EPP auth code, and complete the transfer with enough buffer days before the expiration date, since most registries also block transfers in the last few days before expiry.
How to check it worked
Re-query RDAP and WHOIS to confirm the lock is gone, then confirm the transfer or renewal actually completed.
# 1. Confirm the lock is off
curl -s https://rdap.org/domain/example.com | jq '.status'
# Good result: something like ["active"] only, no "client transfer prohibited"
# 2. Cross-check with WHOIS
whois example.com | grep -i "Domain Status"
# Good result: no clientTransferProhibited line at all
# 3. After requesting the transfer, confirm the gaining registrar accepted it
# (no EPP 2304 / "Object status prohibits operation" error)
# 4. Confirm the outcome: expiration date moved forward (renewed)
# or the transfer completed before the old expiry date
curl -s https://rdap.org/domain/example.com | jq '.events[] | select(.eventAction=="expiration")'
Allow fifteen minutes to a few hours for the registry to reflect the unlock. A good result is an RDAP status array with no transfer-prohibited entries, a WHOIS reply with no clientTransferProhibited line, and either a moved-forward expiration date after renewal, or a completed transfer confirmation before the old expiry date. A bad result is a repeated EPP 2304 error, which means either the lock is still set, or the mandatory 60 day post-change lock is still in effect.
The full code
This script is detect-and-report only. Removing a registrar transfer lock is an account or registrar-portal action, not something the Cloudflare DNS zone API can do, so there is no automated repair step here, only a check that flags the risky combination of a lock plus a near-term expiry.
View this code on GitHub Full runnable folder with tests in the dns-fixes repo.
"""Detect a domain that is both transfer-locked and close to expiring.
Detection only. Removing a registrar transfer lock is an account or
registrar-portal action, not a Cloudflare DNS zone API call, so this
script never attempts a repair, it only reports the risky combination.
"""
import os
import logging
from datetime import datetime, timezone
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("check_transfer_lock_risk")
DNS_DOMAIN = os.environ.get("DNS_DOMAIN", "example.com")
CLOUDFLARE_API_TOKEN = os.environ.get("CLOUDFLARE_API_TOKEN", "")
CLOUDFLARE_ZONE_ID = os.environ.get("CLOUDFLARE_ZONE_ID", "")
DRY_RUN = os.environ.get("DRY_RUN", "true").lower() == "true"
WARNING_DAYS = int(os.environ.get("WARNING_DAYS", "30"))
LOCK_STATUSES = {"clienttransferprohibited", "servertransferprohibited"}
def assess_transfer_risk(status_codes: list, expiration_date: datetime, now: datetime, warning_days: int = 30) -> dict:
"""
Pure decision logic, no I/O.
Input: status_codes (raw RDAP/WHOIS status strings, any case/spacing),
expiration_date (the domain's expiration as a datetime),
now (the current time as a datetime, injected for testability),
warning_days (how many days out counts as "near expiry").
Output: {
"locked": bool,
"days_until_expiry": int,
"at_risk": bool,
}
Logic: normalize status_codes to lowercase with spaces removed, and check
membership against clienttransferprohibited/servertransferprohibited.
Compute days_until_expiry as the whole number of days between now and
expiration_date. at_risk is True only when locked is True AND
days_until_expiry <= warning_days AND days_until_expiry >= 0.
"""
normalized = {s.lower().replace(" ", "") for s in status_codes}
locked = bool(normalized & LOCK_STATUSES)
days_until_expiry = (expiration_date - now).days
at_risk = locked and 0 <= days_until_expiry <= warning_days
return {
"locked": locked,
"days_until_expiry": days_until_expiry,
"at_risk": at_risk,
}
def fetch_rdap(domain: str) -> dict:
"""Query RDAP for a domain and return the raw JSON response."""
import requests
r = requests.get(f"https://rdap.org/domain/{domain}", timeout=30)
r.raise_for_status()
return r.json()
def extract_expiration(rdap_data: dict) -> datetime:
"""Pull the expiration eventDate out of an RDAP response."""
for event in rdap_data.get("events", []):
if event.get("eventAction") == "expiration":
iso = event["eventDate"].replace("Z", "+00:00")
return datetime.fromisoformat(iso).astimezone(timezone.utc)
raise ValueError("No expiration event found in RDAP response")
def report(domain: str, result: dict, status_codes: list) -> None:
"""Report the finding. Replace with email, Slack, or a webhook in production."""
if not result["at_risk"]:
log.info(
"OK: %s locked=%s, %d day(s) until expiry, no action needed",
domain, result["locked"], result["days_until_expiry"],
)
return
prefix = "[dry run] would flag" if DRY_RUN else "FLAG"
log.warning(
"%s: %s is transfer-locked with only %d day(s) left before expiry. statuses=%s. "
"Unlock at the registrar dashboard, or let auto-renew process first. "
"This cannot be fixed through the Cloudflare DNS zone API.",
prefix, domain, result["days_until_expiry"], status_codes,
)
def run():
log.info("Checking transfer lock risk for %s (DRY_RUN=%s)", DNS_DOMAIN, DRY_RUN)
rdap_data = fetch_rdap(DNS_DOMAIN)
status_codes = rdap_data.get("status", [])
expiration_date = extract_expiration(rdap_data)
now = datetime.now(timezone.utc)
result = assess_transfer_risk(status_codes, expiration_date, now, WARNING_DAYS)
report(DNS_DOMAIN, result, status_codes)
if __name__ == "__main__":
run()
/**
* Detect a domain that is both transfer-locked and close to expiring.
* Detection only. Removing a registrar transfer lock is an account or
* registrar-portal action, not a Cloudflare DNS zone API call, so this
* script never attempts a repair, it only reports the risky combination.
*/
import { pathToFileURL } from "node:url";
const DNS_DOMAIN = process.env.DNS_DOMAIN || "example.com";
const CLOUDFLARE_API_TOKEN = process.env.CLOUDFLARE_API_TOKEN || "";
const CLOUDFLARE_ZONE_ID = process.env.CLOUDFLARE_ZONE_ID || "";
const DRY_RUN = (process.env.DRY_RUN || "true").toLowerCase() === "true";
const WARNING_DAYS = Number(process.env.WARNING_DAYS || 30);
const LOCK_STATUSES = new Set(["clienttransferprohibited", "servertransferprohibited"]);
/**
* Pure decision logic, no I/O.
* Input: statusCodes (raw RDAP/WHOIS status strings, any case/spacing),
* expirationDate (the domain's expiration as a Date),
* now (the current time as a Date, injected for testability),
* warningDays (how many days out counts as "near expiry").
* Output: { locked: boolean, daysUntilExpiry: number, atRisk: boolean }
* Logic: normalize statusCodes to lowercase with spaces removed, and check
* membership against clienttransferprohibited/servertransferprohibited.
* Compute daysUntilExpiry as the whole number of days between now and
* expirationDate. atRisk is true only when locked is true AND
* daysUntilExpiry <= warningDays AND daysUntilExpiry >= 0.
*/
export function assessTransferRisk(statusCodes, expirationDate, now, warningDays = 30) {
const normalized = new Set(statusCodes.map((s) => s.toLowerCase().replace(/\s+/g, "")));
const locked = [...LOCK_STATUSES].some((s) => normalized.has(s));
const daysUntilExpiry = Math.floor((expirationDate.getTime() - now.getTime()) / 86400000);
const atRisk = locked && daysUntilExpiry >= 0 && daysUntilExpiry <= warningDays;
return { locked, daysUntilExpiry, atRisk };
}
/** Query RDAP for a domain and return the raw JSON response. */
async function fetchRdap(domain) {
const res = await fetch(`https://rdap.org/domain/${domain}`);
if (!res.ok) throw new Error(`RDAP returned ${res.status} for ${domain}`);
return res.json();
}
/** Pull the expiration eventDate out of an RDAP response. */
function extractExpiration(rdapData) {
const event = (rdapData.events || []).find((e) => e.eventAction === "expiration");
if (!event) throw new Error("No expiration event found in RDAP response");
return new Date(event.eventDate);
}
/** Report the finding. Replace with email, Slack, or a webhook in production. */
function report(domain, result, statusCodes) {
if (!result.atRisk) {
console.log(
`OK: ${domain} locked=${result.locked}, ${result.daysUntilExpiry} day(s) until expiry, no action needed`
);
return;
}
const prefix = DRY_RUN ? "[dry run] would flag" : "FLAG";
console.warn(
`${prefix}: ${domain} is transfer-locked with only ${result.daysUntilExpiry} day(s) left before expiry. ` +
`statuses=${JSON.stringify(statusCodes)}. Unlock at the registrar dashboard, or let auto-renew process first. ` +
"This cannot be fixed through the Cloudflare DNS zone API."
);
}
export async function run() {
console.log(`Checking transfer lock risk for ${DNS_DOMAIN} (DRY_RUN=${DRY_RUN})`);
const rdapData = await fetchRdap(DNS_DOMAIN);
const statusCodes = rdapData.status || [];
const expirationDate = extractExpiration(rdapData);
const now = new Date();
const result = assessTransferRisk(statusCodes, expirationDate, now, WARNING_DAYS);
report(DNS_DOMAIN, result, statusCodes);
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
run().catch((e) => { console.error(e); process.exit(1); });
}
Add a test
The pure decision function is the part worth testing, since it decides what gets flagged. It takes plain status strings and datetimes, so the tests need no network and no RDAP account.
from datetime import datetime, timezone
from check_transfer_lock_risk import assess_transfer_risk
NOW = datetime(2026, 7, 11, tzinfo=timezone.utc)
def test_at_risk_when_locked_and_within_window():
expiration = datetime(2026, 8, 5, tzinfo=timezone.utc)
result = assess_transfer_risk(["clientTransferProhibited", "active"], expiration, NOW, 30)
assert result["locked"] is True
assert result["days_until_expiry"] == 25
assert result["at_risk"] is True
def test_not_at_risk_when_unlocked():
expiration = datetime(2026, 8, 5, tzinfo=timezone.utc)
result = assess_transfer_risk(["active"], expiration, NOW, 30)
assert result["locked"] is False
assert result["at_risk"] is False
def test_not_at_risk_when_locked_but_far_from_expiry():
expiration = datetime(2027, 1, 1, tzinfo=timezone.utc)
result = assess_transfer_risk(["clientTransferProhibited"], expiration, NOW, 30)
assert result["locked"] is True
assert result["at_risk"] is False
def test_not_at_risk_when_already_expired():
expiration = datetime(2026, 7, 1, tzinfo=timezone.utc)
result = assess_transfer_risk(["clientTransferProhibited"], expiration, NOW, 30)
assert result["days_until_expiry"] == -10
assert result["at_risk"] is False
def test_server_transfer_prohibited_also_counts_as_locked():
expiration = datetime(2026, 7, 20, tzinfo=timezone.utc)
result = assess_transfer_risk(["serverTransferProhibited"], expiration, NOW, 30)
assert result["locked"] is True
assert result["at_risk"] is True
def test_status_normalization_is_case_and_space_insensitive():
expiration = datetime(2026, 7, 25, tzinfo=timezone.utc)
result = assess_transfer_risk(["Client Transfer Prohibited"], expiration, NOW, 30)
assert result["locked"] is True
import { test } from "node:test";
import assert from "node:assert/strict";
import { assessTransferRisk } from "./check-transfer-lock-risk.js";
const NOW = new Date("2026-07-11T00:00:00Z");
test("at risk when locked and within window", () => {
const expiration = new Date("2026-08-05T00:00:00Z");
const result = assessTransferRisk(["clientTransferProhibited", "active"], expiration, NOW, 30);
assert.equal(result.locked, true);
assert.equal(result.daysUntilExpiry, 25);
assert.equal(result.atRisk, true);
});
test("not at risk when unlocked", () => {
const expiration = new Date("2026-08-05T00:00:00Z");
const result = assessTransferRisk(["active"], expiration, NOW, 30);
assert.equal(result.locked, false);
assert.equal(result.atRisk, false);
});
test("not at risk when locked but far from expiry", () => {
const expiration = new Date("2027-01-01T00:00:00Z");
const result = assessTransferRisk(["clientTransferProhibited"], expiration, NOW, 30);
assert.equal(result.locked, true);
assert.equal(result.atRisk, false);
});
test("not at risk when already expired", () => {
const expiration = new Date("2026-07-01T00:00:00Z");
const result = assessTransferRisk(["clientTransferProhibited"], expiration, NOW, 30);
assert.equal(result.daysUntilExpiry, -10);
assert.equal(result.atRisk, false);
});
test("serverTransferProhibited also counts as locked", () => {
const expiration = new Date("2026-07-20T00:00:00Z");
const result = assessTransferRisk(["serverTransferProhibited"], expiration, NOW, 30);
assert.equal(result.locked, true);
assert.equal(result.atRisk, true);
});
test("status normalization is case and space insensitive", () => {
const expiration = new Date("2026-07-25T00:00:00Z");
const result = assessTransferRisk(["Client Transfer Prohibited"], expiration, NOW, 30);
assert.equal(result.locked, true);
});
Case studies
The registrar switch that stalled at the worst time
A team found a cheaper registrar and decided to move a domain that had 18 days left before renewal. The transfer request failed instantly with an EPP status error. Nobody had ever touched the default transfer lock since the domain was first registered years earlier.
They logged in to the current registrar, found the lock toggle on the domain overview page, switched it off, and requested the EPP auth code. The transfer completed two days later, comfortably before the expiration date.
The 60 day lock nobody remembered agreeing to
A domain owner updated their registrant email address after a company rebrand, then tried to transfer the domain five weeks later as expiry approached. The registrar's lock toggle showed unlocked, yet the transfer still failed with the same EPP error.
Support confirmed the mandatory 60 day post-registrant-change lock from ICANN's transfer policy was still in effect. Since the toggle could not remove it, the team let auto-renew process the domain at the current registrar and scheduled the transfer for after the 60 day window closed.
A domain that is easy to move has clientTransferProhibited off well before anyone needs to transfer it, and renewal happens automatically regardless of lock state. Check RDAP for the lock status and the expiration date together on a routine schedule, the same way you would check a TLS certificate, so a transfer decision never has to happen under deadline pressure.
FAQ
Why is my domain transfer being rejected right before it expires?
Most likely the registrar still has clientTransferProhibited set on the domain. This lock blocks any transfer request no matter how close the domain is to its expiration date. The lock and the expiry countdown are two separate things, and the transfer fails until the lock comes off.
Will unlocking my domain cause it to expire faster?
No. The transfer lock and the expiration date are unrelated settings. Turning the lock off does not change the expiry date, and it does not renew the domain either. You still need to renew or transfer before the domain actually expires.
Can I unlock a domain through my DNS provider's API?
No. The transfer lock is a registrar account setting, not a DNS zone record, so a DNS provider's zone API cannot touch it. You need to log in to the registrar's dashboard directly, or use that registrar's own domain-lock endpoint if it offers one.
Related field notes
Citations
On the problem:
- ICANN: EPP Status Codes, what do they mean, and why should I know? icann.org/resources/pages/epp-status-codes
- ICANN Transfer Policy. icann.org/en/contracted-parties/accredited-registrars
- NameSilo: Why Can't I Transfer My Domain? The 60-Day Lock Explained. namesilo.com/blog
On the solution:
- Cloudflare Registrar docs: Troubleshoot failed domain transfers. developers.cloudflare.com/registrar/troubleshooting
- AWS re:Post Knowledge Center: Remove the ClientTransferProhibited status from a Route 53 domain. repost.aws/knowledge-center
- DNSimple Help: ICANN 60-Day Lock After Change of Registrant. support.dnsimple.com/articles
Stuck on a tricky one?
If you have a DNS, delegation, or domain lifecycle problem you would rather hand off, this is the kind of work I do. Message me and we can work through it together.
Did this get your transfer moving?
If this saved your domain from an expiry scare, you can buy me a coffee. It is the best way to keep these field notes free and growing.
Buy me a coffee on Ko-fi