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:
displayNamein HTMLbioin HTMLdisplayNameinside 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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
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 concatenationinjectedJavaScriptbuilt from untrusted valuesoriginWhitelist={['*']}- 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.