If Your App Accepts SVG Uploads, Read This First
Somebody asked for logo uploads to look sharp on high-density screens, so the accepted file types grew by three characters. .png, .jpg, .svg. It is a one-line change and it reads like a formatting improvement.
It is not. The first two are containers of pixels. The third is an XML document, and a browser that opens it directly does not treat it as a picture - it treats it as a page.
The awkward part is that the same uploaded file can be completely harmless in one place in your application and a stored cross-site scripting hole in another, with nothing about the file changing in between. What decides which one you get is not the upload. It is how the file is served, and that decision is usually made much later by someone who has no idea the question is open.
The same file, two outcomes
Take one uploaded file, stored once. Now look at three ways it might reach a browser.
Inside an image tag. Written as <img src="/uploads/logo.svg">, browsers render SVG in a deliberately restricted mode. Scripting does not run. External resources are not fetched. This is the well-behaved case, and it is the one most developers picture when they picture an uploaded image.
Inlined into your markup. Someone wants to recolour the logo with CSS, which only works if the SVG is part of the page rather than behind an <img>. So the file's contents get injected into the DOM. Now everything in it is part of your document, in your origin, with access to everything your own scripts can reach.
Opened at its own URL. This is the one that catches people. The file lives at https://yourapp.com/uploads/logo.svg because it has to live somewhere, and that URL works. Paste it into the address bar and the browser loads a full document, in your application's origin, with no restrictions at all.
Nobody built the third case. It exists because the file is stored and reachable. And in most applications, a link to it is one right-click away from any user who can see the image.
How an avatar becomes a session
The reason this is worth caring about is not that a drawing could animate rudely. It is that a document running in your origin can do everything your own front end can do.
The chain is short:
- An attacker uploads an SVG as their profile picture. It passes every check you have, because it is a valid SVG.
- It is stored and served from your domain, like every other avatar.
- They send another user a link to the file itself - in a comment, a support ticket, a shared document, anywhere your product lets one user hand another a URL.
- The victim opens it. The document loads in your origin and reads whatever the browser will hand over there: session-cookie-backed API responses, tokens in local storage, CSRF tokens from your own endpoints.
- It calls your API as them.
No network interception, no password guessing, no server compromise. The application did exactly what it was built to do at every step.
This is why SVG upload handling shows up so often in bug bounty reports. It is not an exotic attack. It is a file type whose capabilities do not match the mental model of the field it was added to.
Four defences that are not defences
Every team arrives at the same four ideas, roughly in this order.
Checking the extension. This confirms the file is called .svg. A malicious SVG is also called .svg. The check does not distinguish anything, because both files are legitimately SVG.
Checking the content type. Same problem one layer down. image/svg+xml is the correct type for a harmless drawing and for a hostile one. There is no honest content type for "SVG, but the dangerous kind".
Parsing it to confirm it is a real image. This one feels more rigorous - load the file, read its dimensions, reject it if it fails. It does establish that the file is a well-formed drawing. It establishes nothing about what else the drawing contains, because valid SVG is allowed to contain those things. The file passes because it genuinely is a valid image.
Relying on a Content Security Policy. The strongest of the four, and the one that fails most quietly. A CSP protects a response only if that response carries it. User uploads very often do not: they are served from a static path, a different handler, or an object store that returns the bytes and a content type and nothing else. Your policy lives on your HTML pages. It does not follow a file opened at its own URL.
Worth checking rather than assuming. Fetch one of your own uploaded files and look at what came back with it:
curl -sI https://yourapp.com/uploads/some-upload.svg | grep -i \
-e content-type -e content-security-policy -e content-disposition If there is no policy header in that output, you do not have a policy on that file.
Three things that do work
All three are real answers. They differ in what they cost and in how easy they are to undo by accident.
Serve uploads from a separate origin. A different domain - not a different path, and not a subdomain that shares cookies - means a document that runs there is isolated from everything worth stealing. This is the architecturally clean answer and it is why large platforms serve user content from unrelated domains. The cost is real: separate hosting, separate certificates, and CORS to negotiate for anything that needs to read those files programmatically.
Force a download instead of a render. Serving uploads with a Content-Disposition: attachment header, alongside X-Content-Type-Options: nosniff, means the browser saves the file rather than executing it as a document. Cheap and effective. The cost is that it applies to everything served that way, so it works for a document store and not for an avatar you need to display.
Clean the file on the way in. Rather than constraining every future way the file might be served, remove the capability from the file itself: parse it, keep the drawing, and write out a version that has nowhere left to put a script. The stored copy is then safe in all three of the contexts above, including the one nobody planned.
The reason to prefer the third is not that it is stronger in isolation - a separate origin is stronger. It is that it survives the future. A separate origin holds only as long as nobody adds a convenient proxy route through the main domain; a download header holds only as long as nobody needs to display the file inline. A file with nothing dangerous in it stays safe no matter which part of your codebase touches it next, and no matter who writes that part.
And it composes. Cleaning on the way in does not stop you from also serving from a separate origin. Uploads are one of the few places where doing two things is genuinely reasonable, because the failure mode is somebody else's session.
Clean at the boundary, not at the point of use
If you do clean, do it when the file arrives, before it is stored - not when it is displayed.
Cleaning at display time means every path that displays the file has to remember. There will be a second one: a thumbnail generator, an email template, an export, an admin panel, a new mobile client. One of them will read the stored bytes directly, and it will be right to, because the stored bytes are supposed to be safe.
Cleaning at the boundary means the invariant is "everything in storage is already safe", which is a property you can state, test, and rely on. Everything downstream inherits it for free.
Practically, that means the cleaning step belongs in the same handler as the upload, between validation and storage, and it should be able to fail the upload rather than pass a file through untouched. Ours is available as a plain API endpoint for exactly this position in the flow - Sanitize SVG takes the file and returns the cleaned version, with a summary of what was found in it. Files are processed in memory and discarded when the response is sent, so putting it in an upload path does not mean handing your users' files to somebody's disk.
Or take the question off the table
The most complete answer is to stop storing XML.
If the image does not need to scale - and for a fixed-size avatar or an uploaded photo it does not - rasterising settles the matter permanently. Converting to PNG leaves you with pixels, and pixels have no document behind them to run. There is no context in which a PNG becomes a page.
The trade is genuine. You lose resolution independence, which matters for logos and icons that get displayed at many sizes, and you lose the small file size that makes SVG attractive for flat graphics. If those matter, keep SVG and clean it. If they do not, converting is the simpler decision and it never needs revisiting.
What you should not do is keep the format because it is convenient and leave the serving question open. That is the configuration that produces the bug reports.
Common questions
Is it safe to accept SVG uploads at all? Yes, once you have decided how they will be served. The format is not the problem; accepting it without making that decision is.
Does an extension or MIME check help? No. Both confirm the file claims to be an SVG, which a hostile SVG also does.
Is an SVG inside an <img> tag dangerous? In that context, no - browsers restrict scripting and external fetches. The problem is that the same file is usually also reachable at its own URL, and that URL is not restricted.
Will my CSP catch it? Only if the header is actually present on the response that serves the file. Check with the curl command above rather than assuming.
Should I just convert to PNG? If the image does not need to scale, that is the simplest permanent answer.
The short version
SVG is the only image format in a normal upload form that is also a document. Accepting it is a decision about how you will serve user content, not a decision about which file types look nicer, and the safest place to resolve it is at the boundary - so that the file in storage is safe regardless of what any future part of your application decides to do with it.
Related reading: SVG vs PNG: which to use, what breaks in file conversion libraries, and what a HAR file gives away.