Back to Knowledge Hub
Identity

User-Agent and Client Hints: Keeping One Identity Consistent

How the User-Agent header, Client Hints, and navigator.userAgentData must agree, why framework overrides leave gaps, and how to set and check a custom identity.

BotBrowser Team

Documentation

Prefer the maintained product doc?

This article has a matching page in the docs center. Use the docs for the canonical setup flow, current flags, and long-term reference.

Why the User-Agent header, Client Hints, and userAgentData must agree

The User-Agent header is the oldest way a browser describes itself to a website. For years it was a long string that packed in the browser name, the full version, the operating system, and the rendering engine. Chrome has since reduced how much detail that string carries, and the structured details moved to User-Agent Client Hints, usually shortened to UA-CH. The result is that one browser identity is now expressed in several places at once: the User-Agent request header, the Sec-CH-UA family of request headers, the navigator.userAgent string, and the navigator.userAgentData API.

A custom identity is therefore only as good as its weakest surface. If you change the string and nothing else, the header says one thing, Sec-CH-UA says another, and navigator.userAgentData says a third. Each surface is individually valid, but together they describe a browser that does not exist.

This matters for privacy and for plain usability. The User-Agent header and the low-entropy hints arrive with the first request, before any script runs, so a server sees them before the page has loaded. Pages and analytics scripts can then read the same facts again through JavaScript and compare them. When the answers differ, the combination is itself unusual and makes the session easier to single out. It also causes ordinary breakage, such as a server choosing a mobile layout from the User-Agent header while a script reads a desktop platform from navigator.userAgentData.

Diagram showing one configured identity feeding the request headers, the main thread, worker scope, and high-entropy values, followed by a check that all four agree

Three groups of readers meet this problem most often. Teams that test a site for Android or Edge visitors need the browser to describe itself as that kind of visitor everywhere. People who keep several separate browsing identities for privacy need each identity to stay stable and internally consistent. Support engineers who reproduce a customer report need the reproduction to carry the same identity the customer had. All three depend on agreement between surfaces, not on any single value.

Before you change anything, it helps to ask three questions:

  • Which surfaces report the identity, and will they all change together?
  • Does every version number belong to the same Chromium major version?
  • Does the rest of the configuration, such as locale, timezone, and network route, still describe one coherent browser?

The surfaces that have to agree

Chromium-based browsers describe themselves through four groups of values. Each one is covered below, followed by a short list of the agreements you can check.

The User-Agent string

The User-Agent request header and navigator.userAgent carry the same string. On desktop Chrome it looks like this:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.7444.60 Safari/537.36

Under Chrome's User-Agent reduction, the operating system and version parts of this string are held at generic values, and the minor parts of the version are fixed. The string is still sent with every request, and it is still the first thing most servers read, so it has to stay in step with the structured values that replaced the detail it no longer carries.

Client Hints request headers

Client Hints expose the same facts in structured form. The low-entropy hints are sent by default on requests:

  • Sec-CH-UA lists brand tokens with major versions, for example "Chromium";v="142", "Google Chrome";v="142", "Not:A-Brand";v="99".
  • Sec-CH-UA-Mobile says whether the browser is a mobile one, for example ?0.
  • Sec-CH-UA-Platform names the operating system, for example "Windows".

The high-entropy hints are sent only after a server asks for them with an Accept-CH response header:

  • Sec-CH-UA-Full-Version-List carries full version strings for every brand.
  • Sec-CH-UA-Platform-Version carries the operating system version.
  • Sec-CH-UA-Arch and Sec-CH-UA-Bitness carry the CPU architecture and the bitness.
  • Sec-CH-UA-Model carries the device model, which matters mainly on mobile.

Because the detailed hints are sent on request, a mismatch in them can stay invisible until a site asks. A configuration that looks right on the first page load can still contradict itself on the second.

The JavaScript side of the same information is navigator.userAgentData. Its brands, mobile, and platform properties mirror the low-entropy headers. The detailed values come from getHighEntropyValues(), which returns a promise:

navigator.userAgentData.brands;
navigator.userAgentData.mobile;
navigator.userAgentData.platform;
await navigator.userAgentData.getHighEntropyValues([
  'platformVersion',
  'architecture',
  'bitness',
  'fullVersionList',
  'model',
]);

Whatever the headers say, these properties should say too. The specification defines the API and the header names together, which is why the two are meant to be read as one description.

Worker scope

A dedicated worker, a shared worker, and a service worker each have their own navigator object. A worker that reports a different User-Agent or different userAgentData from the page that started it is a clear inconsistency, because the worker belongs to the same browser. Any approach that changes values on the page only, and not inside worker scope, leaves this gap open.

GREASE entries and version alignment

Chromium adds an intentionally meaningless GREASE entry to the brand list, such as "Not:A-Brand";v="99". Its text, its version, and its position change between browser versions. The purpose is to stop servers from depending on a fixed list format, so a hand-written list that gets the entry wrong or puts it in the wrong place looks different from a real one.

Version numbers need the same care. The major version in the User-Agent string, the versions in Sec-CH-UA, and the entries in fullVersionList should all describe the same Chromium release. A full version of 142.0.7444.60 in one place and a major version of 140 in another is a mismatch even if every brand name is right.

The agreements you can check

Put together, a credible identity satisfies this list:

  1. The User-Agent header matches navigator.userAgent.
  2. The brand tokens in Sec-CH-UA match navigator.userAgentData.brands.
  3. The platform in Sec-CH-UA-Platform matches navigator.userAgentData.platform.
  4. The major version in the User-Agent string matches the versions in the Sec-CH-UA brand tokens.
  5. The full versions in Sec-CH-UA-Full-Version-List match fullVersionList from getHighEntropyValues().
  6. The platform version is a value that is real for the claimed operating system.
  7. The architecture and bitness fit the platform, for example x86 and 64 for a Windows desktop.
  8. Every value is the same in the main thread, in workers, and in the HTTP request headers.

Why a framework or CDP override leaves gaps

Automation frameworks make the string easy to change. Playwright accepts a userAgent option when you create a context, and Puppeteer offers page.setUserAgent():

const context = await browser.newContext({ userAgent: 'Custom UA String' });
await page.setUserAgent('Custom UA String');

A string-only override changes the User-Agent header and navigator.userAgent. Unless the Client Hints metadata is supplied separately, the other surfaces keep reporting the original browser:

  • Sec-CH-UA and the other hint headers still describe the original brand and platform.
  • navigator.userAgentData.brands still returns the original list.
  • getHighEntropyValues() still returns the original platform version, architecture, and full versions.
  • Worker scope may keep the original values, depending on how the override is applied.

The Chrome DevTools Protocol offers Network.setUserAgentOverride with an optional userAgentMetadata object. That is more complete, because it can carry brands, platform, and the other hint values. It still moves the work to you. The brand list, the GREASE entry, the full versions, and the platform fields have to be written by hand and kept in step with the string. The override applies to the target it was sent to, so each page and each worker needs its own call, and a list that was correct for one Chromium release becomes stale at the next.

Setting extra headers has the same shape of problem. A rule that rewrites Sec-CH-UA on the wire changes the request but not what navigator.userAgentData returns inside the page. The identity then differs between the network layer and the script layer.

These are general limits of approaches that rewrite one surface at a time. A header rule, a string override, and a script that edits one property each cover their own surface, and nothing guarantees that the surfaces still agree afterward. The reliable fix is to choose the identity once and have every surface derive from that choice.

Setting a custom User-Agent in BotBrowser

A BotBrowser profile already carries a coherent baseline. It holds a matching User-Agent string, Client Hints values, and userAgentData fields, so loading a profile gives a consistent identity without further flags. You only need the flags below when you want an identity that differs from the profile, such as an Android identity on a desktop machine.

The --user-agent flag takes the raw string, and it works together with a set of --bot-* flags that describe the structured side. Custom User-Agent and full userAgentData control require an ENT Tier3 license:

FlagControlsExample
--user-agentThe raw User-Agent stringMozilla/5.0 (Linux; ...)
--bot-platformThe platform in userAgentDataWindows, Android
--bot-platform-versionThe operating system version13, 10.0
--bot-modelThe device model, mainly for mobileSM-G991B
--bot-architectureThe CPU architecturex86, arm
--bot-bitnessThe system bitness32, 64
--bot-mobileThe mobile flag in userAgentDatatrue, false

The --user-agent value can contain placeholders that are replaced when the browser starts. {platform}, {platform-version}, {model}, {architecture}, and {bitness} take their values from the matching --bot-* flags, and {ua-full-version}, {ua-major-version}, and {brand-full-version} take theirs from the version flags. Using placeholders keeps the string and the structured values tied to one source, so you cannot change the model in one place and forget the other.

Two supporting flags set the versions. --bot-ua-full-version (ENT Tier2) sets the Chromium full version, and --bot-brand-full-version (ENT Tier2) sets the brand-specific version for brands such as Edge. The documentation recommends keeping --bot-ua-full-version aligned with the Chromium major version of your BotBrowser binary.

An Android identity on a desktop machine looks like this:

chrome --bot-profile="path/to/android-profile.enc" \
       --user-agent="Mozilla/5.0 (Linux; Android {platform-version}; {model}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{ua-full-version} Mobile Safari/537.36" \
       --bot-platform=Android \
       --bot-platform-version=13 \
       --bot-model=SM-G991B \
       --bot-mobile=true \
       --bot-ua-full-version=142.0.7444.60

BotBrowser replaces {platform-version}, {model}, and {ua-full-version} with the flag values, then generates the matching navigator.userAgentData, the brands with a correct GREASE entry, the high-entropy values, and the Client Hints request headers. According to the BotBrowser documentation, the result stays consistent across the main thread, Workers, and HTTP requests.

A Windows desktop identity follows the same pattern with different values:

chrome --bot-profile="path/to/windows-profile.enc" \
       --user-agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/{ua-full-version} Safari/537.36" \
       --bot-platform=Windows \
       --bot-platform-version=10.0 \
       --bot-architecture=x86 \
       --bot-bitness=64 \
       --bot-mobile=false \
       --bot-ua-full-version=142.0.7444.60

Playwright and Puppeteer pass the same flags through their args option, not through a framework-level userAgent setting. Pass the flags through args, as the documentation examples do. For switching the brand itself, which is a separate ENT Tier2 control, see the brand article linked at the end.

Verifying the identity in the page, in a worker, and in the request

After launching, you can verify the result inside the browser with no extra tooling. The goal is to read the same identity from each of the three places a site can read it.

Start with the page. Open the developer tools on any page and run these lines in the Console:

navigator.userAgent;
navigator.userAgentData.brands;
navigator.userAgentData.platform;
await navigator.userAgentData.getHighEntropyValues([
  'platformVersion',
  'architecture',
  'bitness',
  'fullVersionList',
  'model',
]);

Then check the request. Open the Network panel, select a top-level HTTPS request, and read the request headers. The User-Agent, Sec-CH-UA, Sec-CH-UA-Mobile, and Sec-CH-UA-Platform headers should agree with what the Console printed. The detailed headers, such as Sec-CH-UA-Full-Version-List, appear only after the site asks for them with Accept-CH, so their absence on a first request is expected and is not a mismatch.

Finally, check worker scope. This short snippet starts a dedicated worker from a blob and prints what the worker reports:

const worker = new Worker(
  URL.createObjectURL(new Blob(['postMessage([navigator.userAgent, navigator.userAgentData.platform])']))
);
worker.onmessage = event => console.log(event.data);

The two values the worker prints should match the page. If your pages use a service worker, inspect it from the Application panel and run the same two expressions in its Console.

When something looks wrong, the symptom usually points to the cause:

SymptomLikely causeWhat to adjust
A placeholder such as {model} appears literally in the stringThe matching --bot-* flag is missingSet --bot-model, or remove the placeholder
The header says Android but userAgentData.platform says Windows--bot-platform was not setAdd --bot-platform=Android and the other platform flags
The versions in Sec-CH-UA differ from the User-Agent stringThe version flag does not match the Chromium majorAlign --bot-ua-full-version with the binary's Chromium version
The string was changed but the hints did not followThe string was set through a framework optionPass it with --user-agent inside args
The worker reports different values from the pageA page-level script or override changed only the pageRemove the page-level override and keep the browser flags

Repeat the check after every change to the profile, the Chromium version, or the identity flags, because each of them can move one surface without moving the others. A passing check shows that the browser is internally consistent. It does not show how any particular site will treat the session, because sites combine many inputs and change their own rules over time.

Choosing values and planning around the limits

The flags make the surfaces agree with each other. They do not choose sensible values for you, so the quality of the result depends on what you put in:

  • Use realistic, current versions. A browser version that very few people still run is unusual by itself, so follow the Chromium major version of your binary.
  • Pick values that belong together. A platform, a platform version, a model, an architecture, and a bitness should describe one real kind of device.
  • Match the surrounding settings. The identity, the timezone, the locale, the language, and the network route should describe the same kind of visitor.
  • Keep one identity for the life of a session or account. Changing the string between visits to the same site creates an inconsistent history.
  • Re-run the verification after browser or profile updates.

You do not always need these flags. A loaded profile is already consistent, so leaving --user-agent unset is the simplest way to stay consistent. Reach for a custom identity when you have a reason to present a different one, for example to reproduce an Android-only support case on a desktop machine, and then confirm every surface. Every extra override is one more value that has to stay in step with the rest, so change only what the task requires.

A few practical questions come up often:

  • What if I set --user-agent without the matching --bot-* flags? Set the matching --bot-* flag for each placeholder you use; for example, {model} requires --bot-model. The documentation recommends using the placeholder syntax together with the corresponding flags.
  • Does the identity reach workers? Yes. According to the documentation, values stay consistent across the main thread, Workers, and HTTP requests, and the verification above lets you confirm it.
  • Do I need a license for a profile-only setup? No custom flags are involved. The profile's captured values are used. Custom User-Agent and full userAgentData control need an ENT Tier3 license.
  • Does a custom identity change the feature surface? The flags change the reported identity values. They do not add browser features that the underlying browser does not have, so test the pages you care about after a change.

BotBrowser supports a custom --user-agent template with placeholders, together with --bot-platform, --bot-platform-version, --bot-model, --bot-architecture, --bot-bitness, and --bot-mobile, and it generates matching Client Hints brands, high-entropy values, and HTTP headers that stay consistent across the main thread, Workers, and requests. That replaces the manual bookkeeping described above with one identity to verify instead of several. BotBrowser cannot make a site accept a chosen identity, does not replace choosing realistic and current version values, and a custom User-Agent with full userAgentData control requires an ENT Tier3 license.

For switching the browser brand, see Browser Brand Mismatches in Chrome, Edge, and Brave. For how Client Hints are used by websites, see Client Hints Fingerprinting: HTTP Headers as Identity. For pairing the identity with geographic settings, see Timezone, Locale, and Language Configuration.

Sources

#User Agent#Client Hints#Identity#Ua-Ch#Privacy

Take BotBrowser from research to production

The guides cover the model first, then move into cross-platform validation, isolated contexts, and scale-ready browser deployment.