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:
Brute-force verification codes by trying many combinations
Abuse SMS sending to rack up costs or spam users
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#
Encrypt sensitive data at rest (use Django’s database encryption or field-level encryption)
Limit access to the
sms_verificationtableRegularly 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:
Keep SECRET_KEY secret and rotate it periodically
Use a long, random SECRET_KEY (at least 50 characters)
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#
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}")
Limit database retention - Delete old verifications
Encrypt phone numbers if required by regulations (GDPR, CCPA)
User Consent#
Ensure you have user consent before sending SMS:
class PhoneVerificationForm(forms.Form):
phone_number = forms.CharField()
consent = forms.BooleanField(
required=True,
label="I consent to receive SMS messages for verification"
)
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:
Use dedicated test phone numbers
Never use real user numbers
Document all test numbers
Monitor costs closely
Monitoring and Alerting#
Monitor for Abuse#
Set up alerts for:
High SMS volume - Unusual number of verification requests
Failed verifications - Many failed attempts (potential attack)
Expensive operations - SMS to international numbers
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#
Set spending limits in your Twilio/Nexmo account
Monitor daily/monthly costs
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:
Get explicit consent before sending SMS
Allow users to delete their phone verification data
Provide data export functionality
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:
Obtain prior express consent
Provide opt-out mechanism
Identify your business in messages
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:
Do not open a public GitHub issue
Email the maintainer directly (see README for contact)
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#
Configuration Reference - Secure configuration options
Troubleshooting - Common security-related issues