React Native developers often treat WebView like a harmless rendering box. It’s not. It’s a browser surface with script execution, DOM access, navigation, message passing, and all the old web security problems packed into a mobile app.

That matters because teams frequently use WebView for things like:

  • rendering CMS content
  • showing support articles
  • embedding payment or auth flows
  • previewing user-generated HTML
  • injecting app state into a page
  • running custom JavaScript with injectedJavaScript

If untrusted data reaches that surface, you can absolutely end up with XSS-like behavior inside your app. The impact is different from a classic browser XSS, but the bug is still real: attacker-controlled JavaScript runs in your app’s web context.

Why XSS in React Native WebView is nasty

A normal website XSS usually targets cookies, tokens, or user actions in the browser. In a React Native WebView, the attacker may also get access to:

  • anything rendered in the page
  • data you inject into the page
  • messages sent through window.ReactNativeWebView.postMessage
  • privileged flows exposed by your app’s message handler
  • navigation to attacker-controlled pages
  • locally bundled HTML if you concatenate user input into it

The most common mistake I see is this: developers assume “it’s inside the app, so it’s trusted.” That assumption is how untrusted HTML turns into executable script.

The risky patterns

1. Rendering untrusted HTML directly

A lot of apps do this:

import React from 'react';
import WebView from 'react-native-webview';

export default function ArticleView({ html }: { html: string }) {
  return (
    <WebView
      originWhitelist={['*']}
      source={{ html }}
    />
  );
}

If html contains this:

<h1>Hello</h1>
<script>
  window.ReactNativeWebView.postMessage(JSON.stringify({
    type: 'steal',
    data: document.body.innerText
  }));
</script>

that script runs inside the webview.

If your native side trusts incoming messages, the bug gets worse fast.

2. Concatenating user data into HTML templates

This one is everywhere:

const html = `
  <html>
    <body>
      <h1>${title}</h1>
      <div>${body}</div>
    </body>
  </html>
`;

<WebView source={{ html }} />

If title or body is attacker-controlled, they can break out of the markup:

</div><script>alert('xss')</script><div>

That’s classic HTML injection leading to script execution.

3. Injecting unescaped data into JavaScript

This is even easier to get wrong:

const injectedJavaScript = `
  window.appConfig = {
    username: '${username}',
    theme: '${theme}'
  };
  true;
`;

If username is:

'; alert("owned"); //

your injected code becomes:

window.appConfig = {
  username: ''; alert("owned"); //',
  theme: 'dark'
};

That executes immediately.

4. Trusting postMessage from the page

Many apps expose powerful native actions through message handlers:

<WebView
  source={{ html }}
  onMessage={(event) => {
    const msg = JSON.parse(event.nativeEvent.data);

    if (msg.type === 'openCamera') {
      openCamera();
    }

    if (msg.type === 'saveToken') {
      saveToken(msg.token);
    }
  }}
/>

If an attacker can inject script into the page, they can send arbitrary messages:

window.ReactNativeWebView.postMessage(JSON.stringify({
  type: 'saveToken',
  token: 'attacker-value'
}));

Now your XSS has crossed the boundary into app behavior.

A realistic vulnerable example

Here’s a stripped-down version of a pattern I’ve seen in production:

import React from 'react';
import WebView from 'react-native-webview';

export default function ProfileBio({ bio, displayName }) {
  const html = `
    <html>
      <body>
        <h2>${displayName}</h2>
        <div class="bio">${bio}</div>
        <script>
          window.user = {
            name: '${displayName}'
          };
        </script>
      </body>
    </html>
  `;

  return (
    <WebView
      source={{ html }}
      onMessage={(event) => {
        const data = JSON.parse(event.nativeEvent.data);
        if (data.type === 'track') {
          console.log('tracking', data.value);
        }
      }}
    />
  );
}

This is vulnerable in three places:

  • displayName in HTML
  • bio in HTML
  • displayName inside JavaScript

An attacker could set their display name to:

</h2><script>window.ReactNativeWebView.postMessage('{"type":"track","value":"pwned"}')</script><h2>

Or break the JS string:

'; window.ReactNativeWebView.postMessage('{"type":"track","value":"js-breakout"}'); //

The safer pattern: avoid raw HTML when possible

Best fix: don’t use a WebView for content that can be rendered with native components.

If you only need formatted text, use React Native components and a safe parser/renderer rather than a full browser context. A WebView should be the last resort, not the default.

When you really do need WebView, treat every input as hostile.

Safer HTML rendering

Escape untrusted values before inserting into HTML

At minimum, HTML-escape untrusted content:

function escapeHtml(input: string) {
  return input
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

const safeHtml = `
  <html>
    <body>
      <h2>${escapeHtml(displayName)}</h2>
      <div class="bio">${escapeHtml(bio)}</div>
    </body>
  </html>
`;

This prevents markup injection, but it also strips intended HTML formatting. If you need to allow some HTML, escaping alone isn’t enough.

Sanitize allowlisted HTML

If your app must render rich text from a CMS or user content, sanitize it with a strict allowlist before passing it to the webview.

Your goal is simple:

  • allow harmless tags like p, b, i, ul, li
  • strip script, iframe, object
  • strip event handlers like onclick
  • restrict dangerous URLs such as javascript:

A sanitization policy should be explicit. “We remove script tags” is not a policy. Attackers love hearing that because they know there are twenty other execution paths.

Safe JavaScript injection

If you need to pass data into the page, don’t concatenate strings into JavaScript source.

Use JSON.stringify to serialize data safely:

const config = {
  username: displayName,
  theme: 'dark',
};

const injectedJavaScript = `
  window.appConfig = ${JSON.stringify(config)};
  true;
`;

This is much safer than hand-building JS strings because special characters are escaped as data, not treated as code.

Same rule for injectedJavaScriptBeforeContentLoaded.

Lock down navigation

Another easy miss: allowing the webview to navigate anywhere.

Bad:

<WebView
  originWhitelist={['*']}
  source={{ html: safeHtml }}
/>

Better:

const allowedOrigins = ['https://app.example.com'];

<WebView
  originWhitelist={allowedOrigins}
  source={{ uri: 'https://app.example.com/help' }}
  onShouldStartLoadWithRequest={(request) => {
    return allowedOrigins.some(origin => request.url.startsWith(origin));
  }}
/>

If you render inline HTML with source={{ html: ... }}, be extra careful with links inside the content. An attacker may try to pivot into a malicious page even if your initial HTML is clean.

Don’t trust messages from the page

onMessage handlers should assume the page is compromised.

Bad:

onMessage={(event) => {
  const msg = JSON.parse(event.nativeEvent.data);
  if (msg.type === 'deleteAccount') {
    deleteAccount();
  }
}}

Better:

onMessage={(event) => {
  let msg: any;

  try {
    msg = JSON.parse(event.nativeEvent.data);
  } catch {
    return;
  }

  if (!msg || typeof msg !== 'object') return;

  const allowedTypes = new Set(['analytics', 'contentHeight']);

  if (!allowedTypes.has(msg.type)) return;

  if (msg.type === 'contentHeight' && typeof msg.value === 'number') {
    updateHeight(msg.value);
  }
}}

My rule here is simple: the page gets low-trust communication only. Never expose sensitive native actions directly to a webview message channel.

Use CSP for loaded or generated pages

If you control the HTML being loaded, add a Content Security Policy. CSP won’t fix unsafe string building, but it can block a lot of script execution paths and reduce blast radius.

A basic example for webview-served content:

<meta
  http-equiv="Content-Security-Policy"
  content="default-src 'none'; img-src data: https:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src https://api.example.com; frame-src 'none'; base-uri 'none'; form-action 'none';"
/>

If you’re building CSP rules and need implementation details, https://csp-guide.com is a good practical reference. For browser behavior and directives, check official browser documentation as well.

One warning: many React Native webview setups rely on inline scripts. If your CSP needs 'unsafe-inline' for scripts, you’ve already given up a lot of the protection CSP is supposed to provide.

A hardened example

import React from 'react';
import WebView from 'react-native-webview';

function escapeHtml(input: string) {
  return input
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

export default function SafeProfileBio({
  bio,
  displayName,
}: {
  bio: string;
  displayName: string;
}) {
  const html = `
    <html>
      <head>
        <meta
          http-equiv="Content-Security-Policy"
          content="default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'; form-action 'none'; frame-src 'none';"
        />
      </head>
      <body>
        <h2>${escapeHtml(displayName)}</h2>
        <div class="bio">${escapeHtml(bio)}</div>
      </body>
    </html>
  `;

  return (
    <WebView
      source={{ html }}
      originWhitelist={['about:blank']}
      javaScriptEnabled={false}
      onMessage={() => {
        // No messaging needed for this view
      }}
      onShouldStartLoadWithRequest={(request) => {
        return request.url === 'about:blank';
      }}
    />
  );
}

A few things I like here:

  • untrusted data is escaped
  • JavaScript is disabled because the view doesn’t need it
  • navigation is effectively blocked
  • CSP is restrictive
  • no privileged message bridge exists

That’s the mindset: remove features until the attack surface matches the actual requirement.

Practical checklist

When I review React Native webviews for XSS, I look for these first:

  • source={{ html: ... }} with string concatenation
  • injectedJavaScript built from untrusted values
  • originWhitelist={['*']}
  • permissive navigation handlers
  • message handlers that trigger native actions
  • inline HTML from CMS or user content without sanitization
  • JavaScript enabled when it isn’t needed

If you only fix one thing, fix the data flow. Figure out exactly where untrusted input enters the webview and stop treating it like trusted markup or code.

WebView is a browser. Browser rules apply. Once you accept that, the right defenses become pretty obvious.