Sanitize User Input
How to sanitize and validate user input in Python, Java, and JavaScript to prevent injection attacks.
Overview
Untrusted user input is the root cause of most web application vulnerabilities: XSS, SQL injection, command injection, path traversal, and header injection. Sanitization transforms raw input into safe, normalized data; validation checks that it meets structural and semantic constraints. This recipe covers both layers across Python, JavaScript, and Java — plus the advanced cases (OS commands, file paths, NoSQL, uploads) that most basic guides skip.
When to Use
Use this resource when:
- Accepting form data, query parameters, or JSON bodies from web clients
- Processing file uploads or file paths provided by users — see file upload validation for the full recipe
- Rendering user-generated content in HTML, email, or logs — see XSS prevention and escaping HTML entities
- Passing user values to OS commands, SQL queries, or NoSQL filters — see SQL injection prevention
When to Avoid
- Sanitizing when you can validate: if input should be an integer, an enum, or a UUID, reject it rather than “cleaning” it — there is no safe version of a malformed ID.
- Sanitizing output you control: encoding belongs at the rendering boundary. Don’t pre-escape data stored in the database, or you’ll end up with double-escaped text and lost fidelity.
- Rolling your own sanitizer: HTML and shell escaping have decades of edge cases. Use the maintained libraries in the Variants table.
Solution
Python
The two workhorses are bleach for HTML fields and parameterized queries for SQL — these cover the vast majority of injection surface in a Python service:
# HTML sanitization with bleach
# pip install bleach
import bleach
def sanitize_html(text: str) -> str:
allowed_tags = ['p', 'br', 'strong', 'em']
allowed_attrs = {}
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
user_input = '<script>alert("xss")</script><p>Hello</p>'
print(sanitize_html(user_input))
# Output: '<p>Hello</p>'
# SQL safe parameterization with psycopg2
import psycopg2
def get_user_by_email(email: str):
conn = psycopg2.connect("dbname=test")
cur = conn.cursor()
# Never use f-strings or % formatting for SQL
cur.execute("SELECT * FROM users WHERE email = %s", (email,))
return cur.fetchone()
JavaScript
On the JS side, DOMPurify handles HTML sanitization (works in browser and Node.js through jsdom), while express-validator adds a validation layer right in the route:
// DOMPurify for browser-side HTML sanitization
// npm install dompurify jsdom (Node.js usage)
import createDOMPurify from 'dompurify';
import { JSDOM } from 'jsdom';
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
const dirty = '<img src=x onerror=alert(1)><b>Hello</b>';
console.log(DOMPurify.sanitize(dirty, { ALLOWED_TAGS: ['b'] }));
// Output: '<b>Hello</b>'
// express-validator for route input validation
// npm install express-validator
import { body, validationResult } from 'express-validator';
app.post('/register',
body('email').isEmail().normalizeEmail(),
body('password').isLength({ min: 8 }),
(req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
// Safe to proceed
}
);
Java
Java’s equivalents are JSoup with its Safelist profiles for HTML, and the OWASP Java Encoder when you need output encoding tuned to a specific context:
// JSoup for HTML sanitization
// Maven: org.jsoup:jsoup
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.safety.Safelist;
public class HtmlSanitizer {
public static String sanitize(String input) {
return Jsoup.clean(input, Safelist.basic());
}
}
// OWASP Java Encoder for context-specific encoding
// Maven: org.owasp.encoder:encoder
import org.owasp.encoder.Encode;
public class SafeOutput {
public static void renderUserContent(String userInput) {
String safeForHtml = Encode.forHtml(userInput);
String safeForJs = Encode.forJavaScript(userInput);
String safeForCss = Encode.forCssString(userInput);
}
}
Explanation
Sanitization and validation are complementary layers, and the order matters. Sanitization removes or escapes dangerous constructs; validation rejects data that doesn’t match expected schemas, types, or ranges. For an email field, you validate the format first (reject what’s malformed), then HTML-escape at render time if the value gets echoed back in a template.
Python’s bleach is ideal for rich-text fields because it allows an explicit allow-list of tags. DOMPurify (JS) and JSoup (Java) serve the same purpose. For SQL, parameterized queries are the only safe approach; string concatenation is always vulnerable. For output encoding, context matters: HTML encoding, JavaScript encoding, CSS encoding, and URL encoding each have different rules and must be applied in the correct context — an HTML-escaped string is still unsafe inside a <script> block.
The overall pipeline looks like this:
Variants
| Technology | Library | Purpose | Notes |
|---|---|---|---|
| Python | bleach | HTML sanitization | Allow-list based, maintained by Mozilla |
| Python | psycopg2 / sqlalchemy | SQL parameterization | Use bound parameters, never format strings |
| JavaScript | DOMPurify | HTML sanitization | Fast, browser + Node.js, configurable |
| JavaScript | express-validator | Input validation | Middleware for Express routes |
| Java | JSoup | HTML sanitization | Safelist profiles for common use cases |
| Java | OWASP Java Encoder | Context-specific encoding | HTML, JS, CSS, URL, attribute encoding |
Advanced Scenarios
Command injection prevention (Python)
Pass user input to OS commands as an argument list, never a shell string. The example below layers shell=False with a path allow-list, and keeps shlex.quote for the rare case where a shell string can’t be avoided:
import subprocess
import shlex
# VULNERABLE: shell=True with user input
# subprocess.run(f"ls {user_dir}", shell=True) # Never do this
# SAFE: shell=False with argument list
def list_directory(directory: str) -> str:
"""Safely list directory contents."""
# Validate directory is within allowed base
import os
allowed_base = '/var/uploads'
real_path = os.path.realpath(directory)
if not real_path.startswith(allowed_base):
raise ValueError('Directory outside allowed base')
result = subprocess.run(
['ls', '-la', real_path],
capture_output=True,
text=True,
timeout=10,
shell=False, # Critical: never use shell=True with user input
)
return result.stdout
# SAFE: shlex.quote if shell=True is unavoidable (rare cases)
def grep_file(pattern: str, filename: str) -> str:
"""Grep with escaped arguments."""
safe_pattern = shlex.quote(pattern)
safe_filename = shlex.quote(filename)
result = subprocess.run(
f'grep {safe_pattern} {safe_filename}',
capture_output=True,
text=True,
shell=True,
timeout=10,
)
return result.stdout
Path traversal prevention (Node.js)
../../etc/passwd is the classic attack: normalize the path, then verify it still resolves inside your allowed directory. The null-byte check catches a second bypass where attackers terminate the string early:
const path = require('path');
const fs = require('fs').promises;
const ALLOWED_BASE = '/var/uploads';
async function readUserFile(userPath) {
// Resolve to absolute path and check it stays within allowed base
const resolved = path.resolve(ALLOWED_BASE, userPath);
const normalized = path.normalize(resolved);
// Prevent directory traversal: ../etc/passwd
if (!normalized.startsWith(ALLOWED_BASE + path.sep)) {
throw new Error('Path traversal detected');
}
// Additional check: ensure no null bytes
if (userPath.includes('\0')) {
throw new Error('Null byte in path');
}
try {
return await fs.readFile(normalized, 'utf-8');
} catch (err) {
if (err.code === 'ENOENT') {
throw new Error('File not found');
}
throw err;
}
}
// Usage
// readUserFile('../../etc/passwd') -> throws "Path traversal detected"
// readUserFile('reports/2024/q1.pdf') -> reads /var/uploads/reports/2024/q1.pdf
NoSQL injection prevention (JavaScript/MongoDB)
NoSQL injection gets less attention than SQL injection but follows the same pattern — operators like $ne or $gt smuggled inside JSON bodies can bypass authentication. Strip every key that starts with $:
const { MongoClient } = require('mongodb');
// VULNERABLE: user input passed directly to query
// const user = await db.collection('users').findOne({
// $where: `this.username == '${req.body.username}'`
// });
// SAFE: use driver query methods, never $where with user input
async function findUser(username) {
// Validate input shape first
if (typeof username !== 'string' || username.length > 100) {
throw new Error('Invalid username');
}
const client = new MongoClient(process.env.MONGO_URL);
await client.connect();
const db = client.db('app');
// Use structured queries, not string-based $where
return await db.collection('users').findOne({ username });
}
// SAFE: sanitize query operators from user input
function sanitizeQuery(obj) {
// Remove $ operators from user-supplied query objects
const cleaned = {};
for (const [key, value] of Object.entries(obj)) {
if (key.startsWith('$')) {
continue; // Strip $where, $gt, $ne, etc.
}
if (typeof value === 'object' && value !== null) {
cleaned[key] = sanitizeQuery(value);
} else {
cleaned[key] = value;
}
}
return cleaned;
}
// Usage: { username: { $ne: null } } becomes { username: {} } -> safe
File upload validation (Python)
File uploads are input too — validate the magic bytes rather than the extension, cap the size, rename to a random ID, and store outside the web root:
import os
import uuid
import magic
ALLOWED_MIME_TYPES = {
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'application/pdf': '.pdf',
}
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
def validate_and_save_upload(file_bytes: bytes, original_name: str) -> str:
"""Validate and safely save an uploaded file."""
# Check file size
if len(file_bytes) > MAX_FILE_SIZE:
raise ValueError(f'File exceeds {MAX_FILE_SIZE // 1024 // 1024}MB limit')
# Detect real MIME type from magic bytes, not extension
mime = magic.from_buffer(file_bytes, mime=True)
if mime not in ALLOWED_MIME_TYPES:
raise ValueError(f'File type {mime} not allowed')
# Generate random filename, preserve safe extension
ext = ALLOWED_MIME_TYPES[mime]
safe_name = f'{uuid.uuid4().hex}{ext}'
# Store outside web root
upload_dir = '/var/uploads'
safe_path = os.path.join(upload_dir, safe_name)
# Verify path doesn't escape upload dir
if not os.path.realpath(safe_path).startswith(upload_dir):
raise ValueError('Invalid file path')
with open(safe_path, 'wb') as f:
f.write(file_bytes)
return safe_name
File upload validation (Java)
The same approach in Java — sniff the signature bytes, then write under a generated name:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
public class UploadValidator {
private static final long MAX_SIZE = 5L * 1024 * 1024; // 5 MB
private static final Path UPLOAD_DIR = Path.of("/var/uploads");
private static final Map<String, byte[]> MAGIC = Map.of(
".jpg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF},
".png", new byte[]{(byte) 0x89, 0x50, 0x4E, 0x47},
".pdf", new byte[]{0x25, 0x50, 0x44, 0x46} // "%PDF"
);
public static String save(byte[] data) throws IOException {
if (data.length > MAX_SIZE) {
throw new IllegalArgumentException("File exceeds 5MB limit");
}
String ext = null;
for (var entry : MAGIC.entrySet()) {
byte[] sig = entry.getValue();
boolean match = data.length >= sig.length;
for (int i = 0; match && i < sig.length; i++) {
match = data[i] == sig[i];
}
if (match) { ext = entry.getKey(); break; }
}
if (ext == null) {
throw new IllegalArgumentException("File type not allowed");
}
Path target = UPLOAD_DIR.resolve(UUID.randomUUID() + ext).normalize();
if (!target.startsWith(UPLOAD_DIR)) {
throw new IllegalArgumentException("Invalid file path");
}
Files.write(target, data);
return target.getFileName().toString();
}
}
Companion code: the sanitize-user-input companion repo has the path traversal guard and NoSQL sanitizer as runnable Node scripts plus a Python validation example.
What Works
- Validate first, then sanitize: reject invalid input early; sanitization is a safety net, not a gatekeeper
- Use allow-lists, not block-lists: define what’s permitted (tags, protocols, characters) rather than trying to block every attack vector
- Parameterized queries for all SQL: prepared statements eliminate SQL injection regardless of input content
- Context-aware encoding: use HTML encoding in HTML, JS encoding in
<script>blocks, CSS encoding in style attributes - Rate-limit and size-limit: cap request body size and rate to prevent ReDoS and memory exhaustion attacks
Common Mistakes
- Black-listing HTML tags: attackers invent new tags and attributes; allow-lists are the only reliable approach
- Sanitizing after validation: validation should happen on raw input; sanitizing first can bypass validation rules
- Using regex for HTML parsing: regex can’t parse HTML correctly; always use a proper HTML parser for sanitization
- Encoding once and reusing everywhere: HTML-encoded output is unsafe inside JavaScript strings; encode per context
- Trusting client-side validation: client-side checks improve UX but are trivial to bypass; always re-validate server-side
See Also
- OWASP Input Validation Cheat Sheet: allow-list design, semantic validation, and canonicalization rules.
- OWASP XSS Prevention Cheat Sheet: encoding rules per output context.
- OWASP SQL Injection Prevention: parameterized queries, stored procedures, and ORM pitfalls.
- Input validation: schema-based validation patterns that complement sanitization.
Frequently Asked Questions
Should I sanitize input on the client or server?
Always sanitize on the server. Client-side checks are nice for UX, but anyone can skip them by sending raw HTTP requests — they're a convenience, not a boundary. The server is where the trust decision actually happens.
What is the difference between validation and sanitization?
Validation asks "does this look like what we expected?" — a real email, a number in range. Sanitization asks "what in here could hurt us?" — and strips it out, like <script> tags. Validate to reject bad data; sanitize to keep the rest harmless.
Is sanitizing input the same as escaping output?
No. Sanitizing cleans data on the way in; escaping (encoding) makes data safe for wherever it's going — HTML, a <script> block, a URL. They solve opposite ends of the problem, and you need both.
How do I safely handle file uploads?
Validate the file type by inspecting magic bytes, not the extension. Store uploads outside the web root. Rename files to random IDs. Serve them with Content-Disposition: attachment and X-Content-Type-Options: nosniff. Scan with an antivirus if required.
Which sanitization library should I use?
Match it to your stack and threat: bleach (Python), DOMPurify (JS), and JSoup (Java) cover HTML fields. For SQL, use your driver's parameterized queries — no library needed. For OS commands, prefer argument lists over any sanitization at all.
Does sanitizing input replace parameterization in SQL?
No — they solve different problems. Parameterization keeps code and data apart inside the query; sanitization rewrites the data itself. A "clean" string can still break your query the moment you concatenate it, so always parameterize and treat sanitization as a bonus layer.
Related Resources
Escape HTML Entities
How to escape HTML entities to prevent XSS attacks in Python, Java, and JavaScript.
RecipePrevent Cross-Site Scripting (XSS)
How to sanitize user input, escape output, and use Content Security Policy to prevent XSS attacks in web applications.
RecipePrevent SQL Injection Attacks
How to write parameterized queries and use ORMs to eliminate SQL injection vulnerabilities across Python, JavaScript, and Java.
RecipeFile Upload Validation: Secure File Handling Guide
How to handle file uploads securely with size, type, and content validation.
RecipeInput Validation: Schema-First Examples in 3 Languages
Validate user input at the API boundary with schema libraries. Pydantic, Zod, and Jakarta Validation examples with sanitization and structured errors.
RecipeRate Limiting
How to implement API rate limiting using token bucket, sliding window, and fixed window algorithms across Python, JavaScript, and Java.