The User-Agent Client Hints API

This is the fifth chapter of “The problem with User-Agent strings“. In the previous parts, we examined the history of the User-Agent string and browser vendors’ efforts to reduce its information. Removing details from the User-Agent string made passive fingerprinting much more difficult. 

These efforts have been really effective because we are now in a position where only the browser name, major version of the browser, and operating system name are reliable. The rest of the details in the User-Agent string are either definitely fake or potentially fake. In either case, we can’t rely on the data being accurate, so the best option is to ignore it.

But we’ve also seen some use cases where those details are helpful. For example, a download page for an application offering different builds based on CPU architecture or whether or not the operating system is running in 32-bit or 64-bit mode. 

How can we provide this information without sending it over the network with every request to every server we contact? The answer is the User-Agent Client Hints API.


The User-Agent Client Hints API is currently available in Chromium-based browsers like Chrome and Edge and gives us access to all the information removed from the User-Agent string, but we have to ask for it. Yes, that means that data is also available for fingerprinting. So what have we gained by all of this?

Well, the browser is allowed to say: “no”.

Because you actively have to ask for information, browsers can develop heuristics and refuse to give it to known sites or scripts that use that data for fingerprinting. 

How does it work?

Like User-Agent strings, a JavaScript API allows the current webpage to access the information. And just like User-Agent strings, the information is included with each network request.

The information is split into two categories: low-entropy data and high-entropy data. Low-entropy data is low-risk and can be accessed by default. We can extract the same information for the User-Agent string: the browser name, the browser’s main version, and the operating system’s name. The main advantage is that this data is available through a standardised API, and we no longer have to parse a string that is a hornet’s nest of lies. 


The high-entropy data is not sent over the wire with each request. The server has to request specific details by sending a response header. The data is unavailable on the first request, only on subsequent requests. In addition, Client Hints are not sent to third-party domains by default.

On the JavaScipt side of things, a script can request access to the high-entropy data by calling a function with all the data it wants as a parameter; it will then get a promise that resolves to an object with the requested data.

A simple example of low-entropy data

The low-entropy data is available as properties of navigator.userAgentData

brands

An array of objects. Each object has a property brand with the browser’s name and a property version with the browser’s major version number. 

Why an array? Sometimes, you want to target not a specific browser but a whole family of browsers. For example, in the case of a Chromium-based browser, like Chrome or Edge, you would see an entry for both Chromium and the actual name of the browser. 

Be aware that the array might also contain fake data. You cannot use this data to determine conclusively what the browser’s name is, but you can check if it is an already-known name. Also, the order of the array is random.

platform

The name of the operating system, such as “Windows,” “macOS,” or “Android.”

mobile

Finally, this property is true if you want to know if the browser is running on a mobile device. If it is a desktop machine, the property is false. 

For Chrome running on macOS, it would look something like this:

console.log(navigator.userAgentData);

{
  brands: [
    { brand: "Google Chrome", version: "123" },
    { brand: "Not:A-Brand", version: "8" },
    { brand: "Chromium", version: "123" }
  ],
  mobile: false,
  platform: "macOS"
}

The same data is also sent as headers with each network request.

Sec-Ch-Ua: "Google Chrome";v="123", "Not:A-Brand";v="8", "Chromium";v="123"
Sec-Ch-Ua-Mobile: ?0
Sec-Ch-Ua-Platform: "macOS"

But what if you need the high-entropy data?

The server or webpage has to ask for specific types of high-entropy data. You can request multiple types simultaneously. 

The webpage can ask for the data using the navigator.userAgentData.getHighEntropyValues() function. You will need to provide it with a list of all the types of information you want to receive back. It will return a promise, allowing the browser to perform checks or ask the user for permission to give the data. That promise resolves to an object that contains the data you asked for.

The server can ask for the data by setting the Accept-CH response header. Once this response header is sent, the browser will send the data using headers for every subsequent request to the same origin for the duration of the user’s session. In other words, you don’t ask for the data for a single page but for a whole origin, and you only have to ask once per user session1.

You can ask for the following data:

architecture

The CPU architecture, either “Intel” or “arm”, or maybe some other value depending on your system. This is exposed to the server as the Sec-CH-UA-Architecture header.

bitness

The bitness of the operating system and can be either 32 or 64. Please note, that this is not the value of the CPU. If you install a 32-bit version of Windows on a 64-bit processor, this value will be 32. This is exposed to the server as the Sec-CH-UA-Bitness header.

wow64

Wow64 stands for Windows-32-on-Windows-64. This is true if the browser runs as a 32-bit application on a 64-bit system. This is exposed to the server as the Sec-CH-UA-Wow64 header.

fullVersionList

This is a list containing the same information as the brands property. But instead of only the major version, it contains the full version of the browser, including the build number. This is exposed to the server as the Sec-CH-UA-Full-Version-List header.

platformVersion

The version of the operating system. This is the internal version used by the operating system, not the marketing name. This is exposed to the server as the Sec-CH-UA-Platform-Version header.

model

The model name of the device you are using, if it is known. This is exposed to the server as the Sec-CH-UA-Model header.

How can we determine the actual version of the operating system?

One of the use cases I discussed in the previous chapter is a software download page. Using the User-Agent string, we can no longer determine the actual version of the operating system. This information is helpful for our download page because our software may not run on older operating system versions. 

So, let’s get the operating system version using User-Agent Client Hints.

let data = await navigator.userAgentData.getHighEntropyValues([
  "platformVersion"
])

console.log(`You are using ${data.platform} ${data.platformVersion}`);

You are using macOS 14.5.0

This means we can finally determine the correct version of macOS again. The same applies to Windows 11, too. We can distinguish between Windows 10 and 11. In fact, we can even differentiate between the build of Windows that the user is running. 

The same code above running on Windows 11 will give us:

You are using Windows 15.0.0

But wait… Windows 15? Huh? Yes. Windows 10 and Windows 11 are the marketing names, not the versions. The API gives us the internal version number. So we have to translate the version number to the marketing name:

const majorPlatformVersion = parseInt(data.platformVersion.split('.')[0]);

if (majorPlatformVersion >= 13) {
  console.log ("You are using Windows 11")
}
else if (majorPlatformVersion > 0) { 
  console.log("You are using Windows 10"); 
} 
else { 
  console.log("You are using Windows 8.1 or earlier"); 
}

Determining the architecture and bitness of the users’s system

Our download page also needs to know the CPU’s architecture to determine whether we should offer an Intel or ARM build of our app and whether we need to offer a 32-bit or 64-bit version.

We can also do that using Javascript by calling the navigator.userAgentData.getHighEntropyValues() function, asking for the architecture and bitness. But we can also do this fully on the server without any JavaScript at all.

It’s tricky because we must make a second roundtrip to the server to achieve it. We need to request the data on the first roundtrip using the Accept-CH header. Then, we get the requested data from the server the second time around. 

There are three ways to achieve this. 

First, you can request it on the main page, even though we don’t need it yet. When the user navigates to the download page, it will be available immediately. This is not ideal since some people may open the download page as their first page. 

The second method would be to first check if we already have the data. If we do, we render the page. If not, we request it and immediately reload the page. 

The third method achieves the same effect without having to add any checks. We can send a Critical-CH request header that tells us the data we requested is critical. If the browser sees that header, it will automatically do the second roundtrip if needed.

All we need to do is send the following response headers from the server when the browser requests the download page:

Accept-CH: Sec-CH-UA-Architecture, Sec-CH-UA-Bitness
Critical-CH: Sec-CH-UA-Architecture, Sec-CH-UA-Bitness

This will cause the browser to do an automatic second roundtrip to the server, and on that second roundtrip, it will send the following request headers:

Sec-CH-UA-Architecture: arm
Sec-CH-UA-Bitness: 64

The script running on your server can then use these headers to offer the correct build of your application. That saves the user a lot of headaches when trying to figure out what kind of system they are using.

Browser compatibility

Unfortunately, this API is only available on Chromium-based browsers like Chrome and Edge. Safari and Firefox did reduce their User-Agent strings—which is good—but they did so without offering an alternative for legitimate use of the User-Agent string, which hurts the user experience for those use cases.


Continue with Should we rely on browser detection?

  1. What a user session exactly is, is not defined. That is up to the browser. ↩︎