Contact Form Security: Best Practices for Spam Protection
Learn essential contact form security practices: spam protection, rate limiting, input validation, and secure email handling. Protect your application from attacks.
Contact forms are essential for business websites and developer portfolios but represent a significant security vulnerability if not properly implemented. They're prime targets for spam, injection attacks, and abuse. In this comprehensive guide, I'll walk you through implementing robust security measures for contact forms.
Common Contact Form Vulnerabilities
Before diving into solutions, let's understand the threats:
1. Spam and Bot Attacks
- Automated form submissions
- Resource exhaustion
- Email flooding
- Content injection
2. Injection Attacks
- SQL injection through form fields
- Email header injection
- XSS (Cross-Site Scripting)
- Command injection
3. Rate Limiting Issues
- Brute force attacks
- DoS (Denial of Service)
- Resource abuse
4. Data Privacy Concerns
- Unencrypted data transmission
- Inadequate data sanitization
- Information disclosure
Implementing Comprehensive Spam Protection
1. Honeypot Fields
Add hidden fields that humans won't fill but bots will:
// components/ContactForm.tsx
'use client';
import { useState } from 'react';
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: '',
// Honeypot field - should remain empty
website: '',
});
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Visible fields */}
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-4 py-3 border rounded-lg"
placeholder="Your Name"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className="w-full px-4 py-3 border rounded-lg"
placeholder="Your Email"
/>
<textarea
name="message"
value={formData.message}
onChange={handleChange}
required
rows={5}
className="w-full px-4 py-3 border rounded-lg"
placeholder="Your Message"
/>
{/* Honeypot field - hidden from users */}
<input
type="text"
name="website"
value={formData.website}
onChange={handleChange}
className="absolute left-[-9999px] opacity-0"
tabIndex={-1}
autoComplete="off"
/>
<button type="submit" className="bg-blue-600 text-white px-6 py-3 rounded-lg">
Send Message
</button>
</form>
);
}
2. Time-Based Validation
Reject submissions that happen too quickly:
// hooks/useSpamProtection.ts
import { useState, useEffect } from 'react';
export function useSpamProtection() {
const [formStartTime, setFormStartTime] = useState<number>(0);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// Record when form becomes visible
setFormStartTime(Date.now());
// Minimum time before form can be submitted (3 seconds)
const timer = setTimeout(() => {
setIsReady(true);
}, 3000);
return () => clearTimeout(timer);
}, []);
const validateSubmissionTime = (): boolean => {
const submissionTime = Date.now();
const timeTaken = submissionTime - formStartTime;
// Reject if submitted too quickly (less than 3 seconds)
// or too slowly (more than 30 minutes - possible bot)
return timeTaken >= 3000 && timeTaken <= 1800000;
};
return { isReady, validateSubmissionTime };
}
3. CAPTCHA Integration
Implement Google reCAPTCHA v3 for advanced bot detection:
// lib/recaptcha.ts
export async function verifyRecaptcha(token: string): Promise<boolean> {
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
if (!secretKey) {
throw new Error('reCAPTCHA secret key not configured');
}
try {
const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: `secret=${secretKey}&response=${token}`,
});
const data = await response.json();
// Check if verification succeeded and score is above threshold
return data.success && data.score >= 0.5;
} catch (error) {
console.error('reCAPTCHA verification failed:', error);
return false;
}
}
Implementing Rate Limiting
1. Redis-Based Rate Limiting
Use Redis for distributed rate limiting:
// lib/rateLimit.ts
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const redis = new Redis({
url: process.env.UPSTASH_REDIS_REST_URL!,
token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});
// Create rate limiter: 3 requests per 5 minutes per IP
export const ratelimit = new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, '5 m'),
analytics: true,
});
// Enhanced rate limiting with multiple tiers
export const createRateLimiter = (identifier: string) => ({
// Per IP: 3 requests per 5 minutes
ip: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(3, '5 m'),
prefix: `ratelimit:ip:${identifier}`,
}),
// Per email: 1 request per hour (prevents email bombing)
email: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(1, '1 h'),
prefix: `ratelimit:email:${identifier}`,
}),
// Global: 100 requests per hour (prevents system abuse)
global: new Ratelimit({
redis,
limiter: Ratelimit.slidingWindow(100, '1 h'),
prefix: 'ratelimit:global',
}),
});
2. Advanced Spam Detection
Implement content-based spam detection:
// lib/spamDetection.ts
export interface SpamCheckResult {
isSpam: boolean;
confidence: number;
reasons: string[];
}
export function detectSpam(content: {
name: string;
email: string;
message: string;
}): SpamCheckResult {
const reasons: string[] = [];
let spamScore = 0;
// Check for suspicious patterns
const suspiciousPatterns = [
/\b(viagra|casino|lottery|winner|claim|urgent|act now)\b/gi,
/\b(click here|limited time|make money|work from home)\b/gi,
/http[s]?:\/\/[^\s]+/gi, // Multiple URLs
];
suspiciousPatterns.forEach((pattern, index) => {
const matches = content.message.match(pattern);
if (matches && matches.length > 0) {
spamScore += matches.length * 10;
reasons.push(`Suspicious pattern detected (${matches.length} matches)`);
}
});
// Check for excessive capitalization
const capsRatio = (content.message.match(/[A-Z]/g) || []).length / content.message.length;
if (capsRatio > 0.7) {
spamScore += 20;
reasons.push('Excessive capitalization');
}
// Check for email format issues
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(content.email)) {
spamScore += 30;
reasons.push('Invalid email format');
}
// Check for excessive repetition
const words = content.message.toLowerCase().split(/\s+/);
const wordCount = words.reduce((acc, word) => {
acc[word] = (acc[word] || 0) + 1;
return acc;
}, {} as Record<string, number>);
const maxRepeats = Math.max(...Object.values(wordCount));
if (maxRepeats > words.length * 0.3) {
spamScore += 25;
reasons.push('Excessive word repetition');
}
// Check message length (too short might be spam)
if (content.message.length < 10) {
spamScore += 15;
reasons.push('Message too short');
}
return {
isSpam: spamScore >= 30,
confidence: Math.min(spamScore / 100, 1),
reasons,
};
}
Input Validation and Sanitization
1. Schema Validation with Zod
Use Zod for robust type-safe validation:
// lib/validation.ts
import { z } from 'zod';
export const contactFormSchema = z.object({
name: z
.string()
.min(2, 'Name must be at least 2 characters')
.max(100, 'Name must be less than 100 characters')
.regex(/^[a-zA-Z\s'-]+$/, 'Name contains invalid characters'),
email: z
.string()
.email('Invalid email format')
.max(254, 'Email too long'), // RFC 5321 limit
message: z
.string()
.min(10, 'Message must be at least 10 characters')
.max(5000, 'Message must be less than 5000 characters')
.refine(
(message) => {
// Check for balanced parentheses, quotes (prevents injection)
const openParens = (message.match(/\(/g) || []).length;
const closeParens = (message.match(/\)/g) || []).length;
return Math.abs(openParens - closeParens) <= 2;
},
'Message contains suspicious patterns'
),
// Honeypot field should be empty
website: z.string().max(0, 'Bot detected'),
});
export type ContactFormData = z.infer<typeof contactFormSchema>;
2. Server-Side Sanitization
Sanitize input on the server side:
// lib/sanitize.ts
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeInput(input: string): string {
// Remove HTML tags and malicious content
const cleaned = DOMPurify.sanitize(input, {
ALLOWED_TAGS: [],
ALLOWED_ATTR: []
});
// Remove excessive whitespace
return cleaned.replace(/\s+/g, ' ').trim();
}
export function sanitizeContactForm(data: ContactFormData): ContactFormData {
return {
name: sanitizeInput(data.name),
email: sanitizeInput(data.email).toLowerCase(),
message: sanitizeInput(data.message),
website: sanitizeInput(data.website),
};
}
Secure Email Handling
1. Email Service Integration
Use a professional email service like Resend:
// lib/email.ts
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function sendContactEmail(data: ContactFormData): Promise<boolean> {
try {
const { data: result, error } = await resend.emails.send({
from: '[email protected]',
to: '[email protected]',
replyTo: data.email,
subject: `Contact Form: ${data.name}`,
html: generateEmailHTML(data),
text: generateEmailText(data),
});
if (error) {
console.error('Email sending failed:', error);
return false;
}
return true;
} catch (error) {
console.error('Email service error:', error);
return false;
}
}
function generateEmailHTML(data: ContactFormData): string {
// Use template literals with proper escaping
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Contact Form Submission</title>
</head>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<div style="max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #2563eb;">New Contact Form Submission</h2>
<div style="background: #f8fafc; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p><strong>Name:</strong> ${escapeHtml(data.name)}</p>
<p><strong>Email:</strong> ${escapeHtml(data.email)}</p>
</div>
<div style="background: #fff; padding: 20px; border: 1px solid #e2e8f0; border-radius: 8px;">
<h3>Message:</h3>
<p style="white-space: pre-line;">${escapeHtml(data.message)}</p>
</div>
<div style="margin-top: 20px; padding: 10px; background: #fef3c7; border-radius: 4px; font-size: 12px; color: #92400e;">
<strong>Security Note:</strong> This email was sent through a secured contact form with spam protection.
</div>
</div>
</body>
</html>
`;
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
Complete API Route Implementation
1. Secure API Endpoint
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { createRateLimiter } from '@/lib/rateLimit';
import { contactFormSchema, sanitizeContactForm } from '@/lib/validation';
import { detectSpam } from '@/lib/spamDetection';
import { verifyRecaptcha } from '@/lib/recaptcha';
import { sendContactEmail } from '@/lib/email';
export async function POST(request: NextRequest) {
try {
// Get client IP for rate limiting
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? 'anonymous';
const rateLimiter = createRateLimiter(ip);
// Check rate limits
const [ipLimit, globalLimit] = await Promise.all([
rateLimiter.ip.limit(ip),
rateLimiter.global.limit('global'),
]);
if (!ipLimit.success || !globalLimit.success) {
return NextResponse.json(
{
success: false,
error: 'Rate limit exceeded. Please try again later.'
},
{ status: 429 }
);
}
// Parse and validate request body
const body = await request.json();
// Validate with Zod schema
const validationResult = contactFormSchema.safeParse(body);
if (!validationResult.success) {
return NextResponse.json(
{
success: false,
error: 'Invalid form data',
details: validationResult.error.issues
},
{ status: 400 }
);
}
const data = sanitizeContactForm(validationResult.data);
// Honeypot check
if (data.website !== '') {
console.log('Honeypot triggered:', { ip, email: data.email });
return NextResponse.json(
{ success: true, message: 'Thank you for your message!' },
{ status: 200 }
);
}
// Spam detection
const spamCheck = detectSpam(data);
if (spamCheck.isSpam) {
console.log('Spam detected:', {
ip,
email: data.email,
confidence: spamCheck.confidence,
reasons: spamCheck.reasons
});
return NextResponse.json(
{
success: false,
error: 'Message flagged as spam. Please contact us directly.'
},
{ status: 400 }
);
}
// reCAPTCHA verification (if token provided)
if (body.recaptchaToken) {
const isValidCaptcha = await verifyRecaptcha(body.recaptchaToken);
if (!isValidCaptcha) {
return NextResponse.json(
{
success: false,
error: 'reCAPTCHA verification failed'
},
{ status: 400 }
);
}
}
// Email-specific rate limiting
const emailLimit = await rateLimiter.email.limit(data.email);
if (!emailLimit.success) {
return NextResponse.json(
{
success: false,
error: 'Too many messages from this email address. Please try again later.'
},
{ status: 429 }
);
}
// Send email
const emailSent = await sendContactEmail(data);
if (!emailSent) {
throw new Error('Failed to send email');
}
// Log successful submission
console.log('Contact form submitted successfully:', {
ip,
email: data.email,
timestamp: new Date().toISOString()
});
return NextResponse.json({
success: true,
message: 'Thank you for your message! I\'ll get back to you soon.'
});
} catch (error) {
console.error('Contact form error:', error);
return NextResponse.json(
{
success: false,
error: 'An error occurred while processing your message. Please try again.'
},
{ status: 500 }
);
}
}
Monitoring and Analytics
1. Security Logging
Implement comprehensive logging for security monitoring:
// lib/securityLogger.ts
export enum SecurityEvent {
SPAM_DETECTED = 'spam_detected',
RATE_LIMITED = 'rate_limited',
HONEYPOT_TRIGGERED = 'honeypot_triggered',
INJECTION_ATTEMPT = 'injection_attempt',
CAPTCHA_FAILED = 'captcha_failed',
}
export function logSecurityEvent(
event: SecurityEvent,
details: {
ip: string;
userAgent?: string;
email?: string;
severity: 'low' | 'medium' | 'high';
metadata?: Record<string, any>;
}
) {
const logEntry = {
timestamp: new Date().toISOString(),
event,
...details,
};
// Log to console in development
if (process.env.NODE_ENV === 'development') {
console.log('Security Event:', logEntry);
}
// In production, send to monitoring service
// Example: Sentry, DataDog, or custom logging service
if (process.env.NODE_ENV === 'production') {
// Send to your monitoring service
}
}
Testing Security Measures
1. Automated Security Testing
// __tests__/contact-security.test.ts
import { POST } from '@/app/api/contact/route';
import { NextRequest } from 'next/server';
describe('Contact Form Security', () => {
test('should reject honeypot submissions', async () => {
const request = new NextRequest('http://localhost:3000/api/contact', {
method: 'POST',
body: JSON.stringify({
name: 'Test User',
email: '[email protected]',
message: 'Test message',
website: 'spam-bot-filled-this', // Honeypot field
}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(data.success).toBe(true);
});
test('should detect spam content', async () => {
const request = new NextRequest('http://localhost:3000/api/contact', {
method: 'POST',
body: JSON.stringify({
name: 'Spam Bot',
email: '[email protected]',
message: 'URGENT! Click here to win a million dollars! Act now!',
website: '',
}),
});
const response = await POST(request);
const data = await response.json();
expect(response.status).toBe(400);
expect(data.success).toBe(false);
});
test('should enforce rate limiting', async () => {
// Simulate multiple rapid requests
const requests = Array(5).fill(null).map(() =>
POST(new NextRequest('http://localhost:3000/api/contact', {
method: 'POST',
body: JSON.stringify({
name: 'Test User',
email: '[email protected]',
message: 'Valid test message',
website: '',
}),
headers: { 'x-forwarded-for': '192.168.1.1' }
}))
);
const responses = await Promise.all(requests);
const statuses = responses.map(r => r.status);
expect(statuses.filter(s => s === 429).length).toBeGreaterThan(0);
});
});
Best Practices Summary
- Multi-Layer Defense: Implement multiple security measures (honeypots, rate limiting, spam detection)
- Input Validation: Always validate and sanitize user input on the server side
- Rate Limiting: Use distributed rate limiting with multiple tiers (IP, email, global)
- Monitoring: Log security events and monitor for patterns
- Progressive Enhancement: Ensure forms work even if JavaScript is disabled
- Error Handling: Don't reveal system information in error messages
- Regular Updates: Keep dependencies updated and review security measures regularly
Conclusion
Securing contact forms requires a comprehensive approach combining multiple defense layers. By implementing proper spam protection, rate limiting, input validation, and monitoring, you can protect your application from common attacks while maintaining a good user experience. For complete portfolio implementations, consider integrating these security measures alongside features like dark mode support for a professional, polished user experience.
Remember that security is an ongoing process. Regularly review your logs, update your security measures, and stay informed about new threats and protection techniques.
Security is everyone's responsibility. If you found this guide helpful, consider sharing it with other developers who might benefit from these practices.

Richard Joseph Porter
Full-stack developer with expertise in modern web technologies. Passionate about building scalable applications and sharing knowledge through technical writing.