Security Best Practices#

Phone number verification is often used for security-sensitive operations like authentication and account recovery. This guide covers security best practices when using django-phone-verify.

Rate Limiting#

Why Rate Limiting is Critical#

Without rate limiting, attackers can:

  1. Brute-force verification codes by trying many combinations

  2. Abuse SMS sending to rack up costs or spam users

  3. Enumerate phone numbers to discover which are registered

Implementing Rate Limiting#

Option 1: Django Ratelimit

Install the package:

pip install django-ratelimit

Apply rate limiting to your endpoints:

from django_ratelimit.decorators import ratelimit
from rest_framework.decorators import action
from phone_verify.api import VerificationViewSet

class CustomVerificationViewSet(VerificationViewSet):

    @ratelimit(key='ip', rate='3/h', method='POST')
    @action(detail=False, methods=['POST'])
    def register(self, request):
        # Limit to 3 code requests per hour per IP
        return super().register(request)

    @ratelimit(key='ip', rate='10/h', method='POST')
    @action(detail=False, methods=['POST'])
    def verify(self, request):
        # Limit to 10 verification attempts per hour per IP
        return super().verify(request)

Option 2: Django REST Framework Throttling

from rest_framework.throttling import AnonRateThrottle, UserRateThrottle

class PhoneRegisterThrottle(AnonRateThrottle):
    rate = '3/hour'

class PhoneVerifyThrottle(AnonRateThrottle):
    rate = '10/hour'

class CustomVerificationViewSet(VerificationViewSet):
    throttle_classes = [PhoneRegisterThrottle]

    def get_throttles(self):
        if self.action == 'verify':
            return [PhoneVerifyThrottle()]
        return super().get_throttles()

Option 3: Phone Number-Based Rate Limiting

from django.core.cache import cache
from rest_framework.exceptions import Throttled

class CustomVerificationViewSet(VerificationViewSet):

    @action(detail=False, methods=['POST'])
    def register(self, request):
        phone_number = request.data.get('phone_number')

        # Check rate limit
        key = f"phone_verify:{phone_number}:register"
        attempts = cache.get(key, 0)

        if attempts >= 3:
            raise Throttled(detail="Too many requests. Try again later.")

        # Increment counter
        cache.set(key, attempts + 1, timeout=3600)  # 1 hour

        return super().register(request)

Recommended Limits:

  • Code requests: 3-5 per hour per phone number

  • Verification attempts: 5-10 per hour per phone number

  • Failed verifications: Lock after 5 consecutive failures

Security Code Settings#

Token Length#

Use an appropriate token length based on your threat model:

PHONE_VERIFICATION = {
    "TOKEN_LENGTH": 6,  # Recommended minimum (1 million combinations)
    ...
}

Security Analysis:

  • 4 digits: 10,000 combinations → Too weak, brute-forceable

  • 6 digits: 1,000,000 combinations → Standard, secure with rate limiting

  • 8 digits: 100,000,000 combinations → Very secure but harder for users

Warning

Tokens of 4 digits or less are not recommended for production use.

Expiration Time#

Use short expiration times for security-sensitive operations:

PHONE_VERIFICATION = {
    # Security-sensitive (login, 2FA, password reset)
    "SECURITY_CODE_EXPIRATION_SECONDS": 300,  # 5 minutes

    # Standard registration flows
    "SECURITY_CODE_EXPIRATION_SECONDS": 600,  # 10 minutes

    # Avoid longer times in production
    ...
}

Trade-offs:

  • Shorter (5-10 min): Better security, may frustrate slow users

  • Longer (30-60 min): Better UX, higher security risk

One-Time Codes#

Always use one-time codes in production:

PHONE_VERIFICATION = {
    "VERIFY_SECURITY_CODE_ONLY_ONCE": True,  # Recommended for production
    ...
}

This prevents:

  • Code reuse by attackers

  • Replay attacks

  • Unauthorized verification attempts

Secure Storage#

Credential Management#

Never hard-code credentials:

# ❌ BAD - Credentials in source code
PHONE_VERIFICATION = {
    "OPTIONS": {
        "SID": "AC1234567890abcdef",
        "SECRET": "my_secret_token",
    },
    ...
}

Use environment variables:

# ✅ GOOD - Credentials from environment
import os

PHONE_VERIFICATION = {
    "OPTIONS": {
        "SID": os.getenv("TWILIO_ACCOUNT_SID"),
        "SECRET": os.getenv("TWILIO_AUTH_TOKEN"),
    },
    ...
}

Use a secrets manager (production):

# ✅ BETTER - Use AWS Secrets Manager, HashiCorp Vault, etc.
import boto3
import json

def get_secret(secret_name):
    client = boto3.client('secretsmanager')
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response['SecretString'])

twilio_creds = get_secret("production/twilio")

PHONE_VERIFICATION = {
    "OPTIONS": {
        "SID": twilio_creds["sid"],
        "SECRET": twilio_creds["secret"],
    },
    ...
}

Database Security#

  1. Encrypt sensitive data at rest (use Django’s database encryption or field-level encryption)

  2. Limit access to the sms_verification table

  3. Regularly clean up old verification records:

from django.utils import timezone
from datetime import timedelta
from phone_verify.models import SMSVerification

# Delete records older than 30 days
cutoff = timezone.now() - timedelta(days=30)
SMSVerification.objects.filter(created_at__lt=cutoff).delete()

Session Token Security#

Session tokens are JWTs signed with Django’s SECRET_KEY:

  1. Keep SECRET_KEY secret and rotate it periodically

  2. Use a long, random SECRET_KEY (at least 50 characters)

  3. Don’t expose session tokens in URLs or logs

# Generate a secure SECRET_KEY
from django.core.management.utils import get_random_secret_key
print(get_random_secret_key())

Phone Number Privacy#

Minimize PII Exposure#

  1. Log carefully - Don’t log full phone numbers:

import logging

logger = logging.getLogger(__name__)

# ❌ BAD
logger.info(f"Verification sent to {phone_number}")

# ✅ GOOD - Mask phone number
masked = phone_number[:3] + "****" + phone_number[-2:]
logger.info(f"Verification sent to {masked}")
  1. Limit database retention - Delete old verifications

  2. Encrypt phone numbers if required by regulations (GDPR, CCPA)

Avoiding Information Disclosure#

Don’t reveal whether a phone number is registered:

# ❌ BAD - Reveals if phone is registered
def register(self, request):
    phone = request.data['phone_number']
    if User.objects.filter(phone_number=phone).exists():
        return Response({"error": "Phone number already registered"}, status=400)
    # Send code
    ...

# ✅ GOOD - Always send code (or pretend to)
def register(self, request):
    phone = request.data['phone_number']
    # Always return success, but only send SMS if not registered
    if not User.objects.filter(phone_number=phone).exists():
        send_security_code_and_generate_session_token(phone)
    return Response({"message": "If this number is valid, you'll receive a code"})

Message Content Security#

Avoid Phishing Risks#

Use clear, consistent branding in messages:

PHONE_VERIFICATION = {
    "MESSAGE": "Your Acme Corp verification code is {security_code}. "
               "Never share this code with anyone, including Acme staff.",
    ...
}

Best Practices:

  • Include your app/company name

  • Warn users not to share the code

  • Don’t include links (phishing risk)

  • Keep messages concise

Prevent Message Injection#

If you allow custom context in messages, sanitize inputs:

from django.utils.html import escape

def send_custom_verification(phone_number, username):
    service = PhoneVerificationService(phone_number)

    # ❌ BAD - Allows injection
    context = {"username": username}

    # ✅ GOOD - Escape user input
    context = {"username": escape(username)[:20]}  # Limit length too

    service.send_verification(
        phone_number,
        "123456",
        context=context
    )

Testing and Sandbox Mode#

Never Test in Production#

Use sandbox backends for testing:

# Development settings
PHONE_VERIFICATION = {
    "BACKEND": "phone_verify.backends.twilio.TwilioSandboxBackend",
    "OPTIONS": {
        "SANDBOX_TOKEN": "123456",
        ...
    },
    ...
}

Separate Test Phone Numbers#

If you must test with real SMS:

  1. Use dedicated test phone numbers

  2. Never use real user numbers

  3. Document all test numbers

  4. Monitor costs closely

Monitoring and Alerting#

Monitor for Abuse#

Set up alerts for:

  1. High SMS volume - Unusual number of verification requests

  2. Failed verifications - Many failed attempts (potential attack)

  3. Expensive operations - SMS to international numbers

  4. API errors - Twilio/Nexmo failures

# Example: Log suspicious activity
import logging

logger = logging.getLogger(__name__)

class CustomVerificationViewSet(VerificationViewSet):

    @action(detail=False, methods=['POST'])
    def verify(self, request):
        phone = request.data.get('phone_number')
        code = request.data.get('security_code')

        # Check for repeated failures
        key = f"failed_verifications:{phone}"
        failures = cache.get(key, 0)

        try:
            response = super().verify(request)
            cache.delete(key)  # Reset on success
            return response
        except ValidationError:
            failures += 1
            cache.set(key, failures, timeout=3600)

            if failures >= 5:
                logger.warning(
                    f"Suspicious activity: {failures} failed verifications for {phone[:3]}****"
                )

            raise

Audit Logging#

Log security-relevant events:

import logging

logger = logging.getLogger('phone_verify.audit')

def audit_log(event, phone_number, metadata=None):
    masked_phone = phone_number[:3] + "****" + phone_number[-2:]
    logger.info(f"{event} | phone={masked_phone} | metadata={metadata}")

# Usage
audit_log("code_sent", phone_number, {"ip": request.META['REMOTE_ADDR']})
audit_log("verification_success", phone_number)
audit_log("verification_failed", phone_number, {"reason": "expired"})

Cost Management#

SMS costs can add up quickly. Implement safeguards:

Spending Limits#

  1. Set spending limits in your Twilio/Nexmo account

  2. Monitor daily/monthly costs

  3. Alert when approaching limits

Prevent SMS Spam#

from django.core.cache import cache

def check_global_rate_limit():
    """Prevent application-wide SMS abuse"""
    key = "global_sms_count"
    count = cache.get(key, 0)

    # Max 1000 SMS per hour across the application
    if count >= 1000:
        raise Exception("Global SMS limit reached. Possible abuse.")

    cache.set(key, count + 1, timeout=3600)

International Numbers#

International SMS can be expensive. Consider:

import phonenumbers

def is_allowed_country(phone_number):
    """Only allow specific countries"""
    try:
        parsed = phonenumbers.parse(phone_number, None)
        country = phonenumbers.region_code_for_number(parsed)
        # Allow only US and Canada
        return country in ['US', 'CA']
    except:
        return False

class CustomVerificationViewSet(VerificationViewSet):

    @action(detail=False, methods=['POST'])
    def register(self, request):
        phone = request.data.get('phone_number')

        if not is_allowed_country(phone):
            return Response(
                {"error": "Phone number country not supported"},
                status=400
            )

        return super().register(request)

Compliance#

GDPR / CCPA#

If you operate in the EU or California:

  1. Get explicit consent before sending SMS

  2. Allow users to delete their phone verification data

  3. Provide data export functionality

  4. Document data retention policies

# Example: GDPR data deletion
from phone_verify.models import SMSVerification

def delete_user_phone_data(phone_number):
    """Delete all verification data for a phone number"""
    SMSVerification.objects.filter(phone_number=phone_number).delete()

TCPA (US)#

The Telephone Consumer Protection Act regulates automated messages:

  1. Obtain prior express consent

  2. Provide opt-out mechanism

  3. Identify your business in messages

  4. Only send to numbers that opted in

Do Not Call Registries#

Check numbers against do-not-call registries if legally required in your jurisdiction.

Security Checklist#

Use this checklist before going to production:

☐ Rate limiting implemented (per IP and per phone number)
☐ TOKEN_LENGTH >= 6
☐ SECURITY_CODE_EXPIRATION_SECONDS <= 600 (10 minutes)
☐ VERIFY_SECURITY_CODE_ONLY_ONCE = True
☐ Credentials stored in environment variables or secrets manager
☐ Django SECRET_KEY is strong and secret
☐ Sandbox backend used in development/test
☐ Production backend used in production
☐ Phone numbers masked in logs
☐ Old verification records regularly deleted
☐ Monitoring and alerting set up
☐ Spending limits configured with SMS provider
☐ User consent obtained before sending SMS
☐ Messages include clear branding
☐ HTTPS enforced for all API endpoints
☐ CSRF protection enabled
☐ Audit logging implemented
☐ Security testing performed

Reporting Security Issues#

If you discover a security vulnerability in django-phone-verify:

  1. Do not open a public GitHub issue

  2. Email the maintainer directly (see README for contact)

  3. Include: - Description of the vulnerability - Steps to reproduce - Potential impact - Suggested fix (if available)

The maintainer will work with you to address the issue and coordinate disclosure.

Further Reading#