How to Implement Secure Form Validation

Share
How-To Guide

How to Implement Secure Form Validation

Client-side validation is for UX. Server-side validation is for security.

TL;DR

TL;DR (20 minutes)

Always validate on the server, even if you have client-side validation. Use Zod schemas shared between client and server. Add CSRF tokens to all state-changing forms. Implement honeypots to catch bots. Rate limit submissions to prevent abuse.

Prerequisites

  • Node.js 18+ installed
  • React or Next.js project
  • Basic understanding of forms and HTTP
  • npm or yarn package manager

Why Secure Form Validation Matters

Forms are the primary entry point for user input and a major attack vector. Without proper validation, attackers can inject malicious data, bypass business rules, and overwhelm your systems with spam.

Why Client-Side Validation Isn't Enough: Users can bypass client-side validation by disabling JavaScript, using browser DevTools, or calling your API directly with tools like curl. Never rely on client-side validation for security.

Step-by-Step Guide

1

Install required packages

Install Zod for schema validation and React Hook Form for client-side forms:

npm install zod react-hook-form @hookform/resolvers
2

Define shared validation schemas

Create schemas that work on both client and server:

// lib/schemas/contact.ts
import { z } from 'zod';

export const ContactFormSchema = z.object({
  name: z.string()
    .min(1, 'Name is required')
    .max(100, 'Name must be less than 100 characters')
    .regex(/^[a-zA-Z\s'-]+$/, 'Name contains invalid characters'),

  email: z.string()
    .email('Please enter a valid email')
    .max(254, 'Email is too long')
    .toLowerCase(),

  subject: z.enum(['general', 'support', 'sales', 'feedback'], {
    errorMap: () => ({ message: 'Please select a valid subject' }),
  }),

  message: z.string()
    .min(10, 'Message must be at least 10 characters')
    .max(5000, 'Message must be less than 5000 characters'),

  // Honeypot field - should always be empty
  website: z.string().max(0, 'Invalid submission').optional(),

  // Timestamp for timing-based bot detection
  _timestamp: z.number().optional(),
});

export type ContactFormData = z.infer<typeof ContactFormSchema>;

// More schemas for common forms
export const SignupFormSchema = z.object({
  email: z.string().email().max(254).toLowerCase(),
  password: z.string()
    .min(8, 'Password must be at least 8 characters')
    .max(100)
    .regex(/[A-Z]/, 'Password must contain an uppercase letter')
    .regex(/[a-z]/, 'Password must contain a lowercase letter')
    .regex(/[0-9]/, 'Password must contain a number'),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: 'Passwords do not match',
  path: ['confirmPassword'],
});

export const LoginFormSchema = z.object({
  email: z.string().email().max(254).toLowerCase(),
  password: z.string().min(1, 'Password is required').max(100),
});
3

Create a secure form component

Build a reusable form with client-side validation:

// components/ContactForm.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { ContactFormSchema, ContactFormData } from '@/lib/schemas/contact';
import { useState, useEffect } from 'react';

export function ContactForm() {
  const [submitStatus, setSubmitStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const [errorMessage, setErrorMessage] = useState('');
  const [formLoadTime] = useState(Date.now());

  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm<ContactFormData>({
    resolver: zodResolver(ContactFormSchema),
  });

  const onSubmit = async (data: ContactFormData) => {
    setSubmitStatus('loading');
    setErrorMessage('');

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          ...data,
          _timestamp: formLoadTime,
        }),
      });

      const result = await response.json();

      if (!response.ok) {
        throw new Error(result.error || 'Submission failed');
      }

      setSubmitStatus('success');
      reset();
    } catch (error) {
      setSubmitStatus('error');
      setErrorMessage(error instanceof Error ? error.message : 'Something went wrong');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      {/* Honeypot field - hidden from real users */}
      <div style={{ position: 'absolute', left: '-9999px' }} aria-hidden="true">
        <label htmlFor="website">Website (leave empty)</label>
        <input
          type="text"
          id="website"
          tabIndex={-1}
          autoComplete="off"
          {...register('website')}
        />
      </div>

      <div>
        <label htmlFor="name">Name *</label>
        <input
          type="text"
          id="name"
          {...register('name')}
          aria-invalid={errors.name ? 'true' : 'false'}
        />
        {errors.name && (
          <p className="error" role="alert">{errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email">Email *</label>
        <input
          type="email"
          id="email"
          {...register('email')}
          aria-invalid={errors.email ? 'true' : 'false'}
        />
        {errors.email && (
          <p className="error" role="alert">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="subject">Subject *</label>
        <select id="subject" {...register('subject')}>
          <option value="">Select a subject</option>
          <option value="general">General Inquiry</option>
          <option value="support">Technical Support</option>
          <option value="sales">Sales</option>
          <option value="feedback">Feedback</option>
        </select>
        {errors.subject && (
          <p className="error" role="alert">{errors.subject.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="message">Message *</label>
        <textarea
          id="message"
          rows={5}
          {...register('message')}
          aria-invalid={errors.message ? 'true' : 'false'}
        />
        {errors.message && (
          <p className="error" role="alert">{errors.message.message}</p>
        )}
      </div>

      {submitStatus === 'error' && (
        <div className="error-box" role="alert">{errorMessage}</div>
      )}

      {submitStatus === 'success' && (
        <div className="success-box" role="status">Message sent successfully!</div>
      )}

      <button type="submit" disabled={submitStatus === 'loading'}>
        {submitStatus === 'loading' ? 'Sending...' : 'Send Message'}
      </button>
    </form>
  );
}
4

Implement server-side validation

Create the API route with full server-side validation:

// app/api/contact/route.ts
import { ContactFormSchema } from '@/lib/schemas/contact';
import { rateLimit } from '@/lib/rate-limit';
import { sanitizePlainText } from '@/lib/sanitize';
import { headers } from 'next/headers';

// Rate limit: 5 requests per minute per IP
const limiter = rateLimit({
  interval: 60 * 1000,
  uniqueTokenPerInterval: 500,
});

export async function POST(request: Request) {
  // Get client IP for rate limiting
  const headersList = headers();
  const ip = headersList.get('x-forwarded-for') || 'anonymous';

  // Check rate limit
  try {
    await limiter.check(5, ip);
  } catch {
    return Response.json(
      { error: 'Too many requests. Please try again later.' },
      { status: 429 }
    );
  }

  try {
    const body = await request.json();

    // Server-side validation
    const result = ContactFormSchema.safeParse(body);

    if (!result.success) {
      return Response.json(
        {
          error: 'Validation failed',
          details: result.error.flatten(),
        },
        { status: 400 }
      );
    }

    const { name, email, subject, message, website, _timestamp } = result.data;

    // Honeypot check - if filled, it's a bot
    if (website && website.length > 0) {
      // Log for monitoring but return success to avoid tipping off the bot
      console.warn('Honeypot triggered:', { ip, email });
      return Response.json({ success: true });
    }

    // Timing check - if submitted too quickly, likely a bot
    if (_timestamp) {
      const submitTime = Date.now() - _timestamp;
      if (submitTime < 3000) { // Less than 3 seconds
        console.warn('Form submitted too quickly:', { ip, submitTime });
        return Response.json({ success: true });
      }
    }

    // Sanitize inputs before storing/sending
    const sanitizedData = {
      name: sanitizePlainText(name),
      email: email.toLowerCase().trim(),
      subject,
      message: sanitizePlainText(message),
    };

    // Store in database or send email
    await db.contactSubmission.create({
      data: {
        ...sanitizedData,
        ipAddress: ip,
        submittedAt: new Date(),
      },
    });

    // Optional: Send notification email
    // await sendEmail({ to: 'team@example.com', ... });

    return Response.json({ success: true });
  } catch (error) {
    console.error('Contact form error:', error);
    return Response.json(
      { error: 'An error occurred. Please try again.' },
      { status: 500 }
    );
  }
}
5

Add CSRF protection for traditional forms

If using traditional form submissions (not fetch/AJAX), add CSRF tokens:

// lib/csrf.ts
import { cookies } from 'next/headers';
import { randomBytes, createHmac } from 'crypto';

const CSRF_SECRET = process.env.CSRF_SECRET!;

export function generateCsrfToken(): string {
  const token = randomBytes(32).toString('hex');
  const timestamp = Date.now().toString();
  const signature = createHmac('sha256', CSRF_SECRET)
    .update(`${token}:${timestamp}`)
    .digest('hex');

  const fullToken = `${token}:${timestamp}:${signature}`;

  // Store in httpOnly cookie
  cookies().set('csrf-token', fullToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 60 * 60, // 1 hour
  });

  return token;
}

export function verifyCsrfToken(submittedToken: string): boolean {
  const storedToken = cookies().get('csrf-token')?.value;
  if (!storedToken) return false;

  const [token, timestamp, signature] = storedToken.split(':');

  // Verify signature
  const expectedSignature = createHmac('sha256', CSRF_SECRET)
    .update(`${token}:${timestamp}`)
    .digest('hex');

  if (signature !== expectedSignature) return false;

  // Check expiry (1 hour)
  if (Date.now() - parseInt(timestamp) > 60 * 60 * 1000) return false;

  // Compare tokens
  return token === submittedToken;
}

// Usage in form page
// app/contact/page.tsx
import { generateCsrfToken } from '@/lib/csrf';

export default function ContactPage() {
  const csrfToken = generateCsrfToken();

  return (
    <form action="/api/contact" method="POST">
      <input type="hidden" name="_csrf" value={csrfToken} />
      {/* ... other fields */}
    </form>
  );
}

// Verify in API route
const csrfToken = body._csrf;
if (!verifyCsrfToken(csrfToken)) {
  return Response.json({ error: 'Invalid CSRF token' }, { status: 403 });
}
6

Implement rate limiting

Create a rate limiter to prevent abuse:

// lib/rate-limit.ts
import { LRUCache } from 'lru-cache';

interface RateLimitOptions {
  interval: number;
  uniqueTokenPerInterval: number;
}

export function rateLimit(options: RateLimitOptions) {
  const tokenCache = new LRUCache<string, number[]>({
    max: options.uniqueTokenPerInterval,
    ttl: options.interval,
  });

  return {
    check: (limit: number, token: string): Promise<void> =>
      new Promise((resolve, reject) => {
        const tokenCount = tokenCache.get(token) || [0];
        const currentUsage = tokenCount[0];

        if (currentUsage >= limit) {
          reject(new Error('Rate limit exceeded'));
          return;
        }

        tokenCache.set(token, [currentUsage + 1]);
        resolve();
      }),
  };
}

// More sophisticated: Use Redis for distributed rate limiting
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export async function checkRateLimit(
  key: string,
  limit: number,
  windowMs: number
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
  const now = Date.now();
  const windowStart = now - windowMs;

  // Remove old entries and add new one
  await redis.zremrangebyscore(key, 0, windowStart);
  const count = await redis.zcard(key);

  if (count >= limit) {
    const oldestTime = await redis.zrange(key, 0, 0, 'WITHSCORES');
    const resetTime = parseInt(oldestTime[1]) + windowMs;
    return { allowed: false, remaining: 0, resetTime };
  }

  await redis.zadd(key, now, `${now}-${Math.random()}`);
  await redis.expire(key, Math.ceil(windowMs / 1000));

  return { allowed: true, remaining: limit - count - 1, resetTime: now + windowMs };
}

Security Checklist

  • Always validate on the server (never trust client-side only)
  • Use the same Zod schema for client and server validation
  • Sanitize all inputs before storing or displaying
  • Add honeypot fields to catch bots
  • Implement timing-based bot detection (reject instant submissions)
  • Add CSRF tokens for cookie-based auth with traditional forms
  • Implement rate limiting per IP/user
  • Log failed validation attempts for security monitoring
  • Return generic error messages (don't reveal validation logic)
  • Use secure, httpOnly cookies for session tokens

How to Verify It Worked

Test your form validation security:

// Test form security

// 1. Test client-side bypass
// Open browser DevTools, disable JavaScript, submit form
// Server should still validate and reject invalid data

// 2. Test with curl (bypasses all client validation)
curl -X POST http://localhost:3000/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"","email":"notanemail","message":"hi"}'
# Should return 400 with validation errors

// 3. Test honeypot
curl -X POST http://localhost:3000/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"Bot","email":"bot@spam.com","message":"Buy now!","website":"http://spam.com"}'
# Should return success but not actually process

// 4. Test rate limiting
for i in {1..10}; do
  curl -X POST http://localhost:3000/api/contact \
    -H "Content-Type: application/json" \
    -d '{"name":"Test","email":"test@test.com","subject":"general","message":"Testing rate limits"}'
done
# Should start returning 429 after limit

// 5. Test XSS in inputs
curl -X POST http://localhost:3000/api/contact \
  -H "Content-Type: application/json" \
  -d '{"name":"<script>alert(1)</script>","email":"test@test.com","subject":"general","message":"Test"}'
# Should sanitize or reject the script tag

Pro Tip: Use tools like Burp Suite or OWASP ZAP to test your forms for security vulnerabilities. They can automatically test for injection attacks, CSRF, and other common issues.

Common Errors and Troubleshooting

Error: Zod schema not validating on server

// Problem: Using parse() which throws instead of returning errors
const data = ContactFormSchema.parse(body); // Throws on invalid

// Solution: Use safeParse() for controlled error handling
const result = ContactFormSchema.safeParse(body);
if (!result.success) {
  return Response.json({ error: result.error.flatten() }, { status: 400 });
}

Error: Form submits but CSRF token is invalid

// Problem: Token generated on server but not matching

// Solution: Ensure token is generated and passed correctly
// 1. Generate token in server component
const csrfToken = generateCsrfToken();

// 2. Pass to form (not via state that resets)
<input type="hidden" name="_csrf" value={csrfToken} />

// 3. Verify the same token on submission
if (!verifyCsrfToken(body._csrf)) { ... }

Error: Rate limiter not working in production

// Problem: In-memory rate limiter doesn't work with multiple instances

// Solution: Use Redis or a database for distributed rate limiting
// See the Redis example in Step 6 above

// Or use a service like Upstash Redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '1 m'),
});

Error: Honeypot field visible to users

// Problem: CSS not hiding the honeypot properly

// Solution: Use multiple hiding techniques
<div
  style={{
    position: 'absolute',
    left: '-9999px',
    top: '-9999px',
    opacity: 0,
    height: 0,
    overflow: 'hidden',
  }}
  aria-hidden="true"
>
  <input tabIndex={-1} autoComplete="off" {...register('website')} />
</div>

Frequently Asked Questions

Do I need CSRF tokens if I'm using fetch/AJAX?

If you're using modern fetch with SameSite cookies and only accepting JSON content-type, CSRF protection is largely handled automatically. However, adding CSRF tokens provides defense-in-depth and supports older browsers.

Should I show validation errors to users?

Show friendly validation errors for legitimate mistakes (invalid email format, required fields). For security-related rejections (rate limits, honeypot triggers), return generic success to avoid tipping off attackers.

How do I handle file uploads in forms?

File uploads need additional security measures. See our File Upload Security guide for validation, malware scanning, and secure storage.

Is reCAPTCHA better than honeypots?

Honeypots are simpler and don't hurt UX. reCAPTCHA is more robust but adds friction. Consider using both: honeypots for basic bot blocking, reCAPTCHA for high-value forms or when honeypots aren't enough.

Should I validate on blur or submit?

Validate on blur for immediate feedback on individual fields. Always validate the full form on submit. Server-side validation happens on submit regardless of client-side choices.

How-To Guides

How to Implement Secure Form Validation