10x-media plugins
Form Builder

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.

payload.config.ts
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 remoteip taken from the trusted IP header (default x-forwarded-for, override per adapter via ipHeader).
  • 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 and onToken(null) on expiry or error, so the freshest token always reaches <Form>.

Turnstile

payload.config.ts
import { turnstileProvider } from '@10x-media/form-builder'

formBuilder({
  spam: { captcha: turnstileProvider({ secretKey: process.env.TURNSTILE_SECRET_KEY! }) },
})
ContactForm.tsx
'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

payload.config.ts
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

payload.config.ts
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

OptionDefaultDescription
honeypoton, field websitefalse, or { fieldName }.
rateLimiton, 5 per 60sfalse, or { window, max, limiter } per identity.
uploadRateLimiton, 20 per 60sSame shape, for the upload path.
captchanoneturnstileProvider/recaptchaProvider/hcaptchaProvider or a defineCaptchaProvider adapter.
identifyuser id, else IP headerIdentity resolver for limiting and ownership.
ipHeaderx-forwarded-forTrusted client-IP header.
metadataoff{ ip?, ua? } opt-in storage on the submission.

On this page