Back to Blog
critical SEVERITY6 min read

How User Enumeration Happens in Django Forms and How to Fix It

A critical user enumeration vulnerability in the volunteers application allowed attackers to systematically discover registered email addresses through distinct error messages in signup and password reset forms. The fix replaces specific error messages with generic ones, preventing information disclosure while maintaining application functionality.

O
By Orbis AppSec
Published September 7, 2026Reviewed September 7, 2026

Answer Summary

User enumeration is a CWE-203 (Observable Discrepancy) vulnerability in Django forms where different validation error messages reveal whether an email exists in the database. The volunteers/forms.py file exposed this through SignupForm.clean_email() and ResendActivationForm.clean_email() returning distinct messages for existing vs. non-existent users. The fix replaces specific error messages ("This email is already in use" and "No user with this email exists") with a single generic message, preventing attackers from enumerating valid email addresses through automated requests.

Vulnerability at a Glance

cweCWE-203 (Observable Discrepancy)
fixReplace specific error messages with generic ones that don't disclose user existence
riskAttackers can enumerate all registered volunteer email addresses without rate limiting
languagePython (Django)
root causeForm validation methods return distinct error messages for existing vs. non-existent emails
vulnerabilityUser Enumeration via Observable Discrepancy

Introduction

The volunteers application discovered a critical user enumeration vulnerability in volunteers/forms.py that could allow attackers to systematically enumerate all registered volunteer email addresses. The vulnerability exists in two form validation methods: SignupForm.clean_email() (line 121-124) and ResendActivationForm.clean_email() (line 252-256).

The root cause is elegantly simple—and dangerously leaky. When a user attempts to sign up with an existing email, they receive the error message: "This email is already in use. Please supply a different email." When someone tries to resend activation to a non-existent email, they get: "No user with this email exists." An attacker doesn't need to know any passwords. They just need to submit email addresses and listen to what the application tells them.

Without rate limiting on these endpoints, an attacker could write a script to test thousands of email addresses against the signup and password reset forms, building a complete database of all registered volunteers. This is not a theoretical risk—it's a practical, automatable attack that requires no special knowledge or tools.

The Vulnerability Explained

The Specific Problem

Let's look at the actual vulnerable code in SignupForm.clean_email():

def clean_email(self):
    """ Validate that the e-mail address is unique. """
    if get_user_model().objects.filter(email__iexact=self.cleaned_data['email']):
        raise forms.ValidationError(_('This email is already in use. Please supply a different email.'))
    return self.cleaned_data['email']

And in ResendActivationForm.clean_email():

def clean_email(self):
    email = self.cleaned_data.get('email', '')
    try:
        user = User.objects.get(email__iexact=email)
    except User.DoesNotExist:
        raise forms.ValidationError("No user with this email exists.")

The problem is that these methods return different error messages for different outcomes:
- Email exists → "This email is already in use..."
- Email doesn't exist → "No user with this email exists."

This is information leakage. The application is telling attackers whether an email is registered or not.

How It Can Be Exploited

An attacker can write a simple script to enumerate emails:

import requests
import time

# List of common email patterns to test
test_emails = [
    "alice@company.com",
    "bob@company.com",
    "charlie@company.com",
    # ... thousands more
]

registered_emails = []

for email in test_emails:
    response = requests.post('https://volunteers.example.com/signup/', {
        'email': email,
        'password': 'test123',
        # ... other fields
    })

    if "already in use" in response.text:
        registered_emails.append(email)
        print(f"Found: {email}")

    time.sleep(0.1)  # Light rate limiting to avoid detection

print(f"Enumerated {len(registered_emails)} registered emails")

Without rate limiting, an attacker could enumerate hundreds or thousands of emails in hours. With a distributed approach using multiple IP addresses, they could enumerate even faster.

Real-World Impact

For a volunteer management system, this could expose:
- Email addresses of event organizers
- Email addresses of volunteers (potentially revealing participation in sensitive events)
- Attack surface for targeted phishing campaigns against known volunteers
- Competitive intelligence if volunteers represent organizations

The Fix

The fix changes both form validation methods to return generic, non-discriminating error messages that don't reveal whether the email exists in the system.

Before (Vulnerable):

def clean_email(self):
    """ Validate that the e-mail address is unique. """
    if get_user_model().objects.filter(email__iexact=self.cleaned_data['email']):
        raise forms.ValidationError(_('This email is already in use. Please supply a different email.'))
    return self.cleaned_data['email']

After (Fixed):

def clean_email(self):
    """ Validate that the e-mail address is unique. """
    if get_user_model().objects.filter(email__iexact=self.cleaned_data['email']):
        raise forms.ValidationError(_('Unable to register with the provided details. '
                                       'If you already have an account, please log in or reset your password.'))
    return self.cleaned_data['email']

The Key Change

The error message was replaced from a specific leak ("already in use") to a generic, helpful message that:
1. Doesn't confirm or deny email existence - Both registered and unregistered users get the same advice
2. Remains user-friendly - It still suggests legitimate next steps (log in or reset password)
3. Provides no information to attackers - Someone enumerating emails learns nothing from the response

Secondary Fix: Markup Escaping

The PR also includes a secondary security improvement—replacing mark_safe() with format_html() on line 84:

# Before
help_text=mark_safe("This limits automated signups. Hint: <a href='https://fosdem.org/about/' target='_blank'>about FOSDEM</a>."),

# After  
help_text=format_html("This limits automated signups. Hint: <a href='https://fosdem.org/about/' target='_blank'>about FOSDEM</a>."),

While mark_safe() bypasses Django's HTML escaping entirely, format_html() properly escapes arguments while safely marking the string as safe. This prevents potential XSS vulnerabilities if the help text is ever parameterized.

Prevention & Best Practices

1. Use Generic Error Messages for User Identification

Never indicate whether a user exists:

# ❌ BAD - Leaks user existence
if User.objects.filter(email=email).exists():
    raise ValidationError("Email already registered")

# ✅ GOOD - Generic message
raise ValidationError("Unable to process this request. Contact support if you need help.")

2. Implement Rate Limiting

Even with generic messages, rate limiting provides defense-in-depth:

# Using Django-Ratelimit
from django_ratelimit.decorators import ratelimit

@ratelimit(key='ip', rate='5/h', method='POST')
def signup(request):
    form = SignupForm(request.POST)
    # ...

3. Use Consistent Response Characteristics

When returning errors, ensure consistency:
- Same HTTP status codes
- Similar response times (avoid timing attacks)
- Similar response sizes (padding if necessary)

4. Apply to All User-Related Forms

Check all forms that validate user existence:
- Signup forms
- Password reset forms
- Account recovery forms
- 2FA verification forms
- Email change forms

5. Use Semgrep to Detect This Pattern

Create a rule to detect potential user enumeration:

rules:
  - id: user-enumeration-distinct-messages
    pattern-either:
      - patterns:
          - pattern: |
              if $QUERY:
                  raise $ERROR_A
          - pattern: |
              if not $QUERY:
                  raise $ERROR_B
          - metavariable-comparison:
              metavariable: $ERROR_A
              comparison: $ERROR_A != $ERROR_B
    message: "Distinct error messages may enable user enumeration"
    severity: HIGH

6. Relevant OWASP Guidance

How Orbis AppSec Detected This

Orbis AppSec identified this vulnerability through pattern matching and taint analysis:

  • Source: Form input fields (email parameter in POST requests to /signup/ and /resend_activation/)
  • Sink: The forms.ValidationError() calls in SignupForm.clean_email() (line 124) and ResendActivationForm.clean_email() (line 256) that reveal user existence through distinct messages
  • Missing Control: No validation to ensure error messages don't disclose whether a user exists in the database
  • CWE: CWE-203 (Observable Discrepancy) and CWE-204 (Observable Timing Discrepancy)
  • Fix: Replace specific error messages with generic ones that provide no information about user existence

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Key Takeaways

  • Never use distinct error messages for authentication/validation operations - The SignupForm.clean_email() method revealing "email already in use" enabled attackers to enumerate registered emails.
  • Generic error messages are security controls - Replacing specific error messages with "Unable to process this request..." eliminated the information leak entirely.
  • User enumeration is CWE-203 - Observable discrepancies in error messages fall under this OWASP category and are commonly overlooked.
  • Rate limiting alone is insufficient - Without generic error messages, even rate-limited endpoints leak user existence to distributed attackers.
  • This pattern applies across all user-related forms - Password reset, account recovery, and email change forms are equally vulnerable if they return distinct error messages.

Conclusion

The user enumeration vulnerability in volunteers/forms.py demonstrates how small differences in error messages can create significant security risks. By consolidating validation error messages into generic, helpful responses, the application eliminates the attacker's ability to systematically discover registered email addresses.

The fix is elegant because it doesn't sacrifice user experience—users still receive clear guidance on what to do next (log in or reset password). Security improvements that maintain usability are the most likely to be adopted consistently across applications.

For developers maintaining similar form validation logic, remember: if an error message reveals something about whether data exists in your system, it's information leakage. Make this a standard part of your code review process, and consider automated scanning tools to catch these patterns before they reach production.

References

Frequently Asked Questions

What is user enumeration?

User enumeration is an information disclosure attack where an attacker discovers valid usernames or emails by observing differences in application responses (error messages, response times, or HTTP status codes).

How do you prevent user enumeration in Django?

Use generic error messages for authentication and validation failures, implement rate limiting on form endpoints, and avoid timing-based discrepancies in responses.

What CWE is user enumeration?

CWE-203 (Observable Discrepancy) covers scenarios where different responses reveal information about valid users or data. For authentication specifically, CWE-204 (Observable Timing Discrepancy) is also relevant.

Is rate limiting enough to prevent user enumeration?

Rate limiting is a necessary defense-in-depth measure but not sufficient alone. An attacker with distributed resources or time can still enumerate users. Generic error messages eliminate the information leakage entirely.

Can static analysis detect user enumeration?

Yes. Pattern-matching tools can identify distinct error messages in form validation methods and flag them as potential information disclosure risks.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #128

Related Articles

critical

How Authentication Bypass Happens in Node.js WebSocket Services and How to Fix It

The HousePanel push notification service exposed GET and POST endpoints without any authentication checks, allowing unauthenticated attackers to send arbitrary push notifications to connected smart devices. This critical vulnerability was fixed by implementing mandatory token validation on all protected endpoints, ensuring only authenticated requests can trigger push operations.

critical

How Missing API Authentication Happens in Node.js and How to Fix It

The GitHub API integration in `src/github.mjs` was making unauthenticated requests, subjecting the application to GitHub's strict 60 requests/hour rate limit. This fix adds secure authentication token injection from environment variables using conditional header spreading, enabling authenticated requests with a much higher rate limit (5,000 requests/hour).

high

How OAuth 2.0 Authorization Code Interception happens in PHP and how to fix it

The Weibo OAuth login implementation in `trunk/web/login_weibo.php` was missing PKCE (Proof Key for Code Exchange), allowing attackers with network access to exchange intercepted authorization codes for access tokens. The fix adds cryptographic binding between the authorization request and token exchange using SHA256 code challenges.

high

How OAuth Token Binding Prevents Session Hijacking in Weibo Login Implementation

A critical vulnerability in the Weibo OAuth login implementation allowed attackers to replay stolen access tokens across different user sessions. By binding the OAuth access token to the session ID using cryptographic hashing, the fix ensures that intercepted tokens cannot be reused to hijack other sessions, even if compromised via MITM or XSS attacks.

high

How Unauthenticated Endpoint Exposure Happens in Node.js and How to Fix It

A high-severity unauthenticated endpoint exposure was discovered in `dep/src/server/index.js`, where the `/--ziko--` route served internal application state (`globalThis.Ziko`) to any network-connected client without any authentication or environment guard. The fix adds a single production environment check that returns a `404` before the sensitive data is ever sent. This kind of "debug route left in production" vulnerability is surprisingly common in Node.js applications and can silently leak c

critical

How buffer overflow happens in C++ and how to fix it

A critical buffer overflow in `create_hex_string()` within `hmlangw.cpp` let an unconditional 16-iteration loop write past the bounds of a 100-byte `hex` buffer using unchecked `sprintf` calls. The fix replaces `sprintf` with `snprintf` and caps the loop iterations based on the actual destination buffer size, closing off a memory corruption path reachable from serial or network input.