Aurora CMS: Practical Defense-in-Depth Form Protection with Next.js and Supabase
How Aurora CMS eliminated form spam without reCAPTCHA using a five-stage defense pipeline built from server-side token generation, honeypots, origin validation, memory-safe rate limiting, and audit logging in Next.js and Supabase. Zero third-party dependencies, zero false positives after tuning.
Impact Result
Spam dropped to near-zero within days. Zero legitimate submissions blocked after threshold tuning. Submission latency held under 300ms with no CAPTCHA, no vendor costs, and no JavaScript requirement.
Contact forms are a quiet liability on most creative agency websites. Aurora, a portfolio CMS for a visual manufacturing studio, needed robust spam protection without the privacy baggage, accessibility penalties, and vendor lock-in that come with Google reCAPTCHA and its alternatives.
The solution was a five-stage, defense-in-depth form submission pipeline built entirely from native web primitives, cryptographic tokens, and PostgreSQL. No third-party CAPTCHA. No per-request costs. No JavaScript requirement.
This case study details the architecture, the hardening decisions that separate it from a naive implementation, and the trade-offs involved.
The Challenge
Contact forms on creative agency websites are frequent targets for automated abuse:
Simple bots that blindly fill every field
Headless browser scripts submitting at scale
Rate abuse from rotating IPs or impatient legitimate users
CSRF-style submissions from unauthorized origins
Traditional solutions like reCAPTCHA introduce privacy concerns, accessibility issues, extra JavaScript payload, and ongoing vendor costs. For Aurora, the goal was to create a self-contained system that stops the majority of spam effectively, works even if JavaScript is disabled, provides full audit visibility for admins, and adds minimal friction for high-value project inquiries.
The Architecture: Five-Stage Defense Pipeline
The protection runs server-side using Next.js Route Handlers and Supabase for persistence and logging.
Stage 1: Visitor Attribution (Middleware)
export async function middleware(request: NextRequest) {
const response = NextResponse.next();
if (!request.cookies.has("aurora_visitor_id")) {
const visitorId = crypto.randomUUID();
response.cookies.set("aurora_visitor_id", visitorId, {
path: "/",
maxAge: 60 * 60 * 24 * 365,
sameSite: "lax",
httpOnly: false,
secure: process.env.NODE_ENV === "production",
});
}
return response;
}This assigns a persistent, pseudonymized visitor ID for correlation and analytics without collecting personal data.
Stage 2: Server-Side Token Generation
Tokens are generated during Server Component render (no public /api/token endpoint):
// In contact/page.tsx (Server Component)
const csrfToken = await generateCsrfToken();
export async function generateCsrfToken(): Promise<string> {
const supabase = await createClient();
const token = crypto.randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + 5 * 60 * 1000); // 5 minutes
await supabase.from("form_tokens").insert({
token,
expires_at: expiresAt.toISOString(),
used: false,
});
return token;
}This approach binds token creation to legitimate page renders.
Token Issuance Comparison:
Traditional (Vulnerable) Aurora (Hardened)
| |
| ──GET /api/token────> | Server Component Render
| <────token─────────── | (token generated server-side)
| |
| ──POST /form (w/ token) | ──POST /form (w/ token from HTML)
| |
[Attacker can hammer /token endlessly] [No /token endpoint exists]By eliminating the token endpoint, Aurora removes an entire class of resource exhaustion attacks. Token generation is now bound to legitimate page renders, which are inherently more expensive and harder to abuse at scale.
Stage 3: Sequential Validation Pipeline
The submission handler (/api/actions/form/route.ts) applies checks in order:
Honeypot Check
if (website && website.trim() !== '') {
await logSubmission({ blocked: true, blockReason: 'honeypot', ... });
return NextResponse.json({ message: 'Inquiry sent successfully!' }, { status: 200 });
}A hidden website field (styled with Tailwind display: none) catches many simple bots. Fake success response wastes attacker time.
Origin & Referer Validation
Strict allow-list check on Origin or Referer headers to prevent CSRF and cross-site submissions.
Token Validation
Token must exist, not be used, and not expired
Marked
used: trueatomically on success
Rate Limiting (Memory-Safe)
const tenMinutesAgo = new Date(Date.now() - 10 * 60 * 1000);
const { count } = await supabase
.from("form_submissions")
.select("*", { count: "exact", head: true })
.eq("ip_address", ip)
.gte("submitted_at", tenMinutesAgo.toISOString());
if (count >= 5) {
await logSubmission({ blocked: true, blockReason: "rate_limit" });
return NextResponse.json({ error: "Too many requests" }, { status: 429 });
}Under a volumetric attack of 10,000 requests in 10 minutes:
Naive approach (
.select('*')): Each query could return thousands of rows with ~1KB JSONform_dataeach → up to 50 GB of memory pressure across requests, easily causing out-of-memory crashes.Optimized approach (count-only query): Returns a single integer → roughly 160 KB total memory across all requests.
The database handles aggregation efficiently, keeping application memory usage near-constant.
IP Extraction & Trust Boundary
IP extraction trusts forwarded headers only because Aurora runs behind Vercel’s Edge Network:
┌─────────────────────────────────────────────────────────────┐
│ INTERNET │
│ Attacker ──> [X-Forwarded-For: forged IP] │
└──────────────────┬──────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ VERCEL EDGE NETWORK (Trusted Proxy) │
│ • Strips all client-supplied X-Forwarded-For │
│ • Observes actual TCP source IP │
│ • Sets X-Forwarded-For: <real client IP> │
└──────────────────┬──────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ AURORA APPLICATION │
│ • Safely trusts X-Forwarded-For (sanitized by edge) │
│ • Extracts first IP from chain │
│ • Applies rate limiting │
└─────────────────────────────────────────────────────────────┘Self-hosted deployments must replicate this trust boundary using tools like Nginx set_real_ip_from, Traefik trusted IPs, or Cloudflare CF-Connecting-IP.
Stage 4: Comprehensive Audit Logging
Every attempt — blocked or successful — is logged with full context:
async function logSubmission(data: LogData) {
await supabase.from("form_submissions").insert({
ip_address: data.ip,
user_agent: data.userAgent,
success: data.success,
blocked: data.blocked,
block_reason: data.blockReason,
form_data: data.formData ? sanitizeFormData(data.formData) : null,
visitor_id: data.visitorId,
});
}The form_submissions table serves as both a security log and a debugging tool. In practice, the audit trail revealed patterns that shaped threshold tuning: the majority of blocked submissions hit the honeypot stage, a smaller share failed token validation (typically headless browsers that rendered the page but submitted after token expiry), and rate limiting caught the remainder. False positives - legitimate users blocked by rate limiting - were identified by cross-referencing visitor IDs against success: false entries and adjusting the 10-minute window accordingly.
Without this log, threshold tuning would be guesswork.
Key Database Schema
CREATE TABLE form_tokens (
id SERIAL PRIMARY KEY,
token TEXT UNIQUE NOT NULL,
used BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE form_submissions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ip_address TEXT,
user_agent TEXT,
success BOOLEAN DEFAULT FALSE,
blocked BOOLEAN DEFAULT FALSE,
block_reason TEXT,
form_data JSONB,
visitor_id UUID REFERENCES visitors(id) ON DELETE SET NULL,
submitted_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_form_submissions_ip_date
ON form_submissions(ip_address, submitted_at);Observed Results
Stage | Block Mechanism | Observed Contribution |
|---|---|---|
Honeypot | Hidden field detection | Majority of automated blocks |
Token validation | Expired / missing token | Headless browsers, scrapers |
Rate limiting | IP-based 10-min window | Repeat submitters, burst bots |
Origin validation | Allow-list header check | Cross-site and CSRF attempts |
Legitimate users | — | Zero false positives observed after threshold tuning |
Key outcomes:
Spam submissions dropped to near-zero within days of deployment
No legitimate submissions blocked after the rate limit window was calibrated
Submission latency for real users remained under 300ms end-to-end
Zero third-party dependencies, zero per-request CAPTCHA costs
Full audit visibility enabled retroactive attack pattern analysis
Architectural Trade-offs
Honeypot limitations: Simple hidden fields catch many basic bots but can be bypassed by advanced headless browsers...
Token generation cost: Generating a token on every contact page render adds a small database write...
Rate limiting scope: IP-based limiting works well behind a trusted proxy but can be less effective against distributed attacks...
Maintenance overhead: The audit table grows over time and requires periodic archiving or retention policies...
Threat model calibration: This system is tuned for typical agency spam, not nation-state actors or highly determined attackers...
For Aurora's actual traffic and threat profile, the system has proven reliable and low-maintenance.
Conclusion
Aurora's form protection demonstrates that strong spam defense doesn't require heavy third-party services or degraded user experience. By carefully composing middleware, server-side token generation, honeypots, origin checks, memory-safe rate limiting, and audit logging, the team created a practical, auditable security boundary using only standard tools.
The three hardening decisions that matter most: eliminate the token endpoint entirely, use count-only queries under rate limiting load, and define your IP trust boundary explicitly before you need it. Everything else is implementation detail.
Most B2B creative sites don't need reCAPTCHA. They need a well-understood threat model and a layered system that's honest about what it stops. This is that system.
Interested in similar results?
Let's talk about your project