Punycode and internationalised domain names
How IDNA turns a Unicode label into ASCII, the difference between the display name and the wire form, and why homograph domains are a security problem rather than a curiosity.
Two forms of the same name
DNS carries only ASCII. An internationalised domain name is therefore converted with IDNA into an ASCII-compatible form beginning with xn--, called the A-label; the Unicode version is the U-label.
import idna
u_label = "münchen"
a_label = idna.encode(u_label).decode()
print(a_label) # 'xn--mnchen-3ya'
print(idna.decode(a_label)) # 'münchen'
# a full domain, label by label
print(idna.encode("café.example").decode()) # 'xn--caf-dma.example'| Term | Example | Where it appears |
|---|---|---|
| U-label | münchen.de | User interface, certificates (with SANs in A-label form) |
| A-label | xn--mnchen-3ya.de | DNS queries, wire format, HTTP Host header |
| Display form | Mixed scripts, direction handled by the browser | Address bar after IDNA processing |
| Registration | Normalised and lowercased first | Registry rules, per-label validity checks |
What IDNA actually normalises away
- Case is folded, so
MÜNCHENandmünchenare the same name. - Under IDNA2008, purely decorative characters are disallowed and the label is rejected rather than mapped.
- Width variants and other compatibility forms are normalised before encoding, which is why
NFKCmatters here. - A label with a hyphen in the third and fourth positions is reserved for the ACE prefix and must not be registered directly.
- Browsers apply their own display rules on top of IDNA, so the address bar may show the Unicode form even though the wire form is ASCII.
# see what the wire actually carries
python -c "import idna; print(idna.encode('münchen.de').decode())"
curl -sv https://xn--mnchen-3ya.de/ 2>&1 | grep -i '^> Host'
# resolve the ASCII form directly
dig +short xn--mnchen-3ya.deHomographs and why browsers hide some names
Different scripts contain characters that look identical. An attacker can register a name that renders exactly like a trusted one using a different script, which is a homograph attack.
# two different strings, identical on screen in many fonts
a = "apple" # Latin small a ... (all Latin)
b = "\u0430pple" # Cyrillic small a, then Latin pple
a == b # False
print(a, b) # they look the same| Defence | Layer | Limitation |
|---|---|---|
| Single-script check per label | Registry and browser policy | Some legitimate names mix scripts |
| Show the A-label for mixed scripts | Browser address bar | Users rarely read it |
| Certificate transparency | PKI ecosystem | Detects issuance, not the domain itself |
| Blocklist known confusables | Client-side protection | Never complete |
| Display in a fixed font, punycode visible | Applications | Breaks the intended UX |
⚠️
Validate the A-label form, not the displayed text, in any allowlist or blocklist. Comparing user-visible Unicode names invites a bypass through a character that looks the same but normalises differently.
FAQ
Do I need to encode domains myself?
Usually not; HTTP clients, browsers and TLS stacks handle IDNA. You do need to do it when you build a DNS query, validate a name, or compare names for equality.
Why does my certificate show xn-- in the name?
X.509 stores the A-label form. The client displays the Unicode form, but the certificate bytes are ASCII by design.
Related
Unicode in depth: planes, properties and normalisation Character sets: ASCII, Latin-1 and Windows-1252
Last refreshed 2026-09-18.