How to Parse a User-Agent String in JavaScript, Python and PHP
Parsing a user-agent string looks like a job for one regex until you try it. Every Chrome string also says `Safari`, every Edge string also says `Chrome`, and since 2022 Chrome pads its version with zeros and freezes the OS version. This article covers how to parse a UA in JavaScript, Python and PHP without writing the rules yourself, what the output can and cannot tell you, and where parsing is the wrong tool altogether.
Read a User-Agent as an ordered product list
A browser UA is a list of product tokens plus comments in parentheses, and most of the products are there for compatibility. A current Chrome string names Mozilla, AppleWebKit, KHTML, Gecko, Chrome and Safari; only one of them is the browser, so matching the first familiar word gets nearly every browser wrong.
Mozilla/5.0 (Windows NT 10.0; Win64; x64)
AppleWebKit/537.36 (KHTML, like Gecko)
Chrome/128.0.0.0 Safari/537.36
The first parenthesised comment is the platform. The product tokens after it name engines and browsers, and precedence is what makes parsing work: Edge adds Edg/ and Opera adds OPR/ to an otherwise ordinary Chrome string, so those rules must run before the Chrome rule, and the Chrome rule before the Safari one.
Splitting on spaces breaks at once: (KHTML, like Gecko) is a single comment with a comma and a space inside it, and Googlebot hides inside a compatible; comment instead of appearing as a product. That is why real projects use a maintained parser and keep it updated.
Extract browser and engine separately
Keep the browser and the rendering engine as separate fields. Chrome, Edge, Opera and Samsung Internet all run Blink, Safari runs WebKit and Firefox runs Gecko. When a rendering bug is involved, the engine is often the more useful dimension, because a Blink bug shows up in all of those brands at once.
function roughBrowser(ua) {
if (/Edg\//.test(ua)) return 'Edge';
if (/OPR\//.test(ua)) return 'Opera';
if (/Chrome\//.test(ua)) return 'Chrome';
if (/Firefox\//.test(ua)) return 'Firefox';
if (/Safari\//.test(ua) && /Version\//.test(ua)) return 'Safari';
return 'Unknown';
}
// Illustrates precedence; use a maintained parser in production.
Safari's version lives in Version/18.6, not in Safari/604.1, which is a WebKit build number. On the iPhone, Chrome identifies itself with CriOS/ and Firefox with FxiOS/, and both run on WebKit there, so the desktop rules above miss them entirely. ua-parser-js reports Chrome on an iPhone as Mobile Chrome with engine WebKit.
When nothing matches, store unknown. Forcing every string into the nearest family means a new browser quietly inflates your Chrome numbers, and nobody notices for months.
Interpret operating-system and device hints cautiously
The platform comment tells you Windows, macOS, Android, iOS or Linux, and not much more. An iPad in its default desktop mode sends a Macintosh string, a reduced Android string says Android 10; K whatever the phone, and a Windows 11 PC says Windows NT 10.0.
# Example derived fields, each nullable
browser.family = Chrome
browser.major = 128
os.family = Windows
device.category = desktop
engine.family = Blink
is_bot = false
# Do not invent missing minor versions or exact device models.
Keep device categories coarse (mobile, tablet, desktop, unknown) and nullable. ua-parser-js, for instance, returns an empty device object for a desktop browser rather than the word desktop, so code that reads device.type has to treat undefined as an answer, not an error. Screen size and touch support cannot be read from the header at all; layout belongs to CSS media queries.
Architecture tokens are weak too. Win64; x64 is reliable enough on Windows, but every Mac sends Intel Mac OS X, Apple silicon included, so a download page that picks the Intel build from the UA hands Apple silicon users the wrong binary. Offer both, or read the architecture from Client Hints in Chromium browsers.
Parse on the server without trusting the input
The header is text the client wrote. Cap its length before parsing (real browser strings are a few hundred characters at most), strip control characters before it goes into a log line, and make sure a garbage value yields an empty result rather than an exception that turns into a 500. Never build SQL or HTML from it by concatenation: injection through User-Agent is one of the oldest tricks there is.
$ua = (string) ($request->header('User-Agent') ?? '');
$ua = substr($ua, 0, 1024);
// Pass $ua to a maintained parser. Treat every returned field as nullable.
$result = [
'browser' => null,
'os' => null,
'device' => null,
];
The usual libraries are ua-parser-js in JavaScript, the one behind the parser on this site, ua-parser in Python, which uses the shared uap-core rules, and matomo/device-detector in PHP. In the browser, new UAParser(navigator.userAgent).getResult() returns the same JSON the tool shows; on a server, pass it the incoming User-Agent header instead.
Parser rules change as new browsers appear. In an analytics pipeline, store the parser version next to the parsed fields and keep the raw string where your privacy policy allows it, so last year's unknown rows can be parsed again after an upgrade.
Authentication, rate limits and fraud scoring must never rest on the UA. Copying a real browser's string takes one command-line flag.
Detect bots without assuming certainty
Declared crawlers put their name in the string: Googlebot/2.1, bingbot/2.0, DuckDuckBot. Scrapers that want to blend in copy a current Chrome string instead. A bot|crawler|spider pattern is therefore good for one job, cleaning up analytics, and useless as an access-control rule.
def looks_like_bot(user_agent: str) -> bool:
markers = ('bot', 'crawler', 'spider', 'slurp')
value = user_agent.casefold()
return any(marker in value for marker in markers)
# Heuristic only: false positives and false negatives are expected.
When crawler identity matters, for example to exempt Googlebot from a rate limit, verify it the way the operator documents: a reverse DNS lookup of the IP, then a forward lookup of that hostname, which must return the same IP. Cache the answer per IP; two DNS lookups on every request are slow.
Rate-limit on behaviour, requests per IP or per key, rather than granting exemptions to anything that says bot. And keep the detection that feeds dashboards separate from the one that blocks, so a parser update that misreads a new browser cannot lock real people out.
Use User-Agent Client Hints where appropriate
Chromium browsers also send low-entropy client hints such as Sec-CH-UA and Sec-CH-UA-Mobile by default. Anything more detailed, such as the platform version or the device model, has to be requested with an Accept-CH response header, is subject to browser policy, and normally arrives from the next request on, not the first.
GET / HTTP/1.1
User-Agent: Mozilla/5.0 ...
Sec-CH-UA: "Chromium";v="128", "Not;A=Brand";v="99"
Sec-CH-UA-Mobile: ?0
Sec-CH-UA-Platform: "Windows"
The brand list contains a deliberately fake entry such as "Not;A=Brand", in varying positions, precisely so that code which splits on commas and takes the first item breaks early. Parse the header as an HTTP structured-field list and look for the brand you care about.
Client Hints do not replace the User-Agent header: Firefox and Safari do not send them, and neither does curl. Keep the classic parse as a fallback, request only the hints you need, and add Vary for any hint that really changes the response, or a CDN may serve the mobile variant to desktop visitors.
Choose feature detection over browser detection
When code needs to know whether a browser supports something, ask the browser. A property check or CSS @supports works for browsers you have never heard of and keeps working after the next UA reduction; a table of version cut-offs does neither.
if ('showOpenFilePicker' in window) {
enableNativeFilePicker();
} else {
enableUploadInputFallback();
}
// No browser name or version table required.
Parsing still earns its place in aggregate analytics, bug reports and narrow workarounds for a confirmed defect in specific versions. For a workaround, pair the smallest possible UA condition with a behaviour test, and leave a comment with the bug link next to it so someone can delete it once those versions are gone.
Before shipping, run your code on an empty string, a truncated string, a forged Googlebot and a browser version that does not exist yet. Everything that consumes the result must survive all four, and analytics should keep unknown (the parser had no idea) apart from missing (no header at all).
Frequently asked questions
How do I parse a User-Agent string?
Pass the whole header to a maintained parser, such as ua-parser-js in JavaScript, ua-parser in Python or matomo/device-detector in PHP, and treat every returned field as optional. Matching the first familiar word fails, because Chrome strings also contain Safari and Edge strings also contain Chrome.
Can I detect a browser from User-Agent reliably?
Only approximately. Browsers reduce or freeze some values, compatibility tokens overlap, and clients can forge the header. Use parsing for analytics or narrow workarounds, not as a security identity.
Why does Chrome's User-Agent contain Safari?
The `Safari/` token is retained for web compatibility and does not mean the browser is Safari. Detection rules must recognise more specific Edge, Opera and Chrome tokens before a Safari rule, or use a maintained parser.
How do I get the user agent in JavaScript?
In the browser, `navigator.userAgent` returns the string; on a Node.js server, read `req.headers['user-agent']`. Chromium browsers also offer `navigator.userAgentData` with structured brand, platform and mobile fields. Treat the device category you derive from either as a hint, not as physical truth.
How can I detect bots from User-Agent?
Bot-name matching is a heuristic suitable for rough analytics. Any scraper can claim a normal browser; verify important crawlers using their documented IP/DNS process and base abuse controls on behaviour.
Should I use User-Agent Client Hints instead?
Use them when supported and when the extra detail is necessary, with a missing-data fallback. Parse the structured brand list correctly, request minimal entropy, and vary caches whenever hints change the response.