Public Drupal forms attract two different problems. The first is ordinary spam: bots posting links, sales pitches, fake leads, and repeated junk. The second is abusive content: threatening language, harassment, offensive terms, or messages that should never be routed directly to a support inbox.
CAPTCHA alone does not solve both problems. A better Drupal form security model uses layers: bot friction, rate limiting, content validation, moderation, logging, and clear operational rules. This article shows how to build that pattern for Drupal contact forms, with a reusable abusive-word validation system that can grow without turning into a fragile list of hard-coded words.

What We Are Protecting Against
For a Drupal contact form, the most common abuse patterns are:
- Automated bot submissions with links or repeated keywords.
- Credential phishing or fake vendor messages.
- Messages containing abusive or threatening language.
- High-volume repeated submissions from the same source.
- Attempts to bypass validation using punctuation, spacing, mixed case, or encoded characters.
- False positives where a legitimate user quotes an abusive term while reporting a problem.
The last point matters. If your validation is too aggressive, you block real users who may be trying to report harmful behavior. The system should separate “obvious block” from “needs review.”
Start With Proven Drupal Spam Layers
Before writing custom abusive-word logic, add basic anti-bot protection.
Honeypot
The Honeypot module uses hidden-field and timestamp techniques to catch many automated submissions without forcing every user through a visible challenge. The Drupal.org project page notes that it can be enabled globally or for specific forms, including contact forms, webforms, user forms, node forms, and comment forms.
composer require 'drupal/honeypot:^2.2'
drush en honeypot -y
drush cr
Configure it at:
/admin/config/content/honeypot
For custom forms, Honeypot can also be attached programmatically:
\Drupal::service('honeypot')->addFormProtection($form, $form_state, [
'honeypot',
'time_restriction',
]);
CAPTCHA Or Turnstile For Higher-Risk Forms
The CAPTCHA module adds challenge-response tests to user-facing forms. Drupal.org lists CAPTCHA as compatible with Drupal 9.5, 10, and 11 in its current stable branch. Use it carefully: CAPTCHA can reduce spam, but it also adds friction and accessibility considerations.
composer require 'drupal/captcha:^2.0'
drush en captcha -y
drush cr
Use visible challenges when a form is heavily abused, when the form creates accounts or sends email, or after quieter controls have failed. For a normal contact form, start with Honeypot and content validation before making every real visitor solve a challenge.
Why Build An Abusive-Word Validator?
Spam tools are good at catching bots. They are less precise when the problem is human-written abusive content. A contact form may need to reject or moderate messages based on:
- Blocked phrases maintained by your content or support team.
- Threat patterns or harassment indicators.
- Spammy URLs and repeated promotional phrases.
- Severity levels: block, queue, warn, or silently log.
- Form-specific rules. A sales lead form and a support abuse-reporting form should not behave the same way.
The goal is not to create a perfect language model. The goal is to block obvious garbage, route questionable messages safely, and make rule tuning manageable.

Architecture
Use three pieces:
- Configuration: stores blocked terms, phrases, severity, and per-form behavior.
- Validator service: normalizes submitted text, matches rules, and returns a structured result.
- Form integration: attaches validation to Drupal contact forms and decides whether to block or queue.
Do not put the word list directly inside hook_form_alter(). That makes it difficult to test, audit, translate, or update without code changes.
Step 1: Create A Custom Module
Create a small custom module, for example custom_form_protection.
# custom_form_protection.info.yml
name: Custom Form Protection
type: module
description: Adds abusive-content validation to selected Drupal forms.
core_version_requirement: ^10 || ^11
package: Custom
Add a service definition:
# custom_form_protection.services.yml
services:
custom_form_protection.abuse_validator:
class: Drupal\custom_form_protection\Service\AbuseWordValidator
arguments: ['@config.factory', '@logger.channel.custom_form_protection']
logger.channel.custom_form_protection:
parent: logger.channel_base
arguments: ['custom_form_protection']
Step 2: Store Rules In Configuration
Start with simple configuration. You can later move to a config entity if editors need a UI.
# custom_form_protection.settings.yml
blocked_terms:
- 'blocked-term-example'
- 'spam phrase example'
blocked_url_domains:
- 'example-spam-domain.test'
max_links: 2
mode: block
Use placeholder examples in documentation and tests. Avoid committing real slurs or personally harmful language into a public repository unless your governance process requires it and the repository access is controlled.
Step 3: Normalize Submitted Text
Normalization catches simple bypasses. For example, attackers may try uppercase, repeated spaces, HTML entities, punctuation between letters, or invisible characters.
private function normalize(string $text): string {
$text = Html::decodeEntities($text);
$text = strip_tags($text);
$text = mb_strtolower($text);
$text = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $text);
$text = preg_replace('/\s+/u', ' ', $text);
return trim($text);
}
Do not over-normalize blindly. If you remove every symbol, you can create false positives. Keep normalization predictable and covered by tests.
Step 4: Build The Validator Service
The validator should return a structured result instead of just TRUE or FALSE.
namespace Drupal\custom_form_protection\Service;
use Drupal\Component\Utility\Html;
use Drupal\Core\Config\ConfigFactoryInterface;
use Psr\Log\LoggerInterface;
final class AbuseWordValidator {
public function __construct(
private readonly ConfigFactoryInterface $configFactory,
private readonly LoggerInterface $logger,
) {}
public function check(string $text): array {
$config = $this->configFactory->get('custom_form_protection.settings');
$normalized = $this->normalize($text);
$matches = [];
foreach ($config->get('blocked_terms') ?? [] as $term) {
$term = $this->normalize($term);
if ($term !== '' && str_contains($normalized, $term)) {
$matches[] = $term;
}
}
$linkCount = preg_match_all('/https?:\/\/|www\./i', $text);
$maxLinks = (int) ($config->get('max_links') ?? 2);
return [
'blocked' => !empty($matches) || $linkCount > $maxLinks,
'matches' => $matches,
'link_count' => $linkCount,
];
}
private function normalize(string $text): string {
$text = Html::decodeEntities($text);
$text = strip_tags($text);
$text = mb_strtolower($text);
$text = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}]/u', '', $text);
$text = preg_replace('/\s+/u', ' ', $text);
return trim($text);
}
}
This is intentionally simple. In production, add severity levels, phrase matching, allowlists, tests, and better URL/domain parsing.
Step 5: Attach Validation To Drupal Contact Forms
Drupal contact forms usually have form IDs such as contact_message_feedback_form. If you want to cover all contact message forms, detect IDs that begin with contact_message_.

use Drupal\Core\Form\FormStateInterface;
function custom_form_protection_form_alter(
array &$form,
FormStateInterface $form_state,
string $form_id,
): void {
if (str_starts_with($form_id, 'contact_message_')) {
$form['#validate'][] = 'custom_form_protection_validate_contact_message';
}
}
Then validate the message field:
function custom_form_protection_validate_contact_message(
array &$form,
FormStateInterface $form_state,
): void {
$message = $form_state->getValue(['message', 0, 'value']) ?? '';
$subject = $form_state->getValue('subject') ?? '';
$combined = $subject . "\n" . $message;
$result = \Drupal::service('custom_form_protection.abuse_validator')
->check($combined);
if ($result['blocked']) {
$form_state->setErrorByName(
'message',
t('Your message could not be submitted. Please revise it and try again.')
);
}
}
Keep the user-facing error generic. Do not tell attackers which term matched.
Step 6: Decide Block, Queue, Or Log
Not every rule should block. A better pattern is:
| Risk | Example | Action |
|---|---|---|
| Low | One suspicious keyword but no links | Allow and log signal |
| Medium | Several spam terms or too many links | Accept but queue for moderation |
| High | Clear abusive phrase or known spam domain | Block with generic error |

For core contact forms, “queue for moderation” may mean sending the email to a moderation mailbox instead of the normal recipient, or storing the message in a custom entity for review. For Webform-based contact forms, you can often use Webform handlers and submission states to build a richer moderation workflow.
Step 7: Log Safely
Logging is useful for tuning, but it can also store harmful content. Avoid dumping full abusive messages into logs. Prefer structured metadata:
$this->logger->warning('Blocked contact form submission. Matches: @count, links: @links', [
'@count' => count($result['matches']),
'@links' => $result['link_count'],
]);
If your organization needs the original message for moderation, store it in a controlled queue with retention rules and appropriate permissions. Do not scatter harmful content across web server logs, watchdog logs, email alerts, and third-party monitoring tools unless you have a clear privacy and safety policy.
Step 8: Add Rate Limiting
Content validation catches bad messages. Rate limiting catches repeated attempts. Depending on your hosting stack, this can happen at the CDN, WAF, reverse proxy, or Drupal layer.
For Drupal-side protection, consider:
- Limiting submissions per IP and per email address.
- Increasing friction after repeated failures.
- Blocking disposable domains if your business allows it.
- Adding form-specific thresholds for high-risk forms.
If Drupal is behind a CDN, make sure Drupal is seeing the real client IP before relying on IP-based limits. Otherwise, all visitors may appear to come from the CDN or load balancer.
Step 9: Write Tests
At minimum, test the validator service with cases like:
- Exact blocked term.
- Mixed case blocked term.
- HTML entity encoding.
- Extra spaces and line breaks.
- Legitimate message with no matches.
- Message with too many links.
- False-positive phrase that should be allowed.
Example kernel or unit-style expectations:
$this->assertTrue($validator->check('blocked-term-example')['blocked']);
$this->assertTrue($validator->check('BLOCKED-TERM-EXAMPLE')['blocked']);
$this->assertFalse($validator->check('I need help with my account')['blocked']);
Common Mistakes
Hard-coding the word list in a form callback
This makes updates slow and risky. Put rules in configuration or a moderated admin UI.
Showing the blocked word in the error message
This helps attackers tune their payloads and can expose users to harmful language. Keep messages generic.
Blocking every match
Sometimes users quote abusive text because they are reporting abuse. Consider queueing instead of blocking on forms intended for safety, support, or moderation reports.
Ignoring Unicode and encoding
Attackers can use invisible characters, mixed case, or HTML entities to bypass naive matching. Normalize before matching.
Only protecting one form
Spam moves. Protect contact forms, user registration, comment forms, webforms, and any custom public form that sends email or creates content.
Production Checklist
- Install Honeypot for quiet bot protection.
- Add CAPTCHA, Turnstile, or similar challenges only where the friction is justified.
- Use a custom validator service for abusive content rules.
- Store rules in configuration or an admin-managed entity.
- Normalize text before matching.
- Return generic errors to users.
- Log metadata, not full harmful content, unless retention is intentional.
- Add tests for bypasses and false positives.
- Review the word list regularly with support, legal, moderation, or trust-and-safety stakeholders.
Reference Links
Final Thoughts
Securing Drupal forms is not only about stopping bots. It is also about protecting the people who receive, moderate, and respond to submissions. Honeypot and CAPTCHA reduce automated noise; an abusive-word validation service gives your team a maintainable way to handle content that should be blocked or reviewed before it reaches an inbox.
Keep the system simple at first: normalize, match, decide, log, and test. Then improve it with severity rules, moderation queues, and form-specific policy as real abuse patterns appear.