Skip to main content

Defense in Depth: A Secure Contact Form

Black iPhone with lock icon on yellow textile
Aug 21, 20254 min readNext.js, Security, Forms, React

Defense in Depth — Building a Secure Contact Form

Overview

Project: Contact form for danieljoffe.com

Role: Solo Developer

Duration: August 19-24, 2025

Purpose: Ship a production-ready contact form with layered security, not just "a form that sends email"

Business Impact

  • Added hCaptcha spam prevention that blocks automated submissions without getting in real visitors' way
  • Layered on rate limiting and input sanitization to keep abuse out
  • Cut spam submissions to near-zero without adding friction for legitimate users

The Challenge

A contact form on a public portfolio site is an open invitation for abuse. Bots scrape forms around the clock, and a naive version would be drowning in spam inside a few days. I wanted something that felt effortless for real visitors but hostile to automated submissions, and I wanted to do it properly from the start instead of patching holes later.

Requirements:

  • Client-side validation with accessible error states
  • Server-side validation that trusts nothing from the client
  • Bot mitigation without degrading UX
  • Rate limiting to prevent abuse
  • Input sanitization against XSS
  • Proper error handling with structured responses

My Approach

Layer 1: Client-Side Validation with React Hook Form + Yup

The form uses react-hook-form with a yupResolver for instant feedback, and the same Yup schema validates on both the client and the server, so there's one source of truth.

schema.ts
// schema.ts — shared between client and server
export const formSchema = yup
  .object()
  .shape({
    name: yup
      .string()
      .transform(value => (value ? value.trim() : value))
      .matches(VALIDATION_PATTERNS.NAME, 'Name contains invalid characters')
      .min(NAME_MIN_LENGTH, minLengthMessage('Name', NAME_MIN_LENGTH))
      .max(NAME_MAX_LENGTH, maxLengthMessage('Name', NAME_MAX_LENGTH))
      .required('Name is required'),
    email: yup
      .string()
      .transform(value => (value ? value.trim() : value))
      .email('Invalid email address')
      .max(EMAIL_MAX_LENGTH, maxLengthMessage('Email', EMAIL_MAX_LENGTH))
      .required('Email is required'),
    message: yup
      .string()
      .transform(value => (value ? value.trim() : value))
      .test('no-urls', 'Please remove links from your message', val =>
        val ? !/https?:\/\//i.test(val) : true
      )
      .min(MESSAGE_MIN_LENGTH, minLengthMessage('Message', MESSAGE_MIN_LENGTH))
      .max(MESSAGE_MAX_LENGTH, maxLengthMessage('Message', MESSAGE_MAX_LENGTH))
      .required('Message is required'),
    hcaptcha: yup.string().required('Please verify you are human'),
  })
  .required();

The anti-spam URL check in the message field catches a surprising number of automated submissions, since most spam bots can't resist injecting a link.

Every form input includes proper ARIA attributes for accessibility:

ContactForm.tsx
<input
  id='name'
  aria-invalid={!!errors?.name}
  aria-describedby={errors?.name?.message ? 'name-error' : undefined}
  data-sentry-mask
  {...register('name')}
/>
<FormFieldError message={errors?.name?.message} id='name-error' />

The data-sentry-mask attribute ensures PII never reaches error tracking.

Layer 2: Honeypot Field

There's a hidden address field that real users never see. A bot that dutifully fills out every field trips an immediate 403:

ContactForm.tsx
{
  /* Client: invisible to humans, irresistible to bots */
}
<div className='absolute top-0 left-0 size-0 pointer-events-none -z-1 hidden'>
  <input
    name='address'
    placeholder='1234 Main St, Anytown, USA'
    type='text'
    autoComplete='off'
    aria-hidden={true}
    tabIndex={-1}
  />
</div>;
validateFormData.ts
// Server: checked before any other validation
if (data?.address && data.address.length > 0) {
  throw {
    error: { path: 'root.forbidden', message: 'Forbidden' },
    statusCode: 403,
  } as ErrorResponse;
}

The honeypot check sits outside the try/catch block on purpose, because a filled honeypot is a hard rejection, not a validation error.

Layer 3: hCaptcha with Lazy Loading

hCaptcha is loaded dynamically and only when the form scrolls into view, keeping it off the critical path:

ContactForm.tsx
const HCaptcha = dynamic(() => import('@hcaptcha/react-hcaptcha'), {
  ssr: false,
  loading: () => <LoadingDots />,
});
 
// Only load when form is visible (with 200px head start)
useEffect(() => {
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) {
        setShouldLoadCaptcha(true);
        observer.disconnect();
      }
    },
    { rootMargin: '200px' }
  );
  observer.observe(container);
  return () => observer.disconnect();
}, []);

The rootMargin: '200px' kicks off the captcha load before the user actually scrolls to it, so by the time they reach the form it's already there.

Layer 4: Rate Limiting

IP-based throttling with an in-memory store. Five requests per IP within a 15-minute window:

rateLimit.ts
const globalStore = globalThis as typeof globalThis & {
  __apiRateLimitStore?: Map<string, RateLimitEntry>;
  __apiRateLimitLastCleanup?: number;
};
 
export const rateLimit = async (req: NextRequest) => {
  const ip =
    req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
    req.headers.get('x-real-ip');
 
  const entry = incrementRateLimit(ip);
 
  if (entry.count > RATE_LIMIT_MAX) {
    const retryAfterSeconds = Math.ceil((entry.reset - Date.now()) / 1000);
    throw {
      error: { path: 'root.forbidden', message: 'Too many requests.' },
      statusCode: 429,
      retryAfter: retryAfterSeconds,
    } as ErrorResponse;
  }
};

Expired entries get purged every 5 minutes so the store can't grow without bound, and the 429 response carries a Retry-After header.

Layer 5: Input Sanitization

Every text field runs through DOMPurify before schema validation:

validateFormData.ts
const sanitized: RawFormData = {
  name: DOMPurify.sanitize(data.name),
  email: DOMPurify.sanitize(data.email),
  message: DOMPurify.sanitize(data.message),
  hcaptcha: data.hcaptcha,
  address: data.address,
};
 
await schema.validate(sanitized, { stripUnknown: true });

stripUnknown: true drops any field that isn't in the schema, which is an extra safeguard against someone slipping in unexpected data.

Layer 6: Source Validation

The API route confirms the request actually came from the contact page by checking the referer header:

requestFromSource.ts
export const requestFromSource = async (req: NextRequest, source: string) => {
  const referer = req.headers.get('referer');
  if (!referer) throw errorResponse;
 
  const url = new URL(referer);
  if (!url.pathname.includes(source)) throw errorResponse;
};

The API Route: Bringing It All Together

Each security layer is its own composable middleware, so the route handler reads like a checklist:

route.ts
export async function POST(request: NextRequest) {
  const data = await request.json();
 
  try {
    await rateLimit(request); // Layer 4: throttle by IP
    await requestFromSource(request, ABOUT_LINK.href); // Layer 6: referer check
    await validateFormData(data, formSchema); // Layers 1, 2, 5: honeypot + sanitize + validate
    await sendEmail(data); // Deliver via Resend
 
    return NextResponse.json({ success: true, statusCode: 200 });
  } catch (e: unknown) {
    const error = e as ErrorResponse;
    const headers: HeadersInit = {};
    if (error.statusCode === 429 && error.retryAfter) {
      headers['Retry-After'] = String(error.retryAfter);
    }
    return NextResponse.json(error, { status: error.statusCode, headers });
  }
}

Each function throws a typed ErrorResponse on failure, so the catch block handles every error case the same way: 400 for validation, 403 for bots, 429 for rate limits, 500 for service failures.

The Results

Security LayerWhat It CatchesResponse
Client validationTypos, missing fields, invalid formatsInline errors (no request sent)
HoneypotNaive bots that fill every field403 Forbidden
hCaptchaAutomated submissions without human interaction400 (token required)
Rate limitingBrute-force / spam floods from a single IP429 + Retry-After header
DOMPurifyXSS payloads in form fieldsSanitized before processing
Source validationDirect API calls from outside the site403 Forbidden
Anti-URL regexSpam messages containing links400 (validation error)

Key Takeaways

  • Defense in depth works because each layer catches what the others miss. The honeypot stops the dumb bots, hCaptcha stops the smart ones, rate limiting stops the persistent ones, and sanitization stops the malicious ones.
  • Shared validation schemas kill drift. One Yup schema validates on both the client and the server, so the client gives instant feedback while the server actually enforces the rules.
  • Lazy-loading the security widgets keeps the page fast. hCaptcha loads via IntersectionObserver only once the form is visible, which keeps it off the critical rendering path.
  • Composable middleware keeps the route handler readable. Each security concern is a standalone async function that either passes or throws, so the route reads like a pipeline.

Technologies Used

Next.js App Router, React Hook Form, Yup, hCaptcha, DOMPurify, Resend, TypeScript, Sentry, Vercel Analytics