Qwik does a lot right for security by default, but “by default” is where people get lazy.
I’ve seen teams assume that because they’re using a modern framework, XSS is basically handled. Then somebody adds raw HTML rendering for a CMS snippet, builds a cute dynamic link component, or injects JSON into the page during SSR, and now the app has a very old-school bug wearing a very modern outfit.
Here are the most common XSS mistakes I see in Qwik apps, and the fixes that actually hold up.
Mistake #1: Rendering untrusted HTML with dangerouslySetInnerHTML
If you take one thing seriously in Qwik, take this seriously.
Qwik escapes text content by default. That’s what you want. The trouble starts when someone wants rich text from a CMS, markdown output, product descriptions, comments, or “just a little formatted HTML” and reaches for dangerouslySetInnerHTML.
Bad:
import { component$ } from '@builder.io/qwik';
export const ArticleBody = component$((props: { content: string }) => {
return <div dangerouslySetInnerHTML={props.content} />;
});
If props.content contains this:
<img src=x onerror=alert(1)>
you’ve got script execution.
The fix is simple in principle: sanitize HTML before rendering it. Not “filter a few tags.” Not “strip script tags with regex.” Actual sanitization.
Safer pattern:
import { component$ } from '@builder.io/qwik';
import DOMPurify from 'dompurify';
export const SafeHtml = component$((props: { html: string }) => {
const clean = DOMPurify.sanitize(props.html, {
USE_PROFILES: { html: true },
});
return <div dangerouslySetInnerHTML={clean} />;
});
If sanitization happens on the server, keep the policy consistent. Don’t sanitize one way during SSR and another way on the client. That creates weird rendering mismatches and security gaps.
My rule: if the data came from a user, a CMS, markdown, a WYSIWYG editor, or any external source, treat it as hostile until sanitized.
Mistake #2: Assuming encoded HTML is “safe enough”
A common anti-pattern is storing “escaped” HTML in a database and then later deciding to decode and inject it.
Example of the bad idea:
export const Content = component$((props: { encodedHtml: string }) => {
const html = decodeURIComponent(props.encodedHtml);
return <div dangerouslySetInnerHTML={html} />;
});
Encoding is not sanitization. It just changes representation. If dangerous markup survives and gets decoded before insertion, you still lose.
The fix: sanitize at the point where HTML enters the DOM. If you can avoid rendering HTML entirely, even better.
Good:
export const Comment = component$((props: { text: string }) => {
return <p>{props.text}</p>;
});
Text interpolation is boring, and boring is great for security.
Mistake #3: Building URLs from untrusted input
Qwik will escape text nodes and attribute values, but that doesn’t mean every URL is safe. If you let users control href, src, or navigation targets, you can still end up with javascript: URLs or other dangerous schemes.
Bad:
import { component$ } from '@builder.io/qwik';
export const ProfileLink = component$((props: { website: string }) => {
return <a href={props.website}>Visit website</a>;
});
If website is javascript:alert(1), that link becomes an XSS gadget.
Fix it by validating allowed URL schemes and, where possible, restricting to known-safe paths or origins.
Safer:
import { component$ } from '@builder.io/qwik';
function safeUrl(input: string): string {
try {
const url = new URL(input, 'https://example.com');
const allowedProtocols = ['http:', 'https:'];
if (!allowedProtocols.includes(url.protocol)) {
return '#';
}
return url.href;
} catch {
return '#';
}
}
export const ProfileLink = component$((props: { website: string }) => {
return <a href={safeUrl(props.website)}>Visit website</a>;
});
For internal navigation, I prefer not accepting full URLs at all. Accept route IDs, slugs, or validated relative paths instead.
Mistake #4: Injecting server data into inline scripts
This one tends to show up in SSR-heavy apps. Someone wants to pass server data into the page and writes an inline script blob with string interpolation.
Bad:
export const PageData = (props: { username: string }) => `
<script>
window.__DATA__ = {
username: "${props.username}"
};
</script>
`;
If username contains ";alert(1);//, your script block is now attacker-controlled.
Qwik apps often don’t need much hand-rolled inline script in the first place, which is good. Don’t reintroduce risk by manually serializing data into JavaScript source.
Safer options:
- Serialize data safely as JSON.
- Put it in a non-executable context.
- Read it back without evaluating anything.
Example:
import { component$ } from '@builder.io/qwik';
export const BootData = component$((props: { data: unknown }) => {
const json = JSON.stringify(props.data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
return (
<script
type="application/json"
id="boot-data"
dangerouslySetInnerHTML={json}
/>
);
});
Then read it safely:
const el = document.getElementById('boot-data');
const data = el ? JSON.parse(el.textContent || '{}') : {};
That avoids executable inline JavaScript entirely.
Mistake #5: Trusting third-party widgets and embed code
Marketing wants a widget. Support wants a chat bubble. Product wants review snippets. Somebody pastes vendor HTML/JS into a Qwik component and calls it a day.
That can absolutely become an XSS problem, even if the malicious code comes from a compromised third party rather than your own users.
Bad:
export const MarketingWidget = component$((props: { embedCode: string }) => {
return <div dangerouslySetInnerHTML={props.embedCode} />;
});
Fixes, in order of preference:
- Use vetted official SDKs instead of arbitrary embed HTML.
- Load third-party code only from trusted origins.
- Isolate risky content in a sandboxed iframe when possible.
- Lock down script execution with a Content Security Policy.
A real CSP won’t fix bad DOM insertion, but it will limit how much damage a mistake can do. For Qwik apps, I strongly recommend deploying CSP early rather than after the first incident. If you need implementation details, use https://csp-guide.com.
You should also review Qwik’s own docs for framework-specific rendering behavior and server/client boundaries: https://qwik.dev/docs/.
Mistake #6: Using user input in attributes that become code or styles
People usually think about innerHTML, but dangerous injection points aren’t limited to that.
Example problems include:
- event handler attributes
- inline styles built from untrusted strings
- iframe
srcdoc - SVG content
- dynamic script-related attributes
You should never do this:
export const BadButton = component$((props: { handler: string }) => {
return <button onclick={props.handler as any}>Click me</button>;
});
Or this:
export const BadFrame = component$((props: { html: string }) => {
return <iframe srcdoc={props.html}></iframe>;
});
Even when the framework makes some of these patterns awkward, developers still find ways to force them in.
The fix is to treat these as high-risk sinks. Don’t pass user-controlled data into anything that the browser interprets as code or markup. Keep user input in plain text contexts unless it has gone through strict validation or sanitization designed for that exact sink.
Mistake #7: Forgetting that server-rendered input is still untrusted input
Qwik’s resumability and SSR model can make people mentally separate “server content” from “user content,” as if data becomes safer just because it was processed on the server first.
It doesn’t.
This is still bad on the server:
export const SearchPage = component$((props: { q: string }) => {
return <h1 dangerouslySetInnerHTML={`Results for ${props.q}`} />;
});
If q comes from query parameters, form input, headers, or upstream APIs, it is untrusted. SSR just means you shipped the payload faster.
The fix is the same as always:
- render text as text
- sanitize HTML when HTML is truly required
- validate URLs and structured input
- avoid dangerous sinks
Safe version:
export const SearchPage = component$((props: { q: string }) => {
return <h1>Results for {props.q}</h1>;
});
Mistake #8: Treating CSP as optional
I’m opinionated about this: if your app handles any user-controlled content, CSP should not be a “nice to have.”
A solid CSP gives you a second layer when someone eventually makes a bad rendering decision. It won’t excuse dangerouslySetInnerHTML abuse, but it can block a lot of inline script execution and reduce exploitability.
At minimum, aim to:
- disallow unsafe inline scripts
- restrict script sources
- restrict object embedding
- set a base URI policy
- consider
require-trusted-types-for 'script'where supported
Qwik apps can absolutely run with a strong CSP, but you need to plan for it instead of bolting it on at the end. The official docs are the place to start for framework behavior: https://qwik.dev/docs/. For CSP rollout details and policy design, https://csp-guide.com is useful.
A practical checklist for Qwik teams
If I were reviewing a Qwik codebase for XSS risk, I’d start with this:
- Search for
dangerouslySetInnerHTML - Search for dynamic
href,src,action, andsrcdoc - Search for inline
<script>generation - Search for user-controlled HTML from CMS or markdown pipelines
- Search for third-party embed code
- Check whether CSP is deployed and enforced
- Verify sanitization happens consistently, not ad hoc
The safest Qwik code usually looks pretty boring:
export const UserBio = component$((props: { bio: string }) => {
return <p>{props.bio}</p>;
});
That’s good. Boring means the browser never gets a chance to reinterpret your data as code.
When you do need rich HTML, use a real sanitizer. When you do need dynamic URLs, validate them. When you do need third-party scripts, cage them with CSP and tight loading rules.
That’s how you keep XSS out of a Qwik app without pretending the framework can save you from every bad decision.