Building a Secure React Form With Input Sanitization
A secure React form combines client-side validation, output escaping, sanitization, CSRF protection, and backend validation into a unified defense strategy. This final article walks through building a production-grade contact form that implements every security pattern from this series: input validation for UX, DOMPurify sanitization for rich text, Content Security Policy headers, and a CSRF token. By the end, you will have a reusable, secure form component and the architectural knowledge to apply these principles to any form in your React app.
Security Checklist for Forms
Before writing code, review this checklist:
- Client-side input validation (UX feedback, not security).
- Backend input validation (security boundary).
- Output escaping (React automatic for text, manual for attributes).
- CSRF token (prevent cross-origin form submission).
- HTTPS only (encrypt data in transit).
- No sensitive data in URLs (use POST, not GET).
- Rate limiting (prevent brute force and DoS).
- Logging and monitoring (detect attacks).
Step 1: Backend Setup (Node.js/Express)
First, set up the backend to serve the form and handle submissions securely:
// backend.js (Node.js/Express)
const express = require('express');
const session = require('express-session');
const csrf = require('csurf');
const rateLimit = require('express-rate-limit');
const DOMPurify = require('isomorphic-dompurify');
const crypto = require('crypto');
const app = express();
// Session middleware (required for CSRF tokens)
app.use(session({
secret: crypto.randomBytes(32).toString('hex'),
resave: false,
saveUninitialized: true,
cookie: {
httpOnly: true, // Prevent JavaScript access
secure: true, // HTTPS only
sameSite: 'strict' // CSRF protection
}
}));
// Body parser for JSON
app.use(express.json({ limit: '10kb' })); // Limit payload size
// CSRF protection middleware
const csrfProtection = csrf({ cookie: false }); // Use session, not cookies
// Rate limiting: max 5 form submissions per minute per IP
const contactLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 5, // 5 requests per window
message: 'Too many form submissions; please try again later',
standardHeaders: true,
legacyHeaders: false,
});
// CSP header middleware
app.use((req, res, next) => {
const nonce = crypto.randomBytes(16).toString('base64');
const csp = [
"default-src 'self'",
`script-src 'self' 'nonce-${nonce}'`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"connect-src 'self'",
"frame-src 'none'",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self'",
"upgrade-insecure-requests"
].join('; ');
res.setHeader('Content-Security-Policy', csp);
res.locals.nonce = nonce;
next();
});
// GET: Display contact form with CSRF token
app.get('/contact', csrfProtection, (req, res) => {
res.json({
csrfToken: req.csrfToken(),
nonce: res.locals.nonce,
});
});
// POST: Handle form submission
app.post('/api/contact', contactLimiter, csrfProtection, (req, res) => {
const { name, email, message, subject } = req.body;
// INPUT VALIDATION (backend)
const errors = [];
if (typeof name !== 'string' || name.trim().length === 0) {
errors.push('Name is required');
} else if (name.length > 100) {
errors.push('Name too long (max 100 characters)');
}
if (typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
errors.push('Invalid email format');
} else if (email.length > 255) {
errors.push('Email too long');
}
if (typeof subject !== 'string' || subject.trim().length === 0) {
errors.push('Subject is required');
} else if (subject.length > 200) {
errors.push('Subject too long (max 200 characters)');
}
if (typeof message !== 'string' || message.trim().length === 0) {
errors.push('Message is required');
} else if (message.length > 5000) {
errors.push('Message too long (max 5000 characters)');
}
if (errors.length > 0) {
return res.status(400).json({ errors });
}
// SANITIZATION: Clean HTML (if rich text is expected)
const cleanMessage = DOMPurify.sanitize(message, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a'],
ALLOWED_ATTR: ['href'],
});
// Store in database (or send email)
console.log('Contact form submission:', {
name: name.trim(),
email: email.trim(),
subject: subject.trim(),
message: cleanMessage,
submittedAt: new Date(),
ip: req.ip,
});
// Example: Send email
// await sendEmail({
// to: '[email protected]',
// subject: `New contact form: ${subject.trim()}`,
// html: `<p>From: ${cleanMessage(name.trim())}</p><p>${cleanMessage}</p>`,
// });
res.json({
success: true,
message: 'Thank you for your message. We will respond shortly.'
});
});
// CSP Violation Reporting
app.post('/api/csp-report', (req, res) => {
console.warn('CSP Violation:', req.body);
res.status(204).send();
});
app.listen(3000, () => {
console.log('Server running on https://localhost:3000');
});
Step 2: React Form Component
Now build the React form component with client-side validation:
// ContactForm.jsx
import React, { useState, useEffect } from 'react';
import DOMPurify from 'dompurify';
function ContactForm() {
// Form state
const [formData, setFormData] = useState({
name: '',
email: '',
subject: '',
message: '',
});
const [errors, setErrors] = useState({});
const [serverErrors, setServerErrors] = useState([]);
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
const [csrfToken, setCsrfToken] = useState('');
// Fetch CSRF token on mount
useEffect(() => {
fetch('/contact')
.then((res) => res.json())
.then((data) => setCsrfToken(data.csrfToken))
.catch((err) => console.error('Failed to fetch CSRF token:', err));
}, []);
// INPUT VALIDATION (client-side, for UX)
const validateForm = () => {
const newErrors = {};
// Validate name
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
} else if (formData.name.length > 100) {
newErrors.name = 'Name too long (max 100 characters)';
} else if (!/^[a-zA-Z\s\-']+$/.test(formData.name)) {
newErrors.name = 'Name contains invalid characters';
}
// Validate email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!emailRegex.test(formData.email)) {
newErrors.email = 'Invalid email format';
} else if (formData.email.length > 255) {
newErrors.email = 'Email too long';
}
// Validate subject
if (!formData.subject.trim()) {
newErrors.subject = 'Subject is required';
} else if (formData.subject.length > 200) {
newErrors.subject = 'Subject too long (max 200 characters)';
}
// Validate message
if (!formData.message.trim()) {
newErrors.message = 'Message is required';
} else if (formData.message.length > 5000) {
newErrors.message = 'Message too long (max 5000 characters)';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
// Handle input change
const handleChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
// Clear error for this field if user is correcting it
if (errors[name]) {
setErrors((prev) => {
const updated = { ...prev };
delete updated[name];
return updated;
});
}
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
// Reset messages
setServerErrors([]);
setSuccess(false);
// CLIENT-SIDE VALIDATION
if (!validateForm()) {
return;
}
setLoading(true);
try {
// CSRF token must be sent as header (not in body)
const res = await fetch('/api/contact', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken, // CSRF protection
},
body: JSON.stringify(formData),
});
const data = await res.json();
if (!res.ok) {
// Backend validation errors
setServerErrors(data.errors || ['Form submission failed']);
return;
}
// Success
setSuccess(true);
setFormData({ name: '', email: '', subject: '', message: '' });
setErrors({});
// Clear success message after 5 seconds
setTimeout(() => setSuccess(false), 5000);
} catch (err) {
setServerErrors(['Network error; please try again']);
console.error('Form submission error:', err);
} finally {
setLoading(false);
}
};
// Remaining character count
const messageCharsRemaining = 5000 - formData.message.length;
return (
<form onSubmit={handleSubmit} className="contact-form" noValidate>
{/* Server errors */}
{serverErrors.length > 0 && (
<div className="alert alert-error">
<ul>
{serverErrors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
</div>
)}
{/* Success message */}
{success && (
<div className="alert alert-success">
Thank you for your message. We will respond shortly.
</div>
)}
{/* Name field */}
<div className="form-group">
<label htmlFor="name">Name *</label>
<input
id="name"
type="text"
name="name"
value={formData.name}
onChange={handleChange}
placeholder="Your full name"
disabled={loading}
required
maxLength={100}
aria-invalid={!!errors.name}
aria-describedby={errors.name ? 'name-error' : undefined}
/>
{errors.name && (
<span id="name-error" className="error">{errors.name}</span>
)}
</div>
{/* Email field */}
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
id="email"
type="email"
name="email"
value={formData.email}
onChange={handleChange}
placeholder="[email protected]"
disabled={loading}
required
maxLength={255}
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : undefined}
/>
{errors.email && (
<span id="email-error" className="error">{errors.email}</span>
)}
</div>
{/* Subject field */}
<div className="form-group">
<label htmlFor="subject">Subject *</label>
<input
id="subject"
type="text"
name="subject"
value={formData.subject}
onChange={handleChange}
placeholder="What is this about?"
disabled={loading}
required
maxLength={200}
aria-invalid={!!errors.subject}
aria-describedby={errors.subject ? 'subject-error' : undefined}
/>
{errors.subject && (
<span id="subject-error" className="error">{errors.subject}</span>
)}
</div>
{/* Message field */}
<div className="form-group">
<label htmlFor="message">Message *</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
placeholder="Your message (up to 5000 characters)..."
disabled={loading}
required
maxLength={5000}
rows={6}
aria-invalid={!!errors.message}
aria-describedby={errors.message ? 'message-error' : undefined}
/>
<span className="char-count">
{messageCharsRemaining} characters remaining
</span>
{errors.message && (
<span id="message-error" className="error">{errors.message}</span>
)}
</div>
{/* CSRF token hidden field (optional; can be sent as header instead) */}
<input type="hidden" name="_csrf" value={csrfToken} />
{/* Submit button */}
<button
type="submit"
disabled={loading || Object.keys(errors).length > 0}
className="submit-button"
>
{loading ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
export default ContactForm;
Step 3: CSS Styling (Optional)
Basic styles for the form:
.contact-form {
max-width: 600px;
margin: 0 auto;
padding: 20px;
font-family: system-ui, sans-serif;
}
.form-group {
margin-bottom: 20px;
display: flex;
flex-direction: column;
}
.form-group label {
font-weight: bold;
margin-bottom: 5px;
color: #333;
}
.form-group input,
.form-group textarea {
padding: 10px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
font-family: inherit;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.25);
}
.form-group input[aria-invalid="true"],
.form-group textarea[aria-invalid="true"] {
border-color: #dc3545;
}
.error {
color: #dc3545;
font-size: 12px;
margin-top: 5px;
}
.char-count {
font-size: 12px;
color: #666;
margin-top: 5px;
}
.submit-button {
padding: 12px 24px;
background-color: #007bff;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: background-color 0.2s;
}
.submit-button:hover:not(:disabled) {
background-color: #0056b3;
}
.submit-button:disabled {
background-color: #ccc;
cursor: not-allowed;
}
.alert {
padding: 12px;
border-radius: 4px;
margin-bottom: 20px;
}
.alert-error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.alert-success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.alert ul {
margin: 0;
padding-left: 20px;
}
.alert li {
margin: 5px 0;
}
Security Features Implemented
This form includes all security patterns from the series:
| Feature | Implementation | Article |
|---|---|---|
| Input validation | Client-side for UX, backend for security | Article 7 |
| Output escaping | React auto-escapes text; form data rendered as text | Article 2 |
| Sanitization | DOMPurify on backend for rich text | Article 5 |
| Attribute validation | Not applicable to form (no user-provided attributes) | Article 6 |
| URL validation | Not applicable to form | Article 6 |
| CSRF protection | CSRF token sent in header | This article |
| Rate limiting | 5 submissions per minute per IP | This article |
| CSP headers | Sent by backend; script-src nonce | Article 9 |
| HTTPS | secure and httpOnly cookie flags | This article |
| Payload limit | 10KB max JSON payload | This article |
Testing the Secure Form
Unit Tests (Jest + React Testing Library)
// ContactForm.test.js
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import ContactForm from './ContactForm';
describe('ContactForm', () => {
test('displays validation error for empty name', async () => {
render(<ContactForm />);
const submitButton = screen.getByText('Send Message');
fireEvent.click(submitButton);
await waitFor(() => {
expect(screen.getByText('Name is required')).toBeInTheDocument();
});
});
test('submits form with valid data', async () => {
// Mock fetch
global.fetch = jest.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({ success: true }),
});
render(<ContactForm />);
// Fill form
fireEvent.change(screen.getByLabelText(/name/i), {
target: { value: 'John Doe' }
});
fireEvent.change(screen.getByLabelText(/email/i), {
target: { value: '[email protected]' }
});
fireEvent.change(screen.getByLabelText(/subject/i), {
target: { value: 'Test' }
});
fireEvent.change(screen.getByLabelText(/message/i), {
target: { value: 'This is a test message' }
});
// Submit
fireEvent.click(screen.getByText('Send Message'));
// Verify fetch was called with CSRF token
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/contact',
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'X-CSRF-Token': expect.any(String),
}),
})
);
});
});
test('prevents XSS in form submission', async () => {
// Attempt to inject script
render(<ContactForm />);
fireEvent.change(screen.getByLabelText(/message/i), {
target: { value: '<img src=x onerror="alert(1)">' }
});
// Form should not execute the script (React escapes text nodes)
// Backend also sanitizes with DOMPurify
expect(screen.queryByText('<img src=x onerror=')).not.toBeInTheDocument();
});
});
Manual Testing Checklist
- Submit valid data; verify success message.
- Submit invalid data; verify client-side errors.
- Disable JavaScript; verify form still submits to backend (which validates).
- Submit with malicious payload (e.g.,
<script>alert(1)</script>); verify it's escaped/sanitized. - Modify CSRF token and resubmit; verify backend rejects it.
- Submit 10 times in 1 minute; verify rate limiting blocks requests after 5.
- Inspect browser network tab; verify POST request uses HTTPS and includes CSRF header.
- Open DevTools console; verify no XSS alerts or CSP violations.
Key Takeaways
- Secure forms are layered: client-side validation (UX), backend validation (security), CSRF tokens, rate limiting, and HTTPS.
- Never trust the client: Always validate on the backend, even if client validation passes.
- Sanitize user input: Use DOMPurify if rich text is expected; React escaping is sufficient for plain text.
- Protect against CSRF: Send CSRF tokens in request headers, not cookies.
- Rate limit: Prevent brute force and DoS attacks by limiting form submissions per IP.
- Log and monitor: Track form submissions and errors for security auditing.
Frequently Asked Questions
Why is CSRF protection needed if I use POST and validate input?
CSRF (Cross-Site Request Forgery) is a different attack than XSS or injection. An attacker tricks a user into submitting a form to your backend from a different website (attacker's site). A CSRF token ensures the form submission came from your app, not an external source. Even with validation, a forged POST request can submit data.
Can I use local storage for the CSRF token?
No. Local storage is accessible to JavaScript, so an XSS vulnerability could steal it. CSRF tokens should be in HTTP-only cookies (set by the backend) or in the HTML (server-rendered). Express's csurf middleware handles this automatically.
Should I expose the CSRF token in the form HTML?
Yes, if using server-side rendering. The token should be in a hidden input or fetched via an API before the form is submitted. Modern approaches use session cookies (HTTP-only) and send the token in a request header, which is safer.
What if the backend doesn't have DOMPurify?
Install the appropriate library: npm install dompurify (Node.js, isomorphic-dompurify for SSR), pip install bleach (Python), or use your framework's built-in sanitization (Django's django.utils.html.escape).
How do I test form security?
Use automated tools (OWASP ZAP, Burp Suite) to scan the form, run unit tests for validation logic, and manually test with malicious payloads. Set up a staging environment with real data (anonymized) and audit the backend logs for suspicious submissions.