XSS Protection: Preventing Cross-Site Scripting in Backend APIs
In this tutorial, you will learn about XSS Protection: Preventing Cross. We cover key concepts, practical examples, and best practices to help you master this topic.
Cross-Site Scripting (XSS) is a vulnerability where attackers inject malicious scripts into web pages viewed by other users. In backend APIs, XSS typically occurs when user-generated content is stored and later served without proper encoding, allowing scripts to execute in victims' browsers.
flowchart TB
Attacker -->|Submit: | API
API -->|Store Unsanitized| DB[(Database)]
Victim -->|Request Page| Server
Server -->|Retrieve Stored Content| DB
Server -->|Serve Without Encoding| Victim
Victim -->|Execute Script| Cookie[Cookie Stolen]
Attacker -.->|Submit Same| SafeAPI[API with Sanitization]
SafeAPI -->|Sanitize & Encode| SafeDB[(Database)]
Victim -.->|Request Page| SafeServer
SafeServer -->|Encode Output: <script>| SafeVictim
SafeVictim -->|Display as Text| Safe[No Script Execution]
What You'll Learn
- Types of XSS: stored, reflected, and DOM-based
- Output encoding and sanitization with DOMPurify and similar libraries
- Content Security Policy (CSP) headers for defense in depth
- API Design Patterns that reduce XSS risk
Why It Matters
XSS can lead to Session Hijacking, credential theft, defacement, and malware distribution. Even backend APIs that serve JSON are vulnerable if the consuming client renders HTML without proper encoding.
Real-World Use
A social media platform suffered a stored XSS attack when users posted comments containing <script> tags. The backend stored comments as-is and served them in API responses. The frontend rendered comments using innerHTML without sanitization, executing the attacker's script in every viewer's browser.
XSS Prevention Techniques
Output Encoding for API Responses
const he = require('he');
function encodeForHTML(input) {
return he.encode(input, { useNamedReferences: true });
}
app.post('/api/comments', async (req, res) => {
const comment = {
text: req.body.text,
// Store original but encode for display
textEncoded: encodeForHTML(req.body.text),
userId: req.session.userId,
createdAt: new Date()
};
await db.insertComment(comment);
res.status(201).json(comment);
});
app.get('/api/comments', async (req, res) => {
const comments = await db.getComments();
// Return encoded text for safe rendering
res.json(comments.map(c => ({
...c,
text: c.textEncoded
})));
});
Expected output:
Input: <script>alert('xss')</script>
Stored as: <script>alert('xss')</script>
Rendered as text, not executed.
Content Security Policy Headers
const helmet = require('helmet');
app.use(helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'nonce-random123'"],
styleSrc: ["'self'", "'nonce-random123'"],
imgSrc: ["'self'", 'https://images.example.com'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"]
}
}));
// For inline scripts, use nonce
res.set('Content-Security-Policy', "script-src 'nonce-r4nd0m'");
Expected output:
Browser blocks inline scripts without the correct nonce. Blocks scripts from external domains. Blocks eval(). Blocks object/embed tags.
Sanitization with DOMPurify on Server
const createDOMPurify = require('dompurify');
const { JSDOM } = require('jsdom');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
function sanitizeHTML(dirty) {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: ['href', 'title'],
ALLOW_DATA_ATTR: false
});
}
app.post('/api/profile/bio', async (req, res) => {
const clean = sanitizeHTML(req.body.bio);
await db.updateUserBio(req.session.userId, clean);
res.json({ bio: clean });
});
Expected output:
Input: <script>alert('xss')</script><b>Hello</b>
Output: <b>Hello</b> (script tag removed, bold tag preserved)
Common Mistakes
- Relying only on frontend sanitization — attackers can call APIs directly. Always sanitize on the backend.
- Using
blacklistfiltering (blocking<script>tags) instead ofwhitelist(allowing only safe tags). - Setting CSP headers too permissively (e.g.,
script-src 'unsafe-inline'), negating the protection. - Forgetting to encode data in JSON responses if the consumer renders it as HTML.
- Not encoding URLs or CSS values — XSS can occur through
<a href="/programming-languages/javascript/">JavaScript</a>:URLs in links.
Practice Questions
- What is the difference between stored and reflected XSS?
- How does output encoding prevent XSS?
- What is Content Security Policy and how does it help?
- Why is whitelist-based sanitization better than blacklist?
- How can an API that returns JSON still be vulnerable to XSS?
Challenge
Build a comment system API where users can post comments with basic HTML formatting (bold, italic, links). Implement backend sanitization using DOMPurify that strips all script tags and event handlers but allows safe HTML. Add CSP headers. Test with XSS payloads.
FAQ
Mini Project
Build a forum API with posts and comments. Implement: (1) server-side HTML sanitization with DOMPurify, (2) CSP headers via Helmet, (3) output encoding for all user-generated content in API responses, (4) a test suite that attempts 10 different XSS payloads and verifies they are neutralized.
What's Next
Continue to CSRF Protection to prevent cross-site request forgery attacks.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro