How to Secure Drupal Forms Against Spam and Abusive Content

Drupal

How to Secure Drupal Forms Against Spam and Abusive Content

Build a layered Drupal form protection strategy with Honeypot, CAPTCHA, rate limiting, and a custom abusive-word validation service for contact forms.

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.

Layered Drupal form protection architecture for spam and abusive content
Protect public forms with layered controls. Each layer catches a different class of abuse.

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.

Pipeline for normalizing and validating abusive content in Drupal forms
Normalize first, match second, decide last. This is what makes the validator harder to bypass.

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_.

Drupal form alter validation code for a contact form abusive word validator
The form callback should connect Drupal's Form API to a reusable validator service.
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:

RiskExampleAction
LowOne suspicious keyword but no linksAllow and log signal
MediumSeveral spam terms or too many linksAccept but queue for moderation
HighClear abusive phrase or known spam domainBlock with generic error
Moderation flow for abusive content matches in Drupal forms
Use different outcomes for different risk levels instead of treating every match the same.

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.

Keep reading

Drupal Sep 7, 2026 6 min read

Build a Drupal Chatbot with Local AI

Learn how a Drupal chatbot finds relevant published articles, uses Ollama to generate answers, and displays source links while keeping inference on your own hardware.