Form spam is annoying. Clogging your inbox with messages is one thing, but when fraudulent users are signing up and abusing your service that’s when it also starts to cost money. Whether it’s taking advantage of a free compute quota, consuming your AI credits, or running stolen credit cards through your payment processor, protecting forms can suddenly become really important.
Protecting a form usually involves a few steps - setting up form validation, applying a sensible rate limit, implementing bot detection, and properly validating email addresses. This post will guide you through implementing each of these for the React Hook Form framework.
We’ll use Next.js + the shadcn/ui component library as an example because it’s really easy to get started. The form component has features like accessibility and keyboard shortcuts, however these steps apply to any other framework as well.
Form Validation with Zod
Validating each submitted field means attackers need to spend more time creating realistic values. Nothing will provide 100% protection, but every layer of protection we implement is all about making it a bit more annoying for spammers to abuse your form. Adding form validation is straightforward and has UX benefits as well as making it a little more difficult to spam - that’s why adding it is the first step.
In this example we’ll use Zod which is a popular TypeScript validation library that integrates directly with React Hook Form. You define a schema for the form and then Zod validates the submission, returning structured errors so they can be displayed nicely for each field.
For our example form we will have a single email field. This might be used to sign up for a newsletter or could be part of a larger form. You can easily add more fields, but this keeps it simple in our examples.
Zod has email as a built-in string type validation which means you don’t need to think about the complexities of how valid emails should look. The message is a custom error message if the validation fails.
We can set up the form with the Zod schema and resolver:
This connects to the single field we have in our form named email which matches the field name in the Zod schema.
React Hook Form includes error handling so you can show errors specific to each field. We only have a single field, but in long forms this helps improve the UX by highlighting fields the user needs to fix. In our example, we are using Next.js + the shadcn/ui component library. This includes a <FormMessage /> component that handles displaying an error message.
This is the component that returns our validated form. The <Input> component sets the field type to email which also enables browser-side validation. This doesn’t stop attackers because it’s easy to bypass, but it helps improve the UX for real users.
When the form is submitted we want to create a POST request to an API route. We should handle any errors from that request so the complete form component would look like this:
Rate limiting form submissions
Most legitimate users will submit a form once and that’s it. If there’s an error then maybe you might re-submit a couple of times before getting it right, but submitting a form more than a few times from the same IP address within a short time window indicates something nefarious is happening.
We can copy the example in the Next.js project to implement an in-memory rate limit for our form submission API endpoint. This uses the client IP address to set a maximum of 5 requests per 10 minutes, which seems like a reasonable limit for legitimate requests.
We use the @arcjet/ip package to ensure we have a valid IP address because IP headers can be easily spoofed. Where possible it extracts the IP from the headers set on common hosting platforms like Cloudflare, or Vercel before falling back on the remote address headers.
The main disadvantage of using this in-memory rate limit is that if the server is restarted then all the rate limits will be reset. The rate limit is defined outside of the handler so it can persist across requests. However, if the environment is recycled and a new “cold start” is triggered, the rate limit also starts from scratch. In serverless environments this can happen regularly (even after every request).
One way to solve this problem is with an external datastore like Redis. The popular @upstash/ratelimit package uses Redis as its backing storage, which obviously means you need to deal with running Redis somewhere. This can be a hassle if you need to distribute the nodes around the world for better response times. At Arcjet we’re building a set of security tools for developers exposed through a native SDK. Our rate limiting functionality deals with this so you don’t need to worry about persistence or manage Redis.
Bot detection on form submission
Attackers will automate their submission of your form because they’re probably executing attacks against lots of other forms. They might also want to try lots of fake accounts to see what works. This means you should protect your form from automated bot submissions.
Bot protection is difficult. There are lots of products available which use various techniques to detect automated clients. There are even more ways around these detections! Nothing works perfectly.
One approach is to analyze the user agent to block known bots. There are several libraries available to help with this, but the isbot package is regularly updated with new user agents so is the one I’d recommend.
This can easily be added to our route handler:
The obvious disadvantage is that the client can easily spoof the user agent. For more robust detection, various other signals must be considered as part of the request. This can include past behavior and IP reputation details, variables that we consider as part of the Arcjet bot detection functionality. However, security is all about layers so just checking the user agent is a good first step.
Form email validation
The final step is to check whether the email address is valid. There are 10 different RFCs that define the email address format so it’s important not to be too narrow in your validation rules. Zod handles this for us (above), but we need to be sure that it is run on both the client and server because anything running only on the client can be bypassed.
If we move our schema to a separate file then we can include it in both the client and server code. In this example we’ve moved the Zod schema to /lib/form-schema.ts
Form email verification
Checking the string syntax tells us whether the email is valid or not, but that doesn’t mean it can receive email or whether it's an email we want to accept. What if it’s from a disposable email service? Are there MX records set so it can actually receive messages?
These are all additional checks which feed into whether you want to automatically allow a signup, block it completely, or perhaps just flag it for human review. There aren’t any good libraries for this in TypeScript which is why we built it into the Arcjet product. You can use the Arcjet email validation just by itself, but we also package everything together into a signup form protection rule. This combines rate limiting, bot protection, email validation and email verification.
Using Arcjet, the API endpoint looks like this. You can also find the fully implemented example project on GitHub.
Conclusion
Preventing spam and abuse on forms is crucial for maintaining the integrity of websites and applications. Implementing form validation, rate limiting, bot detection, and email address verification enhances the user experience while strengthening security. React Hook Form simplifies form building, especially when combined with UI component libraries such as shadcn/ui.
Although this post demonstrates how to create these protections, Arcjet offers a comprehensive solution that accelerates the process and provides ongoing protection against evolving threats.
Detect, block, and redact PII locally without sending it to the cloud using Arcjet's sensitive information detection, plus the new integration with LangChain.