Punycode: your invisible character is either encoded, translated or deleted

Short answer: punycode is the encoding that lets a domain name written in any script travel as ASCII, and the usual assumption is that it encodes whatever you typed. Measured across eleven characters in three implementations, it does four different things instead. A look-alike letter is encoded: apаple.com comes out as xn--apple-5ve.com. An invisible character is deleted: ap + U+200B + ple.com and apple.com come out as the same domain, with no error. A full-width or ideographic character is translated into a different ASCII character: apaple.com becomes apaple.com and ap。ple.com becomes ap.ple.com. And two characters get three different answers from three implementations — Python encodes one of them, Node refuses it, and Chromium deletes it. The gap between the middle two is the one that matters: both are quiet, but one removes a character and the other replaces it.

Fill it with a character that is hard to see, then read the right-hand pane:

—

Type or paste a hostname above.

This pane is the browser you are reading this in, live — nothing is sent anywhere. It is also the only one of the three implementations on this page you can run yourself, which is why the two disagreements in the tables below are worth knowing about: your browser is one vote of three.

What the mapping step does to a character

Before anything is encoded, the domain goes through a mapping step — the one the URL standard points at, IDNA with its UTS #46 tables. That step is where a character is rewritten, dropped or refused, and it runs before punycode does its work. So the honest question is not what punycode does with your character; it is what is left of your character by the time punycode sees it. Below, the same input — ap, the character, ple.com — is put through all three implementations.

CharacterPython 3.13 idnaNode 22.22.2 URLChromium 153What happened
U+0430 Cyrillic аxn--apple-5ve.comxn--apple-5ve.comxn--apple-5ve.comencoded
U+03BF Greek οxn--apple-sce.comxn--apple-sce.comxn--apple-sce.comencoded
U+200B zero width spaceapple.comapple.comapple.comdeleted
U+00AD soft hyphenapple.comapple.comapple.comdeleted
U+FEFF byte order markapple.comapple.comapple.comdeleted
U+2060 word joinerapple.comapple.comapple.comdeleted
U+FF41 fullwidth aapaple.comapaple.comapaple.comtranslated to a
U+3002 ideographic full stopap.ple.comap.ple.comap.ple.comtranslated to .
U+FF0E fullwidth full stopap.ple.comap.ple.comap.ple.comtranslated to .
U+3164 hangul fillerxn--apple-wfv.comrefusedapple.comthree answers
U+00A0 no-break spaceap ple.comrefusedap%20ple.comthree answers

Nine of the eleven rows are the same in every column, which is what you would expect from a standard. The other two are not, and they are the reason a domain that resolves on your machine can fail on a server. "Refused" means the URL parser threw instead of returning a hostname — measured in Node, new URL('http://apㅤple.com/') and new URL('http://ap ple.com/') both raise a TypeError, while the same two strings are accepted by the browser and by Python, which return three different hostnames between them.

Same result, different mechanism

Four of the rows above end at apple.com, and the honest reading of that is not that four characters behave the same way. It is that four characters were removed and one was replaced, and the input happened to hide the difference: with the character sitting between the two ps, deleting it leaves apple, and so does a translation that produces an a.

Move the character one letter to the left and the two mechanisms separate. Put it between a and b instead, so that a deleted character would leave ab.com and a translated one would leave its replacement behind:

Character, in a<char>b.comAll three implementationsRFC 3454 B.1 "mapped to nothing"Mechanism
U+200B zero width spaceab.comyesdeleted — contributes nothing
U+00AD soft hyphenab.comyesdeleted — contributes nothing
U+FEFF byte order markab.comyesdeleted — contributes nothing
U+2060 word joinerab.comyesdeleted — contributes nothing
U+FF41 fullwidth aaab.comnotranslated — contributes an a
U+3002 ideographic full stopa.b.comnotranslated — contributes a .
U+FF0E fullwidth full stopa.b.comnotranslated — contributes a .

Read the middle column as the proof. ab.com has two letters in it, so the character that was typed contributed nothing at all — it was dropped and the letters on either side closed up. aab.com and a.b.com each carry one more character than the input had letters, and the extra one is a real ASCII character the mapping step put there. The third column reaches the same split a different way: it is the standard's own table of characters mapped to nothing, not something measured here, and the four rows it marks are exactly the four rows that lose their character. Two independent lines agree, which is why the two groups are not the same thing even when they land on the same domain.

One of the three translations does not come from the usual place, and it is worth knowing which. Normalising U+FF41 and U+FF0E under NFKC gives a and . respectively, so those two are the ordinary compatibility mapping. U+3002 is unchanged under all four normalisation forms — measured, NFC, NFD, NFKC and NFKD all leave it exactly as it is — and the three implementations still turn it into a full stop. That translation is the domain layer's own rule for what counts as a label separator rather than a Unicode normalisation, which is why it survives a step that is supposed to be the one rewriting characters.

The deleted ones are the ones that cannot be seen

Four of the eleven characters are the characters this site is about, and they are the four that vanish. A zero-width space, a soft hyphen, a byte order mark and a word joiner are all dropped before encoding, in every implementation, silently. There is no warning, no placeholder and no record of the removal in the output: the hostname that comes back is a perfectly ordinary domain that already exists.

That has a consequence worth stating plainly. ap + U+200B + ple.com is not a domain that resembles apple.com; it is apple.com once it reaches the resolver, in all three implementations measured. So the character does not hide anything and does not disguise anything. What it does is disappear, which means a domain carrying it is not a different domain at all — and a check written to spot a look-alike domain is looking for the wrong thing when it is handed this one, because there is nothing left to spot.

The look-alikes are the ones that get encoded

The rows that end in xn-- are the ones punycode is normally described as handling, and the description is accurate. A Cyrillic а or a Greek ο is a letter, it survives the mapping step, and it is encoded: the address bar then carries ASCII that does not read like the name you typed. All three implementations agree on both strings, to the character.

The practical difference from the deleted group is where the evidence ends up. A deleted character leaves nothing, so the browser has nothing to display and shows an ordinary domain. An encoded one leaves a string, so the browser has to decide what to show: the Unicode name or the encoding. Those two rows are the case where the decision is visible — what you read is not what the request carried, and unlike the invisible characters this is a real substitution rather than a deletion. It is also the reason the two are usually discussed together and should not be: one is a character that is gone, the other is a character that is doing something.

A homograph attack and a phishing URL both lean on the same fact this page has been measuring — that the hostname a person reads and the hostname a resolver uses can be two different strings — and the characters that are removed from a hostname rather than substituted in it are on the invisible character list.

Two characters, three implementations, three answers

The last two rows of the first table are not a curiosity, because they are the two that decide whether a domain works at all. A Hangul filler and a no-break space are both perfectly ordinary characters in text; in a hostname they are split three ways.

A Hangul filler is encoded by Python to xn--apple-wfv.com, refused by Node's URL parser, and deleted by Chromium, which returns apple.com. A no-break space is left as a literal space by Python, refused by Node, and written as %20 by Chromium. So a hostname containing either one does not have a single correct answer to compare against: it has three, and which one you get depends on which of the three ran. The mapping step is a standard, and the standard is implemented to three different extents — the browser is the most permissive of the three, and the two runtimes that would have to agree in a server-side check are the two that refuse.

Why this is not the same as the character being in the path

A domain name is not the only place in a URL a character can go, and the two positions go opposite ways. In the hostname, the invisible characters measured on this page are deleted, so the domain collapses onto one that already exists. In the path they are kept, and the URL serialiser writes each one out as a percent escape, so a blank profile URL ends up carrying nine visible characters instead of none.

Deletion in one position and preservation in the other is why the two questions have different answers, and it is what makes the domain case the sharper one. A path that keeps your character is at least honest about the fact that there is a character there. A hostname that drops it does not leave a trace to be honest about: the string you typed and the string that resolves are the same string, and the character is not part of either. The measured comparison between a username and a profile URL is on the invisible username page; what is on this page is the other half, where the character is not preserved and not encoded but removed.

What this page does not tell you

Whether any particular domain is registered, or what a browser will do with one that is. The measurements above are of the mapping and encoding steps, run on eleven characters, and the input is a made-up domain throughout. A registrar, a certificate authority and a browser's own display rules each add their own layer, and those layers are not measured here.

The set of eleven is the set measured, not a complete list of the characters that map to nothing. RFC 3454's table B.1 is much longer than the four rows above, and a different set moves the counts without moving the direction of any result. The implementations are single builds as well. Python's built-in idna codec follows the older IDNA2003 rules rather than the UTS #46 tables the URL standard points at, which is part of why it is the odd one out on the Hangul filler row; Node 22.22.2 carries ICU 78.2 with Unicode 17.0; Chromium 153 has its own implementation of the same standard. Categories and mechanisms will hold across versions, but the exact hostnames in the first table are a measurement of these three builds.