Serve User UploadsFrom a Separate Domain
usercontent.example.com instead of example.com) so that a malicious file cannot reach your main site's cookies, sessions, or tokens. The browser's same-origin policy treats the two domains as different origins, so a script hidden in an upload runs in a sandbox with no access to your users' logged-in session. Pair this with a Content-Disposition: attachment header and a strict Content Security Policy.If you let users upload files, one question comes up again and again: where should those files be served from? The short version is that they should not live on your main application domain. This guide explains why OWASP gives that advice, what actually goes wrong when you ignore it, and how to set it up. It builds on our broader guide to file upload security, which covers validation and storage.
What Does "Serve Uploads From a Separate Domain" Mean?
It means user files are delivered from a domain that is different from the one your application runs on. If your app is at example.com, you serve uploads from something like usercontent.example.com, or a dedicated storage domain, or an object storage bucket with its own hostname. The files are the same. What changes is the origin the browser sees when it loads them, and that origin is what decides how much damage a bad file can do.
Why Does OWASP Recommend a Separate Domain?
The reason is the same-origin policy, the rule that keeps one website from reading another website's data in the browser. Cookies, stored sessions, and tokens are tied to an origin. A script can only read them if it runs on the same origin. So if a user uploads a file that secretly contains a script, and you serve that file from your main domain, the script runs as your main domain. It can then read the victim's session cookie or token and hand their account to the attacker. Serve the same file from a separate domain and the script runs as a stranger with no access to anything that matters.
This is the difference between a harmless mistake and a full account takeover. The upload validation you do on the server is your first line of defense, but no validation catches everything. Formats like SVG and HTML can carry scripts, and clever files can pass a type check while still being dangerous. The separate domain is the layer that limits the blast radius when a bad file gets through.
What Goes Wrong on the Same Domain?
| Scenario | On your main domain | On a separate domain |
|---|---|---|
| User uploads an SVG with a script | The script runs as your site and can read the victim's session cookie. | The script runs as an unrelated origin and cannot touch your site's cookies. |
| User uploads an HTML file that others open | Stored XSS against every viewer, with access to their logged-in session. | The page is isolated; it cannot read or send your main site's credentials. |
| Attacker guesses another user's file URL | Same origin means the browser will send your app cookies along. | Cross-origin requests do not carry your app's cookies by default. |
The core of every row above is cross site scripting delivered through a file. If you want the full picture of how those attacks work, read our guide on cross site scripting.
How Do You Set It Up?
You do not need to rebuild your app. In most cases you point uploads at a different hostname and add two response headers. Here is the shape of it.
- Use a distinct hostname. Serve files from a subdomain such as
usercontent.example.com, or better, from object storage such as Amazon S3 or Google Cloud Storage, which already has its own domain. - Force a download for risky types. Send
Content-Disposition: attachmentso the browser saves the file instead of rendering it in place. This alone stops most in-browser script execution. - Set the content type honestly. Send the real
Content-Typeyou validated, and addX-Content-Type-Options: nosniffso the browser does not guess a more dangerous type. - Add a strict Content Security Policy. A policy of
default-src 'none'on the upload domain means even a script that loads cannot do anything useful. - Do not share cookies. Keep your session cookies scoped to your app domain only, never to a wildcard that includes the upload subdomain.
// Response headers when serving a user upload
Content-Type: image/png // the real, validated type
Content-Disposition: attachment // download, do not render in place
X-Content-Type-Options: nosniff // no MIME sniffing
Content-Security-Policy: default-src 'none'
// And serve it from a separate host, e.g.
// https://usercontent.example.com/f/9f2c...e1
A wildcard cookie such as Domain=.example.com would defeat the whole point, because it shares your session cookie with the upload subdomain. Keep session cookies bound to the exact app host. If you manage sessions with a library, this binding is something OwlSessionGuard handles for you, so the session token never leaks to a different host.
What If You Cannot Use a Separate Domain?
Sometimes a separate domain is not available right away. It is still worth doing the rest. Always send Content-Disposition: attachment for user files, always set nosniff, and apply a restrictive Content Security Policy so a script inside a file cannot run or call home. These do not replace origin isolation, but they close most of the gap while you plan the move to a separate host. For the full set of upload defenses, see our file upload security guide.
Separate Domain Checklist
- 1Serve user uploaded files from a separate domain or object storage, not your app domain.
- 2Send Content-Disposition: attachment so browsers download risky files instead of rendering them.
- 3Set the real validated Content-Type and add X-Content-Type-Options: nosniff.
- 4Apply a strict Content Security Policy on the upload domain (default-src 'none').
- 5Scope session cookies to your exact app host, never a wildcard that includes the upload domain.
- 6Route downloads through your app to check ownership before handing over a file.