Normalize Fenix's Accept-Language / navigator.languages to reduce Android fingerprinting entropy
Categories
(Firefox for Android :: Privacy, enhancement)
Tracking
()
People
(Reporter: tjr, Unassigned)
References
(Blocks 1 open bug)
Details
Attachments
(1 file, 1 obsolete file)
Bit of a placeholder bug; Fenix uses the OS locale which presents way more entropy to websites than typical Firefox on Desktop. We may want to adjust that behavior. Filing this as a placeholder while we explore/document it more.
| Comment hidden (obsolete) |
Comment 2•4 months ago
|
||
see bug 1961578
Updated•4 months ago
|
| Reporter | ||
Comment 3•4 months ago
|
||
Firefox on Android exposes the device's raw OS locale list directly to web content via the Accept-Language HTTP header, navigator.language, and navigator.languages. Desktop Firefox does not — it runs the user's primary locale through a normalization step that maps a wide range of inputs onto a small set of canonical language chains. Because Android skips this step, two Android users who would be indistinguishable on desktop (say, an en-IN user and an en-US user, both of whom desktop would render as en-US, en) instead present distinct, fine-grained values. The result: Android exposes roughly ten times as many distinct Accept-Language values as any desktop OS, making Android users substantially more unique — and therefore more fingerprintable — than their desktop counterparts.
We cannot make Android users look like desktop users: Android's population of languages, regions, and devices is genuinely different, and we only control Firefox's encoding, not the user's underlying language choices. But we can make Android users look like other Android users by applying the same normalization desktop already performs. This proposal does that: clear Fenix's intl.accept_languages pref so LocaleService computes the canonical chain from the negotiated primary locale, exactly as it does on desktop. This collapses Android's ~100k distinct values toward the small set of canonical chains, dramatically shrinking the anonymity-set fragmentation that is specific to Android.
The metrics under discussion
Firefox's user-characteristics telemetry collects four locale-related metrics, all sourced from the same code path:
intl_intl_locale— Firefox's JSIntlengine resolved locale (e.g."en-US").intl_system_locale— OS-level locale, viaOSPreferences::GetSystemLocale().locale_accept_language—intl::LocaleService::GetAcceptLanguages()output. This is the string web content sees through theAccept-LanguageHTTP header and the source fornavigator.language/navigator.languages.locale_languages— JSON encoding ofnavigator.languages, itself derived fromNavigator::GetAcceptLanguages— so this metric is a redundant re-encoding oflocale_accept_language.
The source comment in toolkit/components/resistfingerprinting/nsUserCharacteristics.cpp confirms the consolidation:
All
navigator.languages,navigator.language, and Accept-Languages header useNavigator::GetAcceptLanguagesto create a language list. It is sufficient to only collect this information as the other properties are just reformats ofNavigator::GetAcceptLanguages.
So a single fix at the Navigator::GetAcceptLanguages source covers all of navigator.language, navigator.languages, the Accept-Language header, and both telemetry metrics simultaneously.
The problem: Android is far more unique than desktop
Distinct locale_accept_language values, per OS, deduped across clients (window 2026-03-25 → 2026-04-17, 24.87M clients):
| OS | Distinct values | Top value | Top value share |
|---|---|---|---|
| Android | ~100,005 | en-US |
17.4% |
| WINNT | 9,295 | en-US, en |
36.5% |
| Darwin | 5,276 | en-US, en |
47.0% |
| Linux | 6,383 | en-US, en |
52.6% |
Two structural differences drive Android's ~10× cardinality:
1. Raw regional tags survive, where desktop collapses them. Android's English population is shattered across en-US (17.4%), en-GB (6.8%), en-IN (3.2%), en-CA (1.3%), en-AU (1.1%), en-ZA (0.3%), en-NZ (0.2%), and a long tail of others — each a distinct fingerprint bucket. On desktop these largely fold into one value: the single most common desktop value, en-US, en, accounts for 36–53% of users, double the share of Android's most common value. An Android en-IN user is needlessly distinguishable from an Android en-US user; on desktop they would be identical.
2. The full ordered OS locale list is exposed verbatim. Multi-locale Android devices emit raw lists like en-GB,en-US, de-DE,en-US, de-DE,en-DE, en-US,es-US — Android-only strings with no separator normalization, no regional collapse, and no fallback chain. These populate the long tail and are a major contributor to the cardinality gap.
How the value is computed
Navigator::GetAcceptLanguages reads intl::LocaleService::GetAcceptLanguages (intl/locale/LocaleService.cpp:739):
LocaleService::GetAcceptLanguages(nsACString& aRetVal) {
// if there is a user (or locked) value, use it
if (Preferences::HasUserValue(ACCEPT_LANGUAGES_PREF) ||
Preferences::IsLocked(ACCEPT_LANGUAGES_PREF)) {
nsresult rv = Preferences::GetCString(ACCEPT_LANGUAGES_PREF, aRetVal);
if (NS_SUCCEEDED(rv)) {
return NS_OK;
}
}
// if we need to fetch the default value, do that instead
if (mAppLocales.IsEmpty()) {
NegotiateAppLocales(mAppLocales);
}
ffi::locale_service_default_accept_languages(&mAppLocales[0], &aRetVal); // line 753
return NS_OK;
}
There are two paths:
- User-value path — if
intl.accept_languageshas a user value (or is locked), return it verbatim. - Default path — otherwise, compute a canonical chain from
mAppLocales[0](the negotiated primary app locale, a single tag) via the Rust functionlocale_service_default_accept_languages(intl/locale/rust/locale_service_glue/src/lib.rs:82).
The shipped pref default is "und" (modules/libpref/init/all.js:1474: pref("intl.accept_languages", "und")) — a default value, not a user value — so a stock Firefox profile always takes the default path.
The desktop data confirms this directly. The dominant desktop values are precisely the canonical chains the default path emits — de, en-US, en (12.55% of WINNT users), fr, fr-FR, en-US, en (8.77%), zh-CN, zh, zh-TW, zh-HK, en-US, en (4.90%) — strings that only the chain function produces. A user who took the user-value path via the manual dialog would instead emit a lowercased, comma-joined list with no chain expansion; those forms appear only as a sliver in the tail (e.g. de-de,en-us,en at 0.13% of WINNT users, versus the 12.55% on the computed de, en-US, en). The ~100:1 ratio between the computed chain and its manually-edited counterpart confirms that the overwhelming majority of desktop profiles carry no user value and take the default normalization path.
Why desktop is already normalized and Android is not
Desktop never sets a user value for intl.accept_languages unless the user manually opens the "preferred languages for web content" dialog (a rarely-used setting). The vast majority of desktop users therefore hit the default path: default_accept_languages(mAppLocales[0]). Two normalizations happen for free there:
- Negotiation (
mAppLocales) maps the OS's requested locale against Firefox's built locales (browser/locales/all-locales), stripping regions Firefox isn't built for:de-DE→de,en-IN→en-US(no build),pt-BR→pt-BR(exact match kept). - The chain function turns that single primary tag into a canonical fallback chain with an
en-US, entail:de→de, en-US, en;fr→fr, fr-FR, en-US, en;en-US→en-US, en.
This is exactly why desktop's distribution is concentrated: dominant values are en-US, en (36–53%), de, en-US, en (8–13%), fr, fr-FR, en-US, en (6–10%) — a few canonical chains absorbing most users. (The chain function processes one tag only; there is no code anywhere that merges multiple locales' chains — even a desktop user whose OS lists three languages gets Accept-Language from the primary alone.)
Fenix, by contrast, takes the OS LocaleList on the Java side, comma-joins it, and sets intl.accept_languages to that string verbatim — forcing the user-value path and bypassing both normalizations entirely. That is the entire source of the Android-specific entropy: the regional tags never collapse, the chain is never applied, and multi-locale lists pass through raw.
The fix
In Fenix's GeckoView:SetLocale handler (mobile/shared/components/geckoview/GeckoViewStartup.sys.mjs), today the code unconditionally writes the raw OS locale list into intl.accept_languages (setComplexValue at lines 434-438). Replace that with logic that clears the pref so LocaleService normalizes — but only when the current value is Fenix's own auto-written one, never when the user set it deliberately.
The discriminator is exact: the value Fenix auto-writes is, by construction, the raw OS locale list (aData.acceptLanguages), and the existing handler keeps it in sync with the OS list on every locale change. So a current user value that equals the raw OS list is Fenix's own; a current user value that differs from it was set by the user (e.g. via about:config) and must be honored.
// in the GeckoView:SetLocale handler:
if (aData.requestedLocales) {
Services.locale.requestedLocales = aData.requestedLocales;
}
const PREF = "intl.accept_languages";
const osList = aData.acceptLanguages || "";
const current = Services.prefs.prefHasUserValue(PREF)
? Services.prefs.getComplexValue(PREF, Ci.nsIPrefLocalizedString).data
: null;
if (current === null || current === osList) {
// No user value, or the value is the one Fenix itself auto-wrote from the
// OS list. Clear it so LocaleService computes the canonical chain via the
// default path: default_accept_languages(mAppLocales[0]).
Services.prefs.clearUserPref(PREF);
}
// else: a deliberate user edit — leave it untouched.
With no user value present, LocaleService::GetAcceptLanguages falls through to the default path and computes default_accept_languages(mAppLocales[0]) — the identical normalization desktop performs. Android users with the same negotiated primary now produce the same canonical chain as one another, and the regional / multi-locale fragmentation collapses.
This is self-contained: a few lines in one Fenix JS handler. It changes nothing for desktop callers and touches no native code (GetAcceptLanguages already uses mAppLocales[0]). It also handles existing profiles for free — a profile carrying the legacy auto-written OS-list value matches current === osList on the first locale sync after the update and is cleared, while a profile whose value the user customized is preserved.
What it does NOT do
It does not make Android's distribution match desktop's. Android users still skew toward different languages and regions than desktop users (Android shows far more en-IN, es-US, en-AU, etc.), Fenix's built-locale list (mobile/android/locales/all-locales) differs from desktop's, and we are not altering anyone's actual language preference. The Android population will still look Android-shaped. The goal is narrower and achievable: collapse Android's internal fragmentation so each Android user blends with the large set of other Android users who share their primary language, rather than standing out via a region tag or a raw multi-locale list that desktop would never have emitted.
Worked examples
| Android OS LocaleList | negotiated mAppLocales[0] |
Resulting Accept-Language |
|---|---|---|
["de-DE"] |
de |
de, en-US, en |
["de-DE", "fr-FR"] |
de |
de, en-US, en |
["de-AT"] |
de |
de, en-US, en |
["de-CH", "fr-CH", "it-CH"] |
de |
de, en-US, en |
["en-IN"] |
en-US (no Fenix build) |
en-US, en |
["en-IN", "hi-IN"] |
en-US |
en-US, en |
["en-AU"] |
en-US |
en-US, en |
["en-GB", "en-US"] |
en-GB (exact) |
en-GB, en |
["fr-CA", "en-CA"] |
fr |
fr, fr-FR, en-US, en |
["pt-BR", "en-US"] |
pt-BR (exact) |
pt-BR, pt, en-US, en |
The collapse is visible directly in the data: today's separate Android buckets en-IN (3.2%), en-AU (1.1%), en-ZA (0.3%), en-NZ (0.2%) all fold into the en-US, en bucket; de-AT (0.8%), de-CH (0.5%), and the multi-locale de-DE,* lists fold into de, en-US, en. The ~100k distinct Android values collapse toward the number of distinct primary-chains reachable on Android.
Edge cases
- Same language, different regions (
["de-DE", "de-AT"]). Both negotiate to primaryde; resultde, en-US, en. Identical to a single-locale German user. - Primary has no Fenix build (
["en-IN", "hi-IN"]).en-INnegotiates toen-US; resulten-US, en. The secondary is dropped — the same outcome a desktop user with that primary gets. - Empty LocaleList. The unconditional
clearUserPrefis still correct:LocaleServicecomputes frommAppLocales[0], whichrequestedLocaleswill have set. - User pref set via Fenix about:config. Honored. The handler only clears when the current value equals the raw OS locale list (i.e. Fenix's own auto-written value); a value the user set deliberately differs from the OS list and is left untouched. The one benign corner is a user who manually sets the pref to a string that happens to equal their exact OS locale list — they are treated as default and receive the normalized chain for their primary, which is a reasonable outcome.
- Separator / case. Output is whatever
default_accept_languagesemits (comma-space, mixed case likeen-US) — identical to the desktop default path by construction, so no Android-specific formatting remains.
Tradeoffs
- Secondary OS locales are dropped from
Accept-Language. A user who configured German + French in Android Settings producesde, en-US, en; the French is not represented. This is intentional and matches desktop behavior (desktop also collapses to the primary). Sites that need a secondary language preference lose that signal — which is the same tradeoff desktop users already live with. navigator.languagesarray shape changes. A["de-DE", "fr-FR"]device currently reportsnavigator.languages = ["de-DE", "fr-FR"]; afterward it reports["de", "en-US", "en"]. Sites that index a fixed position expecting "the second OS locale" get something different.
| Reporter | ||
Comment 4•4 months ago
|
||
Fenix wrote the raw OS LocaleList into intl.accept_languages verbatim,
exposing regional tags and full multi-locale lists to web content. Desktop
never does this: with no user value the pref defaults to "und" and
LocaleService computes a canonical fallback chain from the negotiated
primary app locale (mAppLocales[0]) via default_accept_languages. That is
why Android exposes ~10x as many distinct Accept-Language values as any
desktop OS, making Android users far more unique.
Clear intl.accept_languages on GeckoView:SetLocale so the native default
path normalizes the value the same way it does on desktop, collapsing the
Android-specific regional and multi-locale fragmentation. Only clear when
the current user value is absent or equals the OS locale list Fenix itself
auto-wrote; a value the user set deliberately (e.g. via about:config) is
left untouched. This also migrates existing profiles whose pref holds the
legacy auto-written value.
Adds LocaleTest coverage: the raw OS list is not written verbatim (pref
clears to its default), and a deliberate user pref is preserved.
| Reporter | ||
Comment 5•4 months ago
|
||
This makes a huge difference on Akamai, which collects fewer metrics:
| % unique (Android) | FPJS V2 | Akamai V2 | Signifyd |
|---|---|---|---|
| Baseline (P3) | 51.98% | 12.14% | 48.37% |
| + accept-lang normalize | 47.98% | 5.04% | 44.10% |
| Δ unique | −4.01 pp (−7.7%) | −7.10 pp (−58.5%) | −4.27 pp (−8.8%) |
Description
•