Featured Post

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.

Richard Joseph Porter
11 min read
securityformsspam-protectionvalidationbackendrate-limitingapi-securityupstash

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> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;'
  };
  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

  1. Multi-Layer Defense: Implement multiple security measures (honeypots, rate limiting, spam detection)
  2. Input Validation: Always validate and sanitize user input on the server side
  3. Rate Limiting: Use distributed rate limiting with multiple tiers (IP, email, global)
  4. Monitoring: Log security events and monitor for patterns
  5. Progressive Enhancement: Ensure forms work even if JavaScript is disabled
  6. Error Handling: Don't reveal system information in error messages
  7. 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 - Professional headshot

Richard Joseph Porter

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