How to detect an in-app browser from the user agent
Which markers identify an in-app browser in the user agent, what a substring check can honestly prove, and how to stop your marker list going stale.

Most apps that embed a browser leave a marker in the User-Agent string — Meta’s apps add FBAN and FBAV, Android’s WebView adds wv — so detecting one usually comes down to a substring search. That is the whole trick, and the whole problem: a User-Agent is a courtesy, not a contract, and it changes without notice. Detection is therefore useful for adapting an experience and unsuitable for any decision whose cost of error is high.
An in-app browser is a browsing view rendered inside another application: the visitor tapped a link in Instagram or TikTok, never left it, and is reading your page through a window that app controls.
Key takeaways
- A User-Agent is self-reported text. It supports an inference about the environment; it never guarantees one.
- Most social apps add a token of their own. Android WebView adds
wv. iOS offers no equivalent, so detection there is inference. - Every marker list is a dated snapshot. Record the date, review it on a schedule, expect it to expire.
- Detection describes an environment. It does not decide a route, and an attempted route is not a confirmed arrival.
What you can and cannot learn from a User-Agent
The User-Agent is a string the client sends with each request and exposes to scripts as navigator.userAgent. Nothing verifies it. Any client can change it, browsers have been shortening it for years, and an app can rewrite its own portion in the next release.
It supports a few things reasonably well: the operating system family, the rendering engine, and whether a token you recognise is present. It cannot tell you whether a web API works here, whether storage will persist, or whether a route you attempt will complete.
That gap is why TapBaton treats the user agent as one input, not the answer. Its classifier reads several signals together, records where each came from and how confident it is, and returns an explicit unknown when sources disagree. An honest unknown beats a confident guess, because you can count it and watch it move.
The markers, by app family
The table below is a snapshot taken on 18 September 2026. It is not a complete list, it cannot be one, and parts of it will be wrong within a release cycle or two. Treat it as a starting point you date and own.
| App family | What to look for | Notes |
|---|---|---|
| Meta (Facebook) | FBAN, FBAV, FB_IAB |
Several tokens across builds; Messenger strings can carry them too. |
Instagram, with a version |
Both iOS and Android builds. | |
| TikTok | TikTok, musical_ly, trill |
Older product names still appear in current strings. |
| Android WebView | wv in the platform section |
Says “embedded”, not which app embedded it. |
| iOS WKWebView | No marker of its own | Inferred from a missing browser token such as CriOS. |
In the page, the check is a regular expression test:
const ua = navigator.userAgent;
const meta = /\b(?:FBAN|FBAV|FB_IAB)\b/.test(ua);
const instagram = /\bInstagram\b/.test(ua);
const androidWebView = /;\s*wv[);]/.test(ua);
const looksEmbedded = meta || instagram || androidWebView;
On the server the same string arrives as the User-Agent request header: read it, default a missing value to an empty string, and run the same patterns before any HTML is sent.
Public catalogues exist — useragent.in’s in-app browser collection is one — and they help when you meet an unfamiliar shape. They are third-party compilations, not documentation from the apps they describe, so treat an entry as a lead to confirm on a device, not an authority.
Android WebView vs Chrome Custom Tabs: not the same thing
A WebView is a component the host app embeds and controls, running with that app’s own storage. That is the context the wv token describes. Custom Tabs are different: the visitor’s browser renders the page in a view presented over the app, so the session belongs to the browser and carries no wv. When your check says “not an in-app browser” there it is right, and treating that as a miss leads to loosening a rule that was already correct.
Routing follows separate rules. Chrome documents intent:// links, including an S.browser_fallback_url extra for when the target app cannot be resolved, and declines to launch an external application in specific cases: an intent it cannot resolve, or a JavaScript timer trying to open an app with no user gesture. See Chrome’s Android intent documentation. Knowing which app a visitor is in says nothing about whether that launch succeeds.
iOS: WKWebView, SFSafariViewController and what they share
On Apple platforms an app can embed WKWebView, which it controls and which keeps storage separate from Safari, or present SFSafariViewController, a Safari-backed view it cannot read into. Neither announces itself the way wv does.
So iOS detection is inference from absence: the platform is iOS, WebKit is rendering, no browser brand token is present, therefore this is probably embedded. Unusual Safari builds produce the same shape, and a rule tight enough to exclude them excludes genuine in-app traffic too. TapBaton resolves that case to unknown rather than forcing it either way.
Apple’s linking behaviour makes the wider point. Universal links are ordinary HTTPS URLs: the system checks an association file on your server and falls back to the browser when the app is absent. Apple also documents that tapping a same-domain universal link while already browsing that domain in Safari keeps the person in Safari. See Apple’s guide to linking to app content. The operating system decides, on context your string match never sees.
That is the boundary to write down before anyone builds on detection: the route depends on the source app, the operating system and what the visitor does. Recognising the app is not controlling where it goes next, and attempted is not confirmed.
Feature detection beats string matching where it is possible
When the real question is “can this environment do what I need”, ask the environment, not its name plate.
const canPersist = (() => {
try { localStorage.setItem("p", "1"); localStorage.removeItem("p"); return true; }
catch { return false; }
})();
That answers what you wanted to know — whether a value survives here, right now — without asserting anything about which app you are in. The same applies to share sheets, clipboard access and popups.
Feature detection cannot label the source app in a report, and it cannot run before the document does, so a route chosen at the edge still needs the header.
Server-side vs client-side detection, and what each costs you
Reading the header on the server gets you an answer before the first byte and works with scripting disabled. The costs are real: a response that varies by user agent complicates caching, the server sees headers and nothing else, and a wrong guess is baked into the response.
Reading it in the page costs nothing in cache behaviour and adds feature detection, but it runs after the document, so anything it changes can flash and a visitor whose script never runs sees the default.
A workable split is the server for labels and reversible choices, the client for anything that changes what a person sees. Avoid both sides deciding independently and disagreeing quietly: pass the server’s reading into the page, let the client refine it, log the disagreements. To check what the link carries between hops, the Parameter Inspector shows the decoded fields without opening the destination.
Write down the route you intend for each source app and operating system, then confirm each one on a real device rather than a desktop tab. Start with your first link and test the journey end to end before it carries spend.
A maintenance policy: how to notice the day a string changes
Detection rules rot silently. Nothing errors when an app ships a new string; traffic just moves into a bucket nobody watches.
- Keep the list in one place, with a date and a source per entry. A marker nobody can trace is one nobody will delete.
- Sample what you did not match. Keep a capped sample of strings that look embedded but matched no app. That queue is where a new token appears first.
- Alert on distribution, not on errors. A jump in the share classified as unknown source app is the earliest usable signal that something changed.
- Version the rules themselves. TapBaton stamps each reading with the classifier that produced it and keeps its reference data as a maintained, dated snapshot, so a shift in the numbers traces back to a rule change.
- Re-check on a schedule. Treat a snapshot older than a quarter as a claim that needs fresh evidence.
The failure mode to avoid is loosening a pattern until the unknowns disappear. A rule that matches everything reports beautifully, tells you nothing, and is harder to notice than one that stopped matching.
Frequently asked questions
How do I detect an in-app browser in JavaScript?
Test navigator.userAgent for the tokens the app adds — FBAN or FBAV for Meta apps, Instagram, wv for Android WebView. Combine that with a feature check for the capability you need, and handle the inconclusive case.
Is there a complete list of in-app browser user agents?
No, and any list presented as complete is already wrong. Apps change their strings without announcement, third-party catalogues lag behind, and iOS in-app browsers add no marker. Keep a dated snapshot you maintain.
Can I tell Instagram’s browser apart from Safari on iPhone?
Usually, because Instagram’s builds add an Instagram token. But a generic WKWebView with no app token is separable from Safari only by inference, and that inference is wrong on some Safari builds. Record it as uncertain.
Related
- Testing the browser handoff: design the route and fallback once you know the environment.
- Parameter Inspector: check the fields a campaign URL carries between hops.
- Media buying teams: agree routes and evidence levels across a team running many campaigns.