Domain age as a signup risk signal
· 7 min read · Disposable email
Domain age is the time since a domain was registered, and for signups on company-looking domains it is one of the more useful cheap risk signals: a domain registered a few days before the signup is far more likely to be throwaway infrastructure than one registered ten years ago. You get it from RDAP, the registration data protocol that replaced WHOIS, by reading the registration event on the domain record. Use it to add friction to young domains, not to block them, because real new companies also start on young domains.
Why domain age carries signal
Someone running a signup farm on their own domain buys it shortly before they use it. Domains are cheap, and a burned domain gets replaced with a fresh one. The same is true for new disposable email services: their domains are often only days or weeks old when they show up in your signups, and that is exactly the window where community blocklists haven't caught up yet. We covered that lag in why disposable email blocklists go stale. Age is a way to see a domain that no list knows about.
The signal only makes sense for domains that one person or organization controls. Asking how old gmail.com or outlook.com is tells you nothing about the user. The same goes for university and government domains. So the first rule is: compute age only for domains that classify as company, and only after you've ruled out known disposable domains.
Where the date comes from
RFC 9083 defines the RDAP JSON response. A domain object carries an events array, and each event has an eventAction and an eventDate. A real response for stripe.com from the Verisign RDAP server looks like this (trimmed):
"events": [
{ "eventAction": "registration", "eventDate": "1995-09-12T04:00:00Z" },
{ "eventAction": "expiration", "eventDate": "2027-09-11T04:00:00Z" },
{ "eventAction": "last changed", "eventDate": "2025-10-01T01:39:51Z" }
]
The registration event is the creation date. last changed moves whenever the record is updated (nameserver changes, renewals, transfers), so don't use it as age.
RDAP is now the main protocol for gTLD registration data. ICANN ended the contractual requirement for most gTLD registries and all registrars to run WHOIS on 28 January 2025, and RDAP became the definitive source. If you're still parsing free-text WHOIS output, RDAP vs WHOIS covers the move.
Finding the right RDAP server
There's no single RDAP server for all domains. Each registry runs its own, and IANA publishes a bootstrap file at https://data.iana.org/rdap/dns.json that maps TLDs to base URLs. RFC 9224 describes the format: a services array where each entry pairs a list of TLDs with a list of base URLs, matched label by label from the right, longest match first.
The shortcut is https://rdap.org/domain/<name>, which answers with a 302 to the authoritative server. rdap.org's own docs say it allows a maximum of 10 requests in 10 seconds per client (a 429 after that), returns 404 when it knows no RDAP service for the TLD, and recommends using the bootstrap files directly for high volume. For a signup path, use the bootstrap file. It's one download you cache for a day.
Coverage gaps
Not every TLD has RDAP. When we checked the IANA bootstrap file in 2026, it had entries for gTLDs like .com, .org, .xyz and .top, and for ccTLDs like .uk, .fr, .nl and .in, but no entry for .de, .io, .co or .jp, among others. For those, you get no answer from this method. The code below returns "unknown" in that case, and your policy must treat unknown as neutral.
Code: domain age from RDAP
This works in Node 18+, Deno, Bun and Cloudflare Workers. It expects an ASCII (punycode) registrable domain, which is what a lookup API should hand you.
type Bootstrap = { services: [string[], string[]][] };
type RdapEvent = { eventAction: string; eventDate: string };
export type AgeResult =
| { registered: string; days: number }
| { registered: null; days: null; reason: 'no_rdap_service' | 'not_found' | 'no_registration_event' | 'lookup_failed' };
let bootstrap: { fetchedAt: number; byTld: Map<string, string> } | null = null;
async function rdapBaseFor(domain: string): Promise<string | null> {
if (!bootstrap || Date.now() - bootstrap.fetchedAt > 86_400_000) {
const res = await fetch('https://data.iana.org/rdap/dns.json');
if (!res.ok) throw new Error(`bootstrap fetch failed: ${res.status}`);
const body = (await res.json()) as Bootstrap;
const byTld = new Map<string, string>();
for (const [tlds, urls] of body.services) {
const url = urls.find((u) => u.startsWith('https://')) ?? urls[0];
for (const tld of tlds) byTld.set(tld.toLowerCase(), url.endsWith('/') ? url : `${url}/`);
}
bootstrap = { fetchedAt: Date.now(), byTld };
}
// RFC 9224: match labels from the right, longest match wins ("co.uk" before "uk").
const labels = domain.toLowerCase().split('.');
for (let i = 1; i < labels.length; i++) {
const base = bootstrap.byTld.get(labels.slice(i).join('.'));
if (base) return base;
}
return null;
}
export async function registrationDate(domain: string): Promise<AgeResult> {
try {
const base = await rdapBaseFor(domain);
if (!base) return { registered: null, days: null, reason: 'no_rdap_service' };
const res = await fetch(`${base}domain/${domain}`, {
headers: { accept: 'application/rdap+json' },
signal: AbortSignal.timeout(5000),
});
if (res.status === 404) return { registered: null, days: null, reason: 'not_found' };
if (!res.ok) return { registered: null, days: null, reason: 'lookup_failed' };
const body = (await res.json()) as { events?: RdapEvent[] };
const reg = body.events?.find((e) => e.eventAction === 'registration');
if (!reg) return { registered: null, days: null, reason: 'no_registration_event' };
const days = Math.floor((Date.now() - Date.parse(reg.eventDate)) / 86_400_000);
return { registered: reg.eventDate, days };
} catch {
return { registered: null, days: null, reason: 'lookup_failed' };
}
}
A 404 from the registry usually means the domain isn't registered. That is a separate signal (the address can't receive mail), and a DNS check will tell you the same thing faster.
Combining age with a domain lookup
pingutil does not return domain age, WHOIS or RDAP data. What it gives you is the part that decides whether age is worth checking: the category, the registrable domain, and a verdict. Run the lookup first, then spend RDAP calls only on the domains where age changes the answer.
import { registrationDate } from './domain-age';
const ageCache = new Map<string, { registered: string | null; cachedAt: number }>();
async function cachedRegistration(domain: string) {
const hit = ageCache.get(domain);
if (hit && Date.now() - hit.cachedAt < 30 * 86_400_000) return hit.registered;
const r = await registrationDate(domain);
// Cache successful answers for a month. Don't cache failures for long.
if (r.registered) ageCache.set(domain, { registered: r.registered, cachedAt: Date.now() });
return r.registered;
}
export async function screenSignup(email: string) {
const res = await fetch('https://pingutil.com/v1/lookup', {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.PINGUTIL_KEY}`,
'content-type': 'application/json',
},
body: JSON.stringify({ input: email, skip_enrichment: true }),
});
if (!res.ok) return { action: 'allow', reason: 'lookup_unavailable' } as const;
const data = await res.json();
if (data.verdict.is_spam) return { action: 'block', reason: data.verdict.reason } as const;
if (data.category !== 'company') return { action: 'allow', reason: data.category } as const;
const registered = await cachedRegistration(data.domain); // registrable domain, punycode
if (!registered) return { action: 'allow', reason: 'age_unknown' } as const;
const days = Math.floor((Date.now() - Date.parse(registered)) / 86_400_000);
const looksRandom = data.email_signals?.domain_looks_random ?? false;
if (days < 7) return { action: 'hold', reason: 'domain_under_7_days', days } as const;
if (days < 30 && looksRandom) return { action: 'review', reason: 'young_random_domain', days } as const;
return { action: 'allow', reason: 'established_domain', days } as const;
}
Three design choices worth copying:
- Cache the registration date, not the age. The date doesn't change, the age does. Compute days at read time. Key on the registrable domain so
mail.newco.xyzandnewco.xyzshare an entry; caching domain lookups covers keys and TTLs. - Unknown is neutral. No RDAP service, a timeout or a 429 all return
allow. Fail open on enrichment signals; you can't block a German company because.delacks a bootstrap entry. - The thresholds are placeholders. Seven and thirty days are starting points. Pull your own confirmed abuse cases, look at the age distribution, and pick cutoffs from that.
The innocent young domains
Before you wire hold to anything harsh, list who else lands there:
- New companies. A founder who registered
theircompany.comlast month and signs up for your product this week is a good lead. - Rebrands and spin-outs. An established team can move to a brand-new domain overnight.
- Personal domains. Someone who just bought
theirname.devfor email.
That's why the suggested action is "hold": let the account exist, but delay the things abusers want (free credits, outbound sending, invites, API quota) until email confirmation plus one more signal clears. If the domain also has no website and a random-looking label, the combination is much stronger than any single fact.
How age can mislead in the other direction
An old domain is not proof of a good user.
- Expired domains get re-registered. When a domain is deleted and someone registers it again, the registry normally records a new registration date. But abusers who know age is checked can buy aged domains on the aftermarket, keeping the old date and getting a fresh owner. RDAP's event registry also defines
reregistration, but not every registry populates it. - Compromised accounts on old domains. A real mailbox at a ten-year-old company can be taken over or used by an insider.
- Transfers keep the date. Moving a domain between registrars doesn't reset
registration, so a recent change of hands is invisible here.
Age moves a score. It doesn't settle one.
A checklist for adding domain age
- Classify first. Skip age for free, ISP, education, government and known disposable domains.
- Resolve the RDAP server from the IANA bootstrap file and cache that file for a day.
- Read the
registrationevent and store the date per registrable domain. - Treat missing RDAP coverage, timeouts and rate limits as unknown, never as risky.
- Hold valuable actions for very young domains instead of rejecting the signup.
- Combine age with other signals in a simple email risk score, and review the cutoffs against real cases every few months.
To see what the classification step returns for a given domain before you write any code, the disposable domain category pages at /category/disposable list what the blocklist side already catches.
Questions people ask
- How do I check how old a domain is?
- Query RDAP, the JSON successor to WHOIS. The domain object returned by the registry has an events array, and the entry with eventAction registration holds the creation date. Find the right registry with the IANA RDAP bootstrap file or let rdap.org redirect you.
- Is a newly registered domain always suspicious?
- No. New startups, rebrands and people who just bought a personal domain all sign up from young domains. Domain age is a reason to add friction or hold valuable actions, not a reason to block on its own.
- Can I get domain age for every TLD?
- No. gTLD registries are required to run RDAP, but many country-code registries don't publish an RDAP service in the IANA bootstrap file. Treat a missing answer as unknown and never penalize a user for it.