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 — 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:
<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:
{
/* 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>;// 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:
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:
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:
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:
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:
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 Layer | What It Catches | Response |
|---|---|---|
| Client validation | Typos, missing fields, invalid formats | Inline errors (no request sent) |
| Honeypot | Naive bots that fill every field | 403 Forbidden |
| hCaptcha | Automated submissions without human interaction | 400 (token required) |
| Rate limiting | Brute-force / spam floods from a single IP | 429 + Retry-After header |
| DOMPurify | XSS payloads in form fields | Sanitized before processing |
| Source validation | Direct API calls from outside the site | 403 Forbidden |
| Anti-URL regex | Spam messages containing links | 400 (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
