eTLD+1 explained: why mail.stripe.com is stripe.com
· 6 min read · Company enrichment
The eTLD+1 of a hostname is its public suffix (the "effective top-level domain", like com, co.uk or com.au) plus one more label to the left. It is the shortest part of a name that someone can actually register, so mail.stripe.com and dashboard.stripe.com both have the eTLD+1 stripe.com, and forums.bbc.co.uk has bbc.co.uk. You can't compute it with string splitting; you need the Public Suffix List.
If you store, group, dedupe or cache anything by domain, this is the key you want. The rest of this post covers how the list works, where libraries disagree, and which edge cases bite in production.
Why "last two labels" is wrong
The tempting implementation looks like this:
// Don't do this
const rootDomain = (host) => host.split('.').slice(-2).join('.');
It works for mail.stripe.com. It fails for every country that registers names one level down:
| Hostname | Last two labels | Actual eTLD+1 |
|---|---|---|
mail.stripe.com |
stripe.com |
stripe.com |
forums.bbc.co.uk |
co.uk |
bbc.co.uk |
a.b.c.example.com.au |
com.au |
example.com.au |
eng.cs.harvard.edu |
harvard.edu |
harvard.edu |
city.kawasaki.jp |
kawasaki.jp |
city.kawasaki.jp |
The co.uk row is the dangerous one. Group users by that and every British company lands in one bucket. The Japanese row is stranger still, and we'll get to it.
There is no rule you can derive from the DNS itself that tells you where the registry boundary sits. The Public Suffix List exists because, in its maintainers' words, the only method is to create a list. Mozilla volunteers maintain it, and browsers use it to stop a site on example.co.uk from setting a cookie for all of co.uk.
How the Public Suffix List is structured
The list is a plain text file, one rule per line. At the time of writing it has roughly 10,300 rules. Three kinds of rule matter:
- Normal rules like
com,co.uk,com.au. A hostname ending in one of these has that suffix. - Wildcards like
*.ck. Any single label underckis itself a public suffix, sofoo.bar.ckhas the suffixbar.ckand eTLD+1foo.bar.ck. - Exceptions like
!www.ck. They carve a name back out of a wildcard, sowww.ckis registrable even though*.ckwould make it a suffix.
The matching algorithm, from the list's format spec: find every rule that matches, take the one with the most labels, and let an exception rule beat everything. If nothing matches, the default rule is *, which means an unknown TLD is treated as a suffix of one label.
That Japanese example comes from rules like *.kawasaki.jp with !city.kawasaki.jp as an exception. So city.kawasaki.jp is a registrable domain on its own, and a two-label split gets it wrong in the opposite direction.
ICANN section vs private section
The file is split by comment markers into two sections:
- ICANN domains: suffixes delegated through ICANN and the IANA root zone, such as
com,uk,co.uk. - Private domains: suffixes submitted by the companies that own them, such as
github.io,vercel.app,pages.dev,herokuapp.comands3.amazonaws.com. GitHub putgithub.iothere so thatalice.github.ioandbob.github.ioare treated as separate sites by browsers.
Whether you include the private section changes your answer for a big chunk of the internet, and this is where libraries quietly disagree.
Computing eTLD+1 in JavaScript, Python and Go
All three of these use the same list. They don't all use the same defaults. These outputs were checked against current releases.
JavaScript: tldts
import { parse, getDomain } from 'tldts';
getDomain('mail.stripe.com'); // 'stripe.com'
getDomain('forums.bbc.co.uk'); // 'bbc.co.uk'
getDomain('alice.github.io'); // 'github.io' (ICANN rules only)
getDomain('alice.github.io', { allowPrivateDomains: true }); // 'alice.github.io'
const p = parse('alice.github.io');
// p.domain === 'github.io', p.publicSuffix === 'io', p.isIcann === true
tldts ignores the private section unless you pass allowPrivateDomains: true.
Python: tldextract
import tldextract
# Use the bundled snapshot; by default it fetches the live list on first use
extract = tldextract.TLDExtract(suffix_list_urls=())
extract("forums.bbc.co.uk").top_domain_under_public_suffix # 'bbc.co.uk'
extract("alice.github.io").top_domain_under_public_suffix # 'github.io'
private = tldextract.TLDExtract(suffix_list_urls=(), include_psl_private_domains=True)
private("alice.github.io").top_domain_under_public_suffix # 'alice.github.io'
Two things to know. tldextract downloads the latest list over HTTP the first time it runs and caches it under ~/.cache/python-tldextract, which surprises people in locked-down containers. Passing suffix_list_urls=() makes it use the bundled copy. And registered_domain is the older name for top_domain_under_public_suffix; recent versions emit a deprecation warning for it.
Go: golang.org/x/net/publicsuffix
package main
import (
"fmt"
"golang.org/x/net/publicsuffix"
)
func main() {
for _, h := range []string{"mail.stripe.com", "forums.bbc.co.uk", "alice.github.io", "co.uk"} {
d, err := publicsuffix.EffectiveTLDPlusOne(h)
fmt.Println(h, d, err)
}
}
// mail.stripe.com stripe.com <nil>
// forums.bbc.co.uk bbc.co.uk <nil>
// alice.github.io alice.github.io <nil>
// co.uk publicsuffix: cannot derive eTLD+1 for domain "co.uk"
Go's package uses both sections by default, so alice.github.io stays whole. Its PublicSuffix function returns an icann boolean if you need to tell the two apart. If your Node service and your Go service both key on "eTLD+1", they will disagree on every github.io, vercel.app and netlify.app host unless you configure one of them to match the other. Pick a policy and write it down.
Which section should you use?
Ask what the key is for.
Security and isolation boundaries (cookies, CORS allowlists, rate-limit buckets per tenant, "same site" checks): include private domains. Two GitHub Pages users are different owners and must not share anything.
Organization grouping (CRM accounts, company enrichment, "how many signups from this company"): the ICANN-only answer is usually what you want, and the private-domain case is rare in email addresses anyway. Almost nobody receives mail at alice.github.io.
pingutil takes the second position. Every input goes through tldts with allowPrivateDomains: false, gets lowercased and punycoded, and is keyed on the result. So jane@mail.stripe.com, stripe.com and www.stripe.com all resolve to one cached row for stripe.com. The trade-off is honest: a lookup for a host under a platform domain like github.io describes the platform, not the person who owns the subdomain. For the inputs pingutil is built for (signup emails and company domains) that is the right call, but if you are building cookie logic, don't copy it.
You can see the normalization in the response: domain is the eTLD+1, and input_normalized keeps what you sent.
curl -s -X POST https://pingutil.com/v1/demo/lookup \
-H "Content-Type: application/json" \
-d '{ "input": "jane@mail.stripe.com", "skip_enrichment": true }' \
| jq '{domain, input_normalized, category}'
Edge cases that show up in real data
Internationalized names. Convert to punycode before you look anything up, or müller.de and xn--mller-kva.de become two keys. The punycode and normalization post covers the details.
Trailing dots, uppercase, www. Strip them first. Stripe.com. is a valid fully qualified name and also a cache miss if you don't.
Subdomains that carry meaning. Sometimes you want the eTLD+1 for the key and the full hostname for a rule. Academic and government detection is the usual case: cs.ox.ac.uk keys on ox.ac.uk (because ac.uk is a public suffix), but you still want to see the .ac.uk pattern to classify it. We check TLD rules against the full hostname and key the cache on the registrable domain, which is why student email verification and government domain detection work for deep subdomains.
Bare suffixes. co.uk has no eTLD+1. Go returns an error, tldts returns null for domain. Treat that as invalid input, not as a domain called co.uk.
The list is a snapshot. Every library above ships a copy of the list, so new suffixes reach you only when you upgrade. That's mostly fine because most churn is in the private section. The list's own site also warns against using it to decide whether a domain is valid; use DNS for that (see checking MX records).
Where eTLD+1 pays off
Once you key on the registrable domain, a few problems get simpler:
- Caching stops fragmenting. One entry for
stripe.cominstead of one per mail host. More on keys and TTLs in caching domain lookups. - CRM accounts stop duplicating. HubSpot's automatic company creation, for example, creates a separate company record for each unique domain, including subdomains, so
@example.comand@info.example.combecome two companies unless you normalize first. The CRM hygiene guide walks through the cleanup. - Team detection gets reliable, because
jane@eu.acme.comandraj@acme.comland in the same group.
The short version for your codebase: one function, registrableDomain(host), built on a maintained PSL library, with the private-section policy chosen on purpose and a comment explaining why. Call it everywhere a domain becomes a key.
Questions people ask
- Is eTLD+1 the same as the root domain?
- Usually, but not always. For stripe.com they match. For bbc.co.uk the eTLD is co.uk, so the eTLD+1 is bbc.co.uk, while a naive root-domain function that keeps the last two labels would return co.uk, which nobody owns.
- How often does the Public Suffix List change?
- It is edited continuously through pull requests to the publicsuffix/list repository on GitHub, mostly in the private section. Libraries bundle a snapshot, so updating the library dependency every few months is enough for most applications.
- Should I include private domains like github.io when computing eTLD+1?
- It depends on the question. For cookie and security boundaries, include them, because alice.github.io and bob.github.io are different owners. For grouping by organization or company enrichment, many systems use the ICANN section only, which treats them all as github.io.