DEV Community

Cover image for How to Detect In-App Browsers: Instagram, TikTok, Facebook
ABDULLAH AFZAL
ABDULLAH AFZAL

Posted on

How to Detect In-App Browsers: Instagram, TikTok, Facebook

You can detect an in-app browser from the user agent with one regex. The one most snippets copy, though, misses Instagram and TikTok on iOS and current Facebook on Android. I ran the common checks against real user agent strings from October 2026 to see which ones still hold.

The short version: every big social app still puts its own token in the user agent, and those tokens are the most reliable signal you have. The generic "this looks like a WebView" heuristics are the part that's rotted. They're fine as a fallback and dangerous as your only check.

TL;DR

  • An in-app browser is a WebView the host app embeds instead of handing your link to Safari or Chrome. Instagram, Facebook, and TikTok all do it.
  • Detect it by app token first: Instagram, FBAN / FBAV / FB_IAB and musical_ly / trill_ / AppName/ultralite for TikTok. Treat BytedanceWebview alone as a generic ByteDance-app signal, not proof of TikTok.
  • The "iPhone UA without Safari/" check misses Instagram and TikTok on iOS. Both now end in Safari/604.1.
  • The Android ; wv) check misses current Facebook for Android, which no longer sends wv.
  • Use detection to adapt the page (hide Google sign-in, explain how to open the page in a browser), not to force an escape. Escapes break with app updates. Treat the app token as the primary check and the WebView heuristics as a backstop, then change what the page offers instead of trying to break out. The tokens have been around for years. The heuristics and the escape tricks are what keep failing.

What an in-app browser actually is

An in-app browser is a web view that a native app embeds so links open inside the app instead of in the user's default browser. When someone taps your link in Instagram, Facebook, or TikTok, the page renders in the app's own WebView: WKWebView on iOS, Android System WebView on Android. The host app owns the toolbar, the menu, the cookies, and whether your page is allowed to launch anything.

That last part is the whole problem. An embedded WebView uses app-specific browsing state rather than the visitor's normal Safari or Chrome session, so the visitor often arrives signed out even when they are logged in in the system browser. Cookie persistence and password autofill vary by platform and app. The host app can also inject JavaScript into or inspect the page. Felix Krause showed in 2022 that Instagram and Facebook on iOS inject a pcm.js script into third-party pages opened from links and ads. Meta told him the script aggregates events for ad measurement. Whatever it's for, your page is running code you didn't ship.

In-app browser vs WebView vs Custom Tabs

People use these terms loosely, and the distinction decides what you can detect and what breaks.

  • WebView (WKWebView, Android WebView): the app embeds a rendering engine and controls it. It can inject scripts and read the page. This is what Instagram, Facebook, and TikTok use, and it's what "in-app browser" usually means.
  • SFSafariViewController / Chrome Custom Tabs: the app presents a browser-controlled surface in a separate security domain. The host app cannot inspect or modify the page or its cookie storage. Existing sign-in state may be reused depending on the platform, browser, and session configuration. Krause's same test showed SFSafariViewController running none of the injected commands.
  • System browser: the link leaves the app entirely. WhatsApp did this on iOS in Krause's testing. When this article says "detect an in-app browser", it means the first case. For the OAuth, cookie-isolation, and script-injection problems discussed here, the other two behave more like an external browser.

Diagram comparing WebView, SFSafariViewController or Custom Tabs, and system browser host-app access

What breaks inside Instagram, TikTok, and Facebook WebViews

The worst one is sign-in. Google has blocked OAuth inside embedded WebViews since September 30, 2021, and a blocked request gets a disallowed_useragent error. The same post spells out the part that bites web developers: if another app opens your site in a WebView (the Instagram in-app browser, say), your "Sign in with Google" button breaks, and Google's advice is to contact that app's developer. Good luck with Meta. The rule isn't arbitrary either. RFC 8252 says native apps MUST NOT use embedded user-agents for authorization requests.

Sessions are usually cold relative to Safari or Chrome. A visitor who is logged in to your site in the system browser can arrive in the Instagram WebView without that browser session, and autofill behavior may differ.

App handoffs are unreliable. Universal links and store links that open the native app from Safari often just load the mobile web page instead.

And attribution gets messy. Traffic from these apps regularly shows up with no useful referrer, so social clicks get counted as Direct unless your links carry UTM parameters.

None of these are bugs you can fix from your side. What you can do is know when you're in one and stop offering the things that will fail.

The user-agent tokens each app sends

These come from real strings collected in October 2026. The version numbers will be stale by the time you read this. The tokens won't.

App Platform Token to match Example fragment
Instagram iOS Instagram <version> Mobile/23F84 Instagram 444.0.0.31.65 (iPhone18,3; iOS 26_5_2; ...) Safari/604.1
Instagram Android Instagram <version> Android ...; wv) ... Mobile Safari/537.36 Instagram 444.0.0.46.85 Android (35/15; ...)
Facebook iOS FBAN/FBIOS, FBAV/ Safari/604.1 [FBAN/FBIOS;FBAV/575.0.0.30.70;...]
Facebook Android FB_IAB/FB4A, FBAV/ Mobile Safari/537.36 [FB_IAB/FB4A;FBAV/575.1.0.55.73;IABMV/1;]
Facebook Lite Android FBAN/EMA [FBAN/EMA;FBLC/es_ES;FBAV/525.0.0.5.102;...]
TikTok iOS musical_ly_<version>, BytedanceWebview Safari/604.1 musical_ly_46.4.0 JsSdk/2.0 ... BytedanceWebview/d8a21c6
TikTok Android musical_ly_<build>, BytedanceWebview musical_ly_2024204030 JsSdk/1.0 ... AppName/musical_ly ... BytedanceWebview/d8a21c6
TikTok Lite Android AppName/ultralite, BytedanceWebview AppName/ultralite app_version/45.0.56 ... BytedanceWebview/d8a21c6
Messenger iOS / Android FB.../Messenger per inapp-spy's rules, not tested here
Threads iOS / Android Barcelona per inapp-spy's rules, not tested here
LinkedIn iOS / Android LinkedInApp per inapp-spy's rules, not tested here
Snapchat iOS / Android Snapchat per inapp-spy's rules, not tested here
X (Twitter) iOS / Android Twitter per inapp-spy's rules, not tested here

Two things in that table catch people out. TikTok Lite doesn't send musical_ly at all, so a regex built only on that token misses it. And BytedanceWebview isn't TikTok-only: the same token shows up in other ByteDance apps (the strings with NewsArticle in them), so treat it as a "ByteDance app" match rather than proof of TikTok.

You'll also see IABMV/1 on current Instagram and Facebook strings. I haven't found any documentation for it, so I don't match on it.

Which detection checks actually work (tested October 2026)

I ran three common checks against six current strings, one per app and platform. The first is a token regex of the kind recent guides publish (Instagram|FBAN|FBAV|FB_IAB|musical_ly|Snapchat). The other two are the generic WebView checks that libraries and Stack Overflow answers fall back on.

Real UA (Oct 2026) Token regex (no BytedanceWebview) Android ; wv) iOS "no Safari/ after iPhone"
Instagram iOS 444 Catches n/a Misses
Facebook iOS 575 Catches n/a Catches, by accident
TikTok iOS 46.4 Catches n/a Misses
Instagram Android 444 Catches Catches n/a
Facebook Android 575 Catches Misses n/a
TikTok Lite Android 45 Misses Catches n/a

The token check is the only one that holds up across the board. Add AppName/ultralite for TikTok Lite, and keep BytedanceWebview as a broader ByteDance fallback rather than using it to name the app TikTok.

Why the "iPhone without Safari" check fails now

The idea was sound when it spread: Safari on iOS ends its user agent with Safari/604.1, and older WebViews didn't. Apps have since started copying that suffix. Instagram's iOS string now ends in Safari/604.1, and the TikTok in-app browser's string includes Version/18.7.7 Mobile/15E148 Safari/604.1 before its own tokens.

Facebook iOS only passes because of a quirk. Its bracketed block (FBDV/iPhone17,2;FBMD/iPhone;...) repeats the word iPhone after Safari/604.1, and the regex lookahead matches that second occurrence. That's luck, not detection.

Why the Android wv check misses Facebook

Android's default WebView user agent includes a wv token, and the Android team's own guidance is that sites can keep looking for wv unless the app overrides the user agent. Facebook's current builds don't send it. Current FB_IAB/FB4A strings read Build/BP2A.250605.015) with no wv, while older Facebook builds and Facebook Lite still send it. If your analytics rely on wv, traffic from the newer Facebook in-app browser on Android is landing in your "regular Chrome" bucket.

Detecting in-app browsers in JavaScript

MDN's position is that user-agent sniffing is fragile and feature detection is better. For most things I agree. This is the exception: there's no feature you can test for "the host app is injecting scripts and Google will refuse this login". The user agent is the signal the apps chose to send, so use it.

Here's a detection module that checks app tokens first and only falls back to the generic heuristics when nothing named matches. It takes a string, so it runs unchanged on navigator.userAgent in the browser and on the User-Agent header in Node.

// detect-in-app.js
// Order matters: Messenger strings also carry Facebook tokens, so check it first.
const APP_TOKENS = [
  { app: 'messenger', re: /\bFB[\w_]+\/Messenger/i },
  { app: 'instagram', re: /\bInstagram\b/i },
  { app: 'facebook',  re: /\bFBAN\/|\bFBAV\/|\bFB_IAB\//i },
  // Specific TikTok-family tokens first.
  { app: 'tiktok',    re: /musical_ly|\btrill_|AppName\/ultralite/i },

  // This also appears in other ByteDance apps, so do not name it TikTok.
  { app: 'bytedance', re: /BytedanceWebview/i },
  { app: 'threads',   re: /\bBarcelona\b/i },
  { app: 'linkedin',  re: /LinkedInApp/i },
  { app: 'snapchat',  re: /Snapchat/i },
  { app: 'x',         re: /\bTwitter/i },
];

// Backstops only. Both miss current apps (see the table above),
// but they still catch unnamed WebViews from smaller apps.
const GENERIC_WEBVIEW = [
  /;\s?wv\)/i,                              // Android WebView with the default UA
  /\b(iPhone|iPod|iPad)\b(?!.*Safari\/)/i,  // iOS WebView that dropped the Safari token
];

export function detectInAppBrowser(ua) {
  const s = typeof ua === 'string' ? ua : '';
  if (!s) return { inApp: false, app: null, signal: 'no-ua' };

  for (const { app, re } of APP_TOKENS) {
    if (re.test(s)) return { inApp: true, app, signal: 'app-token' };
  }
  if (GENERIC_WEBVIEW.some((re) => re.test(s))) {
    return { inApp: true, app: null, signal: 'generic-webview' };
  }
  return { inApp: false, app: null, signal: 'none' };
}
Enter fullscreen mode Exit fullscreen mode

The signal field is there for your logs. When you see a lot of generic-webview hits with app: null, that's a new app worth adding a token for.

On the page, use the result to change what you offer:

// adapt.js
import { detectInAppBrowser } from './detect-in-app.js';

const APP_NAMES = {
  instagram: 'Instagram',
  facebook: 'Facebook',
  messenger: 'Messenger',
  tiktok: 'TikTok',
  bytedance: 'a ByteDance app',
};

function adaptForInAppBrowser() {
  let result;
  try {
    result = detectInAppBrowser(globalThis.navigator?.userAgent);
  } catch (err) {
    // Detection is a UX nicety; if it throws, render the normal page.
    console.warn('In-app detection failed:', err);
    return;
  }
  if (!result.inApp) return;

  document.documentElement.dataset.inApp = result.app ?? 'unknown';

  // Google returns disallowed_useragent inside WebViews, so don't offer it.
  document.querySelector('#google-signin')?.setAttribute('hidden', '');

  const hint = document.querySelector('#open-in-browser-hint');
  if (hint) {
    const where = APP_NAMES[result.app] ?? 'this app';
    hint.textContent =
      `Opened in ${where}? For sign-in and downloads, use the ••• menu to open this page in your browser.`;
    hint.hidden = false;
  }
}

if (typeof document !== 'undefined') {
  document.readyState === 'loading'
    ? document.addEventListener('DOMContentLoaded', adaptForInAppBrowser)
    : adaptForInAppBrowser();
}
Enter fullscreen mode Exit fullscreen mode

The hint stays generic on purpose. The apps rename their menu items often enough that exact wording goes stale before your copy does.

If you'd rather not maintain the regex list yourself, inapp-spy covers 14 apps and is actively maintained. It also detects Telegram, which it can only do client-side, not from a user agent string. Bowser and ua-parser-js are fine for general browser detection but weren't built for this.

Detecting on the server

Client-side detection is enough for UI tweaks. Server-side is where you want it for everything else: tagging analytics events so Instagram traffic stops hiding in "Direct", choosing a magic-link flow before you render the login page, or just knowing how much of your mobile traffic arrives inside an app.

Express middleware with the token check

The same module works in Node. The middleware below runs the token check on every request, then optionally asks a UA parser for a second opinion. Keep in mind the user agent is client-controlled: fine for UX and analytics, not for anything security-relevant.

// server.js (Node 18+ for global fetch and AbortSignal.timeout)
import express from 'express';
import { detectInAppBrowser } from './detect-in-app.js';

const PARSER_URL = 'https://api.ipgeolocation.io/v3/user-agent';
const API_KEY = process.env.IPGEO_API_KEY; // server-side only, never ship it to the browser

// UA strings repeat heavily (one Instagram build, thousands of visitors),
// so a small in-memory cache saves most parser calls.
const cache = new Map();
const CACHE_MAX = 5000;

async function parserVerdict(ua) {
  if (!API_KEY || !ua) return null;
  if (cache.has(ua)) return cache.get(ua);
  try {
    const res = await fetch(`${PARSER_URL}?apiKey=${API_KEY}`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ uaString: ua }),
      signal: AbortSignal.timeout(1500),
    });
    if (!res.ok) throw new Error(`parser HTTP ${res.status}`);
    const data = await res.json();
    const verdict = {
      type: data?.type ?? null, // "Browser Webview", "Mobile App", "Browser", ...
      name: data?.name ?? null,
      engineType: data?.engine?.type ?? null,
    };
    if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value);
    cache.set(ua, verdict);
    return verdict;
  } catch (err) {
    // Fail open: the token check already ran, so a slow or failed
    // parser call should never block the request.
    console.warn('UA parser unavailable:', err.message);
    return null;
  }
}

export async function inAppBrowser(req, res, next) {
  const ua = req.get('user-agent') ?? '';
  const local = detectInAppBrowser(ua);

  // Known tokens are sufficient. Use the paid parser only as a fallback.
  const parsed = local.inApp ? null : await parserVerdict(ua);
  const parserSaysInApp = parsed?.type === 'Browser Webview';

  // Either the app token or a Browser Webview result is enough.
  // A generic Mobile App result is not proof that a page is in a WebView.
  req.inApp = {
    inApp: local.inApp || parserSaysInApp,
    app: local.app, // null when only the parser fired
    signal: local.inApp ? local.signal : parserSaysInApp ? 'parser' : 'none',
  };
  next();
}

const app = express();
app.use(inAppBrowser);
app.get('/', (req, res) => res.json(req.inApp));
app.listen(3000);
Enter fullscreen mode Exit fullscreen mode

Without IPGEO_API_KEY set, the parser step is skipped and you get the token check alone, which is the right default.

Using a UA parser's type as a second signal

I use ipgeolocation.io's parser here because the User Agent API's type field can identify Browser Webview separately from a plain Browser. The API also returns Mobile App, but that broader value is not used as a parser-only in-app-browser signal because it can describe native app traffic that is not rendering a webpage.Parsing an arbitrary string with POST, as the middleware does, needs a paid plan; the GET endpoint that parses the caller's own user agent is on every plan. Here's the full response for the Instagram iOS string above:

{
  "user_agent_string": "Mozilla/5.0 (iPhone; CPU iPhone OS 26_5_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/23F84 Instagram 444.0.0.31.65 (iPhone18,3; iOS 26_5_2; en_US; en; scale=3.00; 1206x2622; IABMV/1; 1047335181) NW/7 Safari/604.1",
  "name": "Instagram",
  "type": "Mobile App",
  "version": "444.0.0.31.65",
  "version_major": "444",
  "device": {
    "name": "Apple iPhone",
    "type": "Phone",
    "brand": "Apple",
    "cpu": "Unknown"
  },
  "engine": {
    "name": "AppleWebKit",
    "type": "Browser",
    "version": "605.1.15",
    "version_major": "605"
  },
  "operating_system": {
    "name": "iOS",
    "type": "Mobile",
    "version": "26.5.2",
    "version_major": "26",
    "build": "??"
  }
}
Enter fullscreen mode Exit fullscreen mode

I ran the same six strings through it on October 19, 2026:

UA type name
Instagram iOS Mobile App Instagram
Instagram Android Browser Webview Chrome Webview
Facebook iOS Browser Webview UIWebView
Facebook Android (no wv) Browser Webview Chrome Webview
TikTok Lite Android Browser Webview Chrome Webview
TikTok iOS Browser musical_ly_46.4.0 JsSdk

Five of six outputs are useful, including the Facebook Android string that the wv check misses. Two limits are worth knowing. On Android it usually reports a generic Chrome Webview rather than naming the host app. It also classifies Instagram iOS as Mobile App and the current TikTok iOS string as a regular browser. The middleware therefore uses known app tokens to identify named apps and accepts only Browser Webview as a parser-only fallback. Neither source alone covers all six.

If you already call /v3/ipgeo for location or ASN data, adding include=user_agent (paid plans) returns the same object in that response at no extra credit cost.

What to do once you know

My take: detect to adapt the page, not to escape the app.

There's a whole genre of escape tricks. On Android, an intent: URL can sometimes hand the page to Chrome. On iOS, people have tried x-safari-https://, app-specific schemes, and even a Shortcuts callback hack. Every one of them depends on undocumented behaviour inside an app whose owner wants you to stay put and ships updates constantly. You find out an escape stopped working when your conversion rate tells you.

Adapting doesn't have that problem. When inApp is true:

  • Hide "Sign in with Google" and lead with email magic links or passkeys. Google will refuse the OAuth request anyway.
  • Replace file downloads with "email me the file".
  • Show one line telling the visitor how to open the page in their browser, using the app's own menu wording.
  • Put UTM parameters on every link you post to these apps, so the visits stay attributed when the referrer is missing.
  • Log req.inApp.app with each session. It's the cheapest way to find out how much of your mobile traffic never reaches a real browser. That list keeps working when Instagram ships its next build. The escape hack might not.

How long this will keep working

The tokens are the stable part. The Android team is reducing the default WebView user agent, but it keeps the wv token. Apps that append their own token to the default string keep that token, and apps that replace the string entirely aren't affected at all. Either way, the app tokens survive. User-Agent Client Hints only cover WebViews sending the default string, so they won't replace this for the apps people care about.

What will drift is version formats and the occasional new token, like TikTok Lite's. Re-run your detector against a fresh batch of real strings every quarter. The test harness is ten lines of Node and a list of user agents from your own logs.

Start by logging req.inApp for a week before changing any UI. You'll know which apps actually send you traffic, and whether a missing Google button is worth an afternoon or a sprint.

Top comments (0)