On 18 August 2026, the Python Software Foundation published CVE-2026-17084. The offending code was three lines long, had been in the standard library for years, and would sail through any code review you care to name:
def map_table_b3(code):
r = b3_exceptions.get(ord(code))
if r is not None:
return r
return code.lower()
The vulnerability is code.lower(). Not a typo in it, not a missing check around it. The call itself.
This is worth your team’s attention, and not because you are likely to be using Python’s stringprep module. It is worth it because the same mistake, in a far less exotic form, is almost certainly sitting in your authentication code right now.
TL;DR
- CVE-2026-17084: CPython’s IDNA 2003 implementation called
str.lower(), which uses whatever Unicode version the interpreter ships with, where RFC 3454 requires the case-folding rules frozen at Unicode 3.2.0. - Case-folding tables are not stable. Cherokee gained lowercase letters in Unicode 8.0 in 2015, so a comparison that was a no-op under the pinned specification silently started changing its answer.
- This is a bug class, not a bug. Django’s CVE-2019-19844 (CVSS 9.8) was full account takeover, caused by matching password-reset emails after Unicode case transformation.
lower()andcasefold()disagree with each other, in opposite directions, on different characters. Neither is “the safe one”.- The fix is architectural: normalise once at the trust boundary, store the normalised form, then compare bytes. Pin your Unicode version and test it in CI.
What actually went wrong
Internationalised domain names need a way to squeeze the world’s writing systems into the ASCII that DNS understands. IDNA 2003 does this via StringPrep (RFC 3454), which includes a case-folding step so that comparisons are case-insensitive. Crucially, RFC 3454 does not say “lowercase the string”. It embeds specific mapping tables that encode the case-folding rules as they stood in Unicode 3.2.0, released in 2002.
CPython’s implementation reached for str.lower() instead. That method uses the Unicode database compiled into your interpreter, which is version 17.0.0 in current builds and something else in the one you deployed last year. For most characters the two agree, which is exactly why the bug survived. For characters added or changed after 2002, they do not.
Cherokee is the clean example. Cherokee letters have been in Unicode since version 3.0, but they were uppercase-only; lowercase Cherokee (U+AB70 to U+ABBF) did not arrive until Unicode 8.0 in 2015. Under the pinned specification, lowercasing a Cherokee letter does nothing. Under a modern interpreter, it does something. Here is the result on a machine running a Python built against Unicode 15.0.0, before the fix:
# What RFC 3454 requires
>>> "ᎠᎠ".encode("idna")
b'xn--58da'
# What you actually get
>>> "ᎠᎠ".encode("idna")
b'xn--kz9aa'
Two different domains. If one side of a comparison ran on a machine with one Unicode version and the other side ran on a machine with another, you have a check that passes when it should fail. The remediation was to add explicit exceptions so that this one function behaves as though it were still 2002.
The part that applies to your codebase
Almost nobody reading this maintains an IDNA implementation. Nearly everybody reading this ships code that decides whether two strings refer to the same person, and does it by lowercasing both and using ==.
Django did. CVE-2019-19844 was rated 9.8 critical: submit a password reset for an address that is not yours but becomes equal to a real user’s address after Unicode case transformation, and the reset token lands in your inbox. Full account takeover, from a case-insensitive lookup that looked entirely reasonable in review.
The characters that do this are not obscure once you go looking. Every result below is real output from a stock interpreter:
>>> "K".lower() # U+212A KELVIN SIGN
'k'
>>> "ſ".casefold() # U+017F LATIN SMALL LETTER LONG S
's'
>>> "ß".casefold() # sharp s expands to two characters
'ss'
>>> "İ".lower() # one character in, two characters out
'i̇'
>>> unicodedata.normalize("NFKC", "ᴮIG").lower()
'big'
That last one is how Spotify famously lost accounts in 2013: a username containing a modifier letter normalised down to an existing account’s name. The user typed something visibly different. The database saw a collision.
Now the detail that undermines the obvious fix. Python’s documentation quite rightly tells you that casefold() is the aggressive, comparison-oriented version of lower(), so “always use casefold()” sounds like sound advice. It is not sufficient, because the two methods do not simply differ in strength. They differ in direction:
>>> "ß".lower(), "ß".casefold() # casefold changes it, lower does not
('ß', 'ss')
>>> "Ꭰ".lower(), "Ꭰ".casefold() # lower changes it, casefold does not
('ꭰ', 'Ꭰ')
There is no single call that is correct for all inputs, because “the same string” is not a property of the string. It is a decision your system makes, and different subsystems in your stack are currently making it differently.
Where this bites in ordinary business software
Four places, in rough order of how often we find them:
Signup and login lookups that disagree. Registration checks uniqueness with one normalisation, or none; the login query uses LOWER() in SQL, which follows the database’s collation and Unicode version rather than your application’s. Two records, one apparent identity, and whichever one the login query happens to return is the account you get.
Domain allowlists, which are the dangerous one. Plenty of SaaS products auto-join a new user to a workspace, or grant SSO access, when their email domain matches a configured value. If that comparison is done on the human-readable form of the domain rather than on the punycode A-label produced by one pinned library, you have an authorisation control built on a mapping table that changes between releases. IDNA 2003 and IDNA 2008 already disagree in production about whether faß.de and fass.de are the same domain.
Uniqueness constraints on email. RFC 5321 makes only the domain part case-insensitive; the local part belongs to the receiving mail server. Most products fold the whole address anyway, which is a defensible product decision but must then be applied at exactly one place, on write, with the result stored.
Blocklists and moderation. Any deny rule matched on a folded form can be routed around with a codepoint whose folding differs between your normalisation step and your matcher.
Why this is getting worse, not better
Ask a coding assistant to write a case-insensitive email lookup and you will get .lower() ==, because that is overwhelmingly the pattern in the training corpus. It is what most code does, and most code is not attacked in this way.
The safe pattern, meanwhile, is invisible: a codebase that normalises correctly contains one tested function and a comment nobody wrote a blog post about. Its reasoning lives in an incident report, not in the diff. That asymmetry is why string identity deserves an explicit, documented decision rather than an idiom repeated at forty call sites.
What to do about it
- Normalise once, at the boundary, and store the result. Decide identity when the value enters the system. Persist both the original (for display and correspondence) and the canonical form (for comparison), and make every lookup a byte comparison against the stored canonical column. Comparisons scattered through the code are where the drift lives.
- Write down which normal form you chose and why. NFC for storage in almost all cases; NFKC only where you genuinely want compatibility characters collapsed, and with the understanding that it is lossy. “The library default” is not a decision.
- Never use locale-dependent case conversion for a security decision. In .NET this means
ToLowerInvariant()andStringComparison.Ordinal, never the culture-sensitive overloads: the Turkish locale mapsIto a dotlessıand will change your answer on a server whose culture happens to be set differently. - Compare domains as A-labels. Convert to punycode with one pinned library, using IDNA 2008 with UTS #46 processing, and compare the encoded output. Never compare the display form.
- Pin the Unicode version and assert it in CI. A one-line test that fails when a runtime upgrade changes the Unicode database is cheap, and it converts a silent behavioural change into a build failure. Pair it with a fixture of adversarial strings (Kelvin sign, dotless and dotted i, sharp s, long s, Cherokee, zero-width joiners) run against your real signup and login paths.
- Log it when normalisation changes the input. A registration where the canonical form collides with an existing account is not a validation error to swallow. It is a security event, and the first thing you will want when someone reports a hijacked account.
The wider point
Every system that authenticates anyone contains an implicit answer to “when are two names the same?” Most teams have never written that answer down, so it is supplied by a standard library, a database collation and a table a committee updates annually. Three answers, none of them yours, all free to change on the next runtime upgrade.
CVE-2026-17084 is a small bug with an unusually clear moral: the code was correct on the day it was written, and it became wrong without anybody touching it. That is the failure mode worth designing against.
At REPTILEHAUS we build and maintain production systems for entrepreneurs, management teams and other agencies, and identity handling is one of the first things we audit, because it is where cheap mistakes become expensive ones. If you cannot point to the single function in your codebase that decides whether two users are the same person, get in touch.
📷 Photo by Raphael Schaller on Unsplash
