
XSS prevention in product tour tooltip content
Product tours inject HTML into your app at runtime. Every tooltip, popover, and overlay is a potential XSS vector if the library renders unsanitized user input. In the first half of 2025, cross-site scripting and SQL injection accounted for 38% of all identified web application weaknesses (Security Boulevard, March 2026). And tooltip libraries have been hit before — Bootstrap's tooltip component had a stored XSS vulnerability (CVE-2019-8331) that persisted across two major versions.
This isn't theoretical. If your product tour pulls step content from a CMS, translation service, or database, you need to know exactly how that content reaches the DOM.
npm install @tourkit/core @tourkit/reactSee the live demo on StackBlitz →
Why XSS prevention matters for product tours
Cross-site scripting costs real money. Web application attacks reached 6.29 billion incidents in 2025, a 56% year-over-year increase (Indusface State of Application Security Report, 2026). Exploited vulnerabilities in web applications cost organizations an average of $4.44 million in damages. Over 6,227 XSS-specific CVEs were published in 2025 alone.
Product tours are a particularly attractive attack surface because they run inside authenticated sessions. A successful XSS in a tour tooltip can steal session cookies, redirect users to phishing pages, or exfiltrate data from the DOM. The react-tooltip package had exactly this kind of vulnerability in versions before 3.8.1 (Snyk: SNYK-JS-REACTTOOLTIP-72363).
The fix isn't complicated. Choose a library with safe defaults, sanitize external content, and add CSP headers. The rest of this article shows you how.
What makes tooltips an XSS target?
Product tour tooltips are high-value XSS targets because they render dynamic content inside trusted UI elements. A tooltip that displays a user's name, a feature description from a CMS, or a translated string is executing a data-to-DOM pipeline. If any part of that pipeline accepts raw HTML without escaping, an attacker can inject <script> tags or event handlers that execute in the user's browser session.
The attack surface is wider than most developers expect. Three common vectors exist in tour libraries:
-
HTML string content. Libraries like Shepherd.js accept raw HTML strings as step text. The content goes straight into the DOM via
innerHTML. If that string originates from an API response, a CMS field, or a URL parameter, the library will render whatever HTML it contains. -
Attribute injection. Bootstrap's tooltip XSS (CVE-2019-8331) came through unsanitized
title,data-title, anddata-templateattributes. NoinnerHTMLneeded. The XSS lived in element attributes that the tooltip read and rendered. -
URL scheme injection. React protects against
<script>tags in JSX, but it doesn't validatehrefvalues. Ajavascript:alert(1)URL in a tooltip link bypasses React's escaping entirely. The OWASP XSS Prevention Cheat Sheet explicitly flags this: "React cannot handlejavascript:ordata:URLs without specialized validation."
How tour libraries render content differently
The rendering approach a tour library uses determines its default XSS posture. The difference between "safe by default" and "vulnerable by default" comes down to one architectural decision: does the library accept HTML strings or React elements?
| Library | Content type | Rendering method | Default XSS posture |
|---|---|---|---|
| Tour Kit | React.ReactNode | JSX auto-escaping | Safe by default |
| React Joyride | string | ReactNode | Accepts HTML elements | Safe when using JSX, risky with string HTML |
| Shepherd.js | string | HTMLElement | innerHTML | Vulnerable if content is untrusted |
| Driver.js | string | innerHTML | Vulnerable if content is untrusted |
| Intro.js | string | innerHTML | Vulnerable if content is untrusted |
Shepherd.js documentation confirms the text property accepts "a regular HTML string or an HTMLElement object." That HTML string hits the DOM without sanitization. If you're pulling tour content from an external source, you're one unsanitized API response away from a stored XSS.
Why React.ReactNode is the safer default
Tour Kit's step type definition uses React.ReactNode for content, not HTML strings. This is an architectural decision, not an oversight. When you pass JSX to a Tour Kit step, React's rendering pipeline handles escaping automatically. There's no intermediate innerHTML call. No raw string-to-DOM conversion.
Here's what a Tour Kit step definition looks like:
// src/tours/onboarding.tsx
import type { TourStep } from '@tourkit/core'
const steps: TourStep[] = [
{
id: 'welcome',
target: '#dashboard-header',
content: (
<div>
<h3>Welcome, {userName}</h3>
<p>Here's your dashboard. We'll walk you through the key features.</p>
</div>
),
},
]That {userName} variable gets escaped by React before it reaches the DOM. If userName contains <script>alert('xss')</script>, it renders as a harmless text string. No sanitization library required. No configuration. It just works.
Compare this with a Shepherd.js step where the same content would be:
// Shepherd.js — HTML string approach
const step = {
text: `<div>
<h3>Welcome, ${userName}</h3>
<p>Here's your dashboard.</p>
</div>`,
}
// If userName = "<img src=x onerror=alert(1)>", that's XSSThe Shepherd.js example injects userName directly into an HTML string. If that value comes from user input, a database, or an API, you've got a stored XSS vulnerability. We built Tour Kit around ReactNode specifically to avoid this class of bug.
That said, Tour Kit doesn't have a visual builder. You need React developers writing JSX. That's a real tradeoff. Libraries that accept HTML strings make it easier for non-developers to author content through a CMS. But easy authoring and safe rendering are at odds when the authoring pipeline doesn't sanitize.
When you still need to sanitize (even with React)
React's auto-escaping covers the common case, but it doesn't cover everything. Three situations require additional defense even in a Tour Kit app.
Rendering CMS content with dangerouslySetInnerHTML
If your tour content comes from a headless CMS as HTML, you'll need dangerouslySetInnerHTML, and that means you need DOMPurify. Tour Kit itself never uses dangerouslySetInnerHTML in its source code. But you might use it inside the ReactNode you pass as step content.
// src/tours/cms-tour.tsx
import DOMPurify from 'dompurify'
import type { TourStep } from '@tourkit/core'
function CmsStepContent({ html }: { html: string }) {
const clean = DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
})
return <div dangerouslySetInnerHTML={{ __html: clean }} />
}
const steps: TourStep[] = [
{
id: 'feature-intro',
target: '#new-feature',
content: <CmsStepContent html={cmsResponse.body} />,
},
]DOMPurify adds roughly 6.4KB gzipped to your bundle (npm: dompurify). Tour Kit's core is under 8KB gzipped, so you're looking at ~14KB total, still smaller than React Joyride alone.
Validating URL schemes in tooltip links
React won't stop a javascript: URL from rendering in an <a> tag. If your tour step includes user-generated links, validate the protocol:
// src/utils/safe-url.ts
function isSafeUrl(url: string): boolean {
try {
const parsed = new URL(url)
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol)
} catch {
return false
}
}
// In your step content
function StepWithLink({ url, label }: { url: string; label: string }) {
if (!isSafeUrl(url)) {
return <span>{label}</span>
}
return (
<a href={url} target="_blank" rel="noopener noreferrer">
{label}
</a>
)
}Handling dynamic attribute values
If your tour steps set data-* attributes, title, or aria-label values from external data, those strings need escaping too. React handles this for JSX attributes, but not for values passed to the DOM via refs or vanilla JS.
// Safe — React escapes the attribute value
<div data-tour-step={stepId} aria-label={stepDescription}>
{content}
</div>
// Unsafe — direct DOM manipulation bypasses React
elementRef.current.setAttribute('title', untrustedValue) // Don't do thisContent Security Policy and product tours
As of 2025, 43% of tested web applications had no Content Security Policy header defined at all, and another 19% had an overly permissive one (2NS Cybersecurity, 2025). CSP is your second line of defense. If sanitization fails, a strict CSP prevents injected scripts from executing.
Product tour libraries create unique CSP challenges. Overlays and tooltips often use inline styles for positioning (absolute coordinates, z-index stacking). A strict style-src policy breaks the tour UI.
Setting up CSP with Tour Kit in Next.js
Tour Kit works with nonce-based CSP because it doesn't inject inline <style> blocks. Positioning relies on the style attribute on individual elements. You allow 'unsafe-inline' for style-src only, keeping script-src strict:
// src/middleware.ts
import { NextRequest, NextResponse } from 'next/server'
export function middleware(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self';
frame-ancestors 'none';
`.replace(/\n/g, '')
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', cspHeader)
response.headers.set('x-nonce', nonce)
return response
}The 'unsafe-inline' for style-src is a pragmatic concession. Almost every UI library that handles dynamic positioning needs it. The important thing is keeping script-src locked down with nonces.
The CSS-in-JS complication
If your app uses styled-components or Emotion, those libraries inject <style> tags at runtime, which breaks style-src 'self'. You'll need to pass the nonce through their provider:
// For styled-components
import { StyleSheetManager } from 'styled-components'
function App({ nonce }: { nonce: string }) {
return (
<StyleSheetManager nonce={nonce}>
<TourProvider steps={steps}>
<YourApp />
</TourProvider>
</StyleSheetManager>
)
}Tour Kit itself doesn't ship CSS-in-JS. It's headless, so you bring your own styles. Fewer CSP complications compared to libraries that bundle their own styled components.
A security audit checklist for your product tour
We tested Tour Kit against the OWASP XSS Prevention Cheat Sheet during development. Here's the checklist we use, adapted for any tour library:
-
Check the content type. Does the library accept HTML strings or
ReactNode? If it accepts strings and you pass external data, sanitize with DOMPurify. -
Audit all dangerouslySetInnerHTML usage. Search your codebase for
dangerouslySetInnerHTML. Every instance that renders external data needs a sanitization wrapper. -
Validate URL schemes. Any
hrefin tour content should be validated against an allowlist of protocols (http:,https:,mailto:). -
Check attribute injection. If your tour library reads
data-*ortitleattributes for display content, verify those values are escaped before rendering. -
Verify CSP headers. Run
curl -I https://yourapp.comand check for aContent-Security-Policyheader. At minimum,script-srcshould not include'unsafe-inline'or'unsafe-eval'. -
Test with payloads. Pass these strings as tour step content and verify they render as text, not executable code:
<img src=x onerror=alert(1)><script>alert(document.cookie)</script>javascript:alert(1)(as a link href)" onmouseover="alert(1)" data-x="
-
Check third-party dependencies. The
react-tooltippackage had an XSS vulnerability in versions before 3.8.1 (Snyk: SNYK-JS-REACTTOOLTIP-72363). Runnpm auditregularly.
Common mistakes and anti-patterns
These are the XSS anti-patterns we see most often when reviewing product tour code. Each is fixable with a one-line change.
Pattern 1: Template literal step content
// Vulnerable — string interpolation into HTML
const step = {
content: `<p>Welcome, ${user.name}!</p>`,
}
// Fixed — use JSX
const step = {
content: <p>Welcome, {user.name}!</p>,
}Pattern 2: Markdown rendering without sanitization
// Vulnerable — markdown to HTML without sanitization
import { marked } from 'marked'
const step = {
content: (
<div
dangerouslySetInnerHTML={{
__html: marked(cmsContent), // marked doesn't sanitize
}}
/>
),
}
// Fixed — sanitize after markdown conversion
import { marked } from 'marked'
import DOMPurify from 'dompurify'
const step = {
content: (
<div
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(marked(cmsContent)),
}}
/>
),
}Pattern 3: Dynamic image sources
// Vulnerable — user-controlled image source with error handler
const step = {
content: (
<img src={userProvidedUrl} alt="Feature" />
),
}
// An attacker sets src to trigger onerror in older browsers
// Fixed — validate the URL and use a fallback
const step = {
content: (
<img
src={isSafeUrl(userProvidedUrl) ? userProvidedUrl : '/fallback.png'}
alt="Feature"
/>
),
}Try the security-hardened tour demo on StackBlitz →
How Tour Kit handles security internally
Tour Kit's architecture eliminates the most common XSS vectors by design. But here's exactly what happens under the hood, so you can verify rather than trust:
- Step content is typed as
React.ReactNode(packages/core/src/types/step.ts). No HTML string overload exists. You can't accidentally pass raw HTML. - No
dangerouslySetInnerHTMLin production code. We searched the codebase. It appears only in test setup files (clearingdocument.body.innerHTMLbetween tests) and in a spike comment explicitly noting its absence. - The live announcer uses
textContent(packages/core/src/utils/a11y.ts), notinnerHTML. Screen reader announcements can't execute scripts even if you passed one. - Overlay positioning uses React's
styleprop, notelement.styleorsetAttribute. React escapes style values.
Tour Kit is a younger project with a smaller community than React Joyride or Shepherd.js. We haven't been battle-tested at the same enterprise scale. But the architectural decision to use ReactNode exclusively means an entire class of XSS vulnerabilities simply doesn't apply.
npm install @tourkit/core @tourkit/reactGet started with Tour Kit → | View source on GitHub →
FAQ
Does React fully protect against XSS in product tours?
React's JSX auto-escaping prevents most XSS when rendering dynamic values through curly braces. But it doesn't cover dangerouslySetInnerHTML, javascript: URLs, or direct DOM manipulation via refs. Tour Kit uses React.ReactNode for step content, so React's escaping applies automatically. Add DOMPurify if you render CMS HTML.
What is the XSS risk of using dangerouslySetInnerHTML in tooltips?
Using dangerouslySetInnerHTML bypasses all of React's built-in escaping. Any <script> tags, onerror attributes, or event handlers in the HTML string will execute in the browser. The OWASP XSS Prevention Cheat Sheet lists this as a known React vulnerability. Always sanitize with DOMPurify first, restricting allowed tags to <b>, <em>, and <a>.
How do I test my product tour for XSS vulnerabilities?
Pass known XSS payloads as step content: <img src=x onerror=alert(1)> and <script>alert(1)</script>. Verify they render as visible text, not executable code. Run npm audit to check for known vulnerabilities in your tour dependencies. Add automated scans with StackHawk or OWASP ZAP for production coverage.
Should I add a Content Security Policy if I use Tour Kit?
Yes. CSP is defense-in-depth: if sanitization fails, it prevents injected scripts from running. As of 2025, 43% of web applications had no CSP header at all. Set script-src 'self' 'nonce-...' at minimum. Tour Kit works with strict CSP because it doesn't inject inline scripts.
Which product tour libraries are vulnerable to XSS by default?
As of April 2026, Shepherd.js and Driver.js accept HTML strings rendered via innerHTML. Intro.js also renders HTML string content directly. React Joyride accepts both ReactNode and strings. Tour Kit accepts only React.ReactNode. The vulnerability depends on whether you pass untrusted data. Hardcoded content is safe regardless of rendering method.
JSON-LD Schema:
{
"@context": "https://schema.org",
"@type": "TechArticle",
"headline": "Tour Kit security: XSS prevention in dynamic tooltip content",
"description": "Learn how product tour libraries handle XSS risks in tooltip rendering. Compare HTML string vs ReactNode approaches, add DOMPurify sanitization, and configure CSP headers.",
"author": {
"@type": "Person",
"name": "Tour Kit Team",
"url": "https://tourkit.dev"
},
"publisher": {
"@type": "Organization",
"name": "Tour Kit",
"url": "https://tourkit.dev",
"logo": {
"@type": "ImageObject",
"url": "https://tourkit.dev/logo.png"
}
},
"datePublished": "2026-04-09",
"dateModified": "2026-04-09",
"image": "https://tourkit.dev/og-images/product-tour-xss-prevention.png",
"url": "https://tourkit.dev/blog/product-tour-xss-prevention",
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "https://tourkit.dev/blog/product-tour-xss-prevention"
},
"keywords": ["product tour xss prevention", "tooltip xss security", "safe html rendering tooltip react", "react xss dangerouslySetInnerHTML", "content security policy product tour"],
"proficiencyLevel": "Intermediate",
"dependencies": "React 18+, TypeScript 5+",
"programmingLanguage": {
"@type": "ComputerLanguage",
"name": "TypeScript"
}
}Internal linking suggestions:
- Link FROM:
how-onboarding-tools-inject-code(security context) - Link FROM:
aria-tooltip-component-react(tooltip rendering) - Link FROM:
portal-rendering-product-tours-createportal(DOM rendering) - Link TO:
keyboard-navigable-product-tours-react(a11y + security) - Link TO:
screen-reader-product-tour(textContent usage) - Link TO:
best-product-tour-tools-react(library comparison)
Distribution checklist:
- Dev.to (cross-post with canonical URL)
- Hashnode (cross-post with canonical URL)
- Reddit r/reactjs (discussion post, not self-promotion)
- Reddit r/netsec (security angle)
- Hacker News (if paired with a security audit release)
Related articles

Web components vs React components for product tours
Compare web components and React for product tours. Shadow DOM limits, state management gaps, and why framework-specific wins.
Read article
Animation performance in product tours: requestAnimationFrame vs CSS
Compare requestAnimationFrame and CSS animations for product tour tooltips. Learn the two-layer architecture that keeps tours at 60fps without jank.
Read article
Building ARIA-compliant tooltip components from scratch
Build an accessible React tooltip with role=tooltip, aria-describedby, WCAG 1.4.13 hover persistence, and Escape dismissal. Includes working TypeScript code.
Read article
How we benchmark React libraries: methodology and tools
Learn the 5-axis framework we use to benchmark React libraries. Covers bundle analysis, runtime profiling, accessibility audits, and statistical rigor.
Read article