Spam protection
Honeypot and rate limiting on by default, bundled captcha adapters, upload ownership, privacy-first metadata.
A honeypot decoy and per-identity rate limiting protect the public submission and upload paths out of the box. Everything is opt-out, per control or wholesale with spam: false.
formBuilder({
spam: {
// honeypot: false, // disable the decoy
// honeypot: { fieldName: 'nickname' },
// rateLimit: { window: 60_000, max: 5 }, // defaults shown
// uploadRateLimit: { window: 60_000, max: 20 },
// captcha: turnstileProvider({ secretKey }), // bundled or custom adapter
// identify: (req) => (req.user ? `user:${req.user.id}` : null),
// ipHeader: 'cf-connecting-ip', // trusted client-IP header
// metadata: { ip: true, ua: true }, // opt in to storing IP/UA
},
})Scope: defense in depth, not DoS protection
App-level limiting complements edge/CDN/WAF limiting; it does not replace it. It cheaply turns away casual abuse and bots; a determined flood belongs to your edge. The default limiter is a window counter over payload.kv (durable, cross-instance). kv has no atomic increment, so the cap is soft under concurrent bursts; swap in a Redis-backed RateLimiter via rateLimit.limiter for a hard limit.
Identity
Payload v3 has no req.ip. Identity resolves to the authenticated user id, else the first hop of a trusted IP header (default x-forwarded-for). This is proxy-dependent: set ipHeader to whatever your proxy sets, or supply your own identify(req) (a signed cookie, a session id). When no identity resolves, rate limiting and upload ownership fail open rather than blocking legitimate traffic, so configure your proxy for these controls to bite.
Honeypot
<Form> renders a visually hidden decoy input: off-screen, aria-hidden, out of the tab order, and marked autoComplete="new-password" rather than "off", because Chrome routinely ignores "off" and an autofilled honeypot would flag a real visitor as a bot. Humans never see it; bots that fill every field do, and the server rejects with a generic error. The decoy name defaults to website (the exported DEFAULT_HONEYPOT_FIELD), chosen to look like a real field to bots while avoiding the name patterns browsers autofill for humans, such as anything containing email. If you change spam.honeypot.fieldName on the server, pass the same name to <Form honeypot={{ name }}>, and keep it from colliding with a real field.
Captcha
Bundled adapters cover Cloudflare Turnstile, Google reCAPTCHA (v2 and v3), and hCaptcha. Each pairs a server verify adapter (exported from the package root) with a headless widget component (exported from /react) that reports tokens for <Form captchaToken>. With a provider configured, an anonymous submission without a valid token is rejected.
All adapters share the same semantics:
- The token is POSTed form-encoded to the provider's siteverify endpoint, with
remoteiptaken from the trusted IP header (defaultx-forwarded-for, override per adapter viaipHeader). - Verification fails closed: a network error, a timeout (5s default,
timeoutMs), or a non-2xx response rejects the submission rather than letting it through. - Widget components are unstyled beyond the vendor widget itself: a bare div the vendor renders into. Each calls
onToken(token)on solve andonToken(null)on expiry or error, so the freshest token always reaches<Form>.
Turnstile
import { turnstileProvider } from '@10x-media/form-builder'
formBuilder({
spam: { captcha: turnstileProvider({ secretKey: process.env.TURNSTILE_SECRET_KEY! }) },
})'use client'
import { useState } from 'react'
import { Form, TurnstileCaptcha } from '@10x-media/form-builder/react'
export const ContactForm = ({ form }) => {
const [token, setToken] = useState<string | null>(null)
return (
<Form form={form} captchaToken={token ?? undefined}>
<TurnstileCaptcha siteKey={process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY!} onToken={setToken} />
</Form>
)
}Extra render params (theme, size, appearance) pass through options. A ref exposes reset().
reCAPTCHA
import { recaptchaProvider } from '@10x-media/form-builder'
formBuilder({
spam: {
captcha: recaptchaProvider({
secretKey: process.env.RECAPTCHA_SECRET_KEY!,
minScore: 0.5, // v3 threshold; v2 responses have no score and pass on success alone
}),
},
})One server adapter handles both versions; the client component picks the flow:
// v2: checkbox widget, token arrives when the user solves it
<RecaptchaCaptcha siteKey={siteKey} version="v2" onToken={setToken} />
// v3: invisible, fetches an initial token on mount
<RecaptchaCaptcha ref={captchaRef} siteKey={siteKey} version="v3" action="contact" onToken={setToken} />v3 tokens expire after about two minutes. For a form the user may sit on, refresh right before submit via the ref: await captchaRef.current?.execute() resolves a fresh token and also reports it through onToken.
hCaptcha
import { hcaptchaProvider } from '@10x-media/form-builder'
formBuilder({
spam: { captcha: hcaptchaProvider({ secretKey: process.env.HCAPTCHA_SECRET_KEY! }) },
})<HcaptchaCaptcha siteKey={process.env.NEXT_PUBLIC_HCAPTCHA_SITE_KEY!} onToken={setToken} />Custom providers
Any other verification service plugs into the same seam with defineCaptchaProvider:
import { defineCaptchaProvider } from '@10x-media/form-builder'
const myCaptcha = defineCaptchaProvider({
type: 'my-captcha',
verify: async ({ token, req }) => {
// return true to accept; return false (do not throw) to reject
return await myVerifyCall(token)
},
})
formBuilder({ spam: { captcha: myCaptcha } })verify receives the raw token and the PayloadRequest; returning false rejects the submission with a generic captcha error. Thrown errors are also treated as a failed verification.
Upload ownership
Uploads to the configured uploads collection are stamped with the uploader's identity. At submit, a file reference counts only if the submitting identity matches the upload's owner, so an anonymous submitter cannot reference someone else's upload; a mismatch is treated as a missing file. Where no identity resolves, scoping fails open (it could not apply fairly anyway). Granularity is identity-level: NAT-sharing clients share scope, and an IP that rotates between upload and submit will not match itself. A per-session token is a planned option.
Capture metadata (privacy)
The submission meta always records a timestamp and a spam signal. The client IP and user agent are not stored unless you opt in via metadata.ip / metadata.ua, a deliberate GDPR default.
Options
| Option | Default | Description |
|---|---|---|
honeypot | on, field website | false, or { fieldName }. |
rateLimit | on, 5 per 60s | false, or { window, max, limiter } per identity. |
uploadRateLimit | on, 20 per 60s | Same shape, for the upload path. |
captcha | none | turnstileProvider/recaptchaProvider/hcaptchaProvider or a defineCaptchaProvider adapter. |
identify | user id, else IP header | Identity resolver for limiting and ownership. |
ipHeader | x-forwarded-for | Trusted client-IP header. |
metadata | off | { ip?, ua? } opt-in storage on the submission. |