Module 12: User Registration with Email Verification & Password Reset
🎯 What You Will Learn in This Module
By the end of this tutorial, you will:
- Understand why email verification is critical for production applications
- Build a registration flow that deactivates accounts until email is confirmed
- Use Django's
default_token_generatorto create secure, time-limited tokens - Use
urlsafe_base64_encodeto safely pass user IDs in URLs - Send emails using Django's
send_mailutility - Implement a complete password reset flow (request + confirm)
- Add rate limiting to prevent abuse of password reset endpoints
- Decode verification tokens and safely activate user accounts
📖 1. Why Email Verification Matters
In a production application, you cannot trust that a user owns the email address they provided. Without verification:
- Anyone can register with your email address
- Password reset links go to the wrong person
- Your platform becomes vulnerable to identity fraud and spam
The verification flow:
- User fills out the registration form
- System creates the account but marks it
is_active = False - System sends an email with a unique, secure verification link
- User clicks the link in their inbox
- System validates the token and activates the account
🔐 2. The Token System Explained
Django provides default_token_generator — a cryptographically secure token generator that considers:
- The user's primary key (
pk) - The user's password (hashed)
- The current timestamp
- A secret key
This means:
- Tokens are unique per user
- Tokens expire automatically when the user changes their password
- Tokens cannot be forged without Django's
SECRET_KEY
URL-safe encoding:
We cannot put raw primary keys (like user.pk = 42) directly in URLs for security and consistency reasons. Instead, we use urlsafe_base64_encode to convert the binary ID into a URL-safe string like MQ instead of 1.
📝 3. Registration with Email Verification
The Registration View
1# api/views.py 2 3from django.core.mail import send_mail 4from django.contrib.auth.tokens import default_token_generator 5from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode 6from django.utils.encoding import force_bytes, force_str 7from django.urls import reverse 8from django.conf import settings 9from rest_framework import generics, status 10from rest_framework.response import Response 11from rest_framework.permissions import AllowAny 12 13from django.contrib.auth import get_user_model 14from .serializers import UserRegistrationSerializer 15 16User = get_user_model() 17 18 19class RegisterWithVerificationView(generics.CreateAPIView): 20 """ 21 Handles user registration with email verification. 22 Creates an inactive account and sends a verification email. 23 """ 24 serializer_class = UserRegistrationSerializer 25 permission_classes = [AllowAny] 26 27 def create(self, request, *args, **kwargs): 28 # Validate incoming data using the serializer 29 serializer = self.get_serializer(data=request.data) 30 serializer.is_valid(raise_exception=True) 31 32 # Save the user but keep them inactive 33 user = serializer.save() 34 user.is_active = False # Cannot log in until verified 35 user.save() 36 37 # Generate a secure, one-time verification token 38 token = default_token_generator.make_token(user) 39 40 # Encode the user's primary key for safe URL transmission 41 uid = urlsafe_base64_encode(force_bytes(user.pk)) 42 43 # Build the full verification URL 44 # request.build_absolute_uri turns '/verify/.../' into 'http://localhost:8000/verify/.../' 45 verification_url = request.build_absolute_uri( 46 reverse('verify-email', kwargs={'uidb64': uid, 'token': token}) 47 ) 48 49 # Send the verification email 50 send_mail( 51 subject='Verify your email address', 52 message=f'Click the link to verify your account: {verification_url}', 53 from_email=settings.DEFAULT_FROM_EMAIL, 54 recipient_list=[user.email], 55 fail_silently=False, # Raise errors if email fails 56 ) 57 58 return Response({ 59 'message': 'Registration successful. Please check your email to verify your account.' 60 }, status=status.HTTP_201_CREATED)
Line-by-line breakdown:
| Code | What It Does |
|---|---|
user.is_active = False | Prevents login until verification is complete |
default_token_generator.make_token(user) | Creates a secure, user-specific token |
force_bytes(user.pk) | Converts the integer PK to bytes for encoding |
urlsafe_base64_encode(...) | Encodes bytes into a URL-safe string |
request.build_absolute_uri(...) | Builds a full URL including domain and protocol |
reverse('verify-email', kwargs={...}) | Generates the URL path from your URL configuration |
send_mail(...) | Sends the actual email via your configured backend |
The Email Verification View
1class VerifyEmailView(APIView): 2 """ 3 Handles the verification link click. 4 Decodes the user ID, checks the token validity, and activates the account. 5 """ 6 permission_classes = [AllowAny] 7 8 def get(self, request, uidb64, token): 9 try: 10 # Decode the Base64 user ID back to a normal string 11 uid = force_str(urlsafe_base64_decode(uidb64)) 12 13 # Fetch the user from the database 14 user = User.objects.get(pk=uid) 15 except (TypeError, ValueError, OverflowError, User.DoesNotExist): 16 # If decoding fails or user doesn't exist, set user to None 17 user = None 18 19 # Verify the token matches this user and hasn't expired 20 if user and default_token_generator.check_token(user, token): 21 user.is_active = True 22 user.is_verified = True 23 user.save() 24 25 return Response({ 26 'message': 'Email verified successfully. You can now log in.' 27 }) 28 29 return Response( 30 {'error': 'Invalid or expired verification link'}, 31 status=status.HTTP_400_BAD_REQUEST 32 )
Why we catch TypeError, ValueError, OverflowError:
If a malicious user sends garbage data in the uidb64 parameter, urlsafe_base64_decode might throw an exception. We catch these gracefully instead of crashing.
Why default_token_generator.check_token(user, token) is secure:
It recalculates what the token should be based on the user's current state and compares it. If the user has already clicked the link (and thus changed is_active), or if the token was tampered with, the check fails.
🔑 4. Password Reset Flow
Password reset requires two views: one to request the reset email, and one to confirm the new password using the token from the email.
Request Password Reset
1class RequestPasswordResetView(APIView): 2 """ 3 Accepts an email address and sends a password reset link if the user exists. 4 Does NOT reveal whether the email exists in the system (security best practice). 5 """ 6 permission_classes = [AllowAny] 7 8 def post(self, request): 9 email = request.data.get('email') 10 11 try: 12 user = User.objects.get(email=email) 13 14 # Generate the same type of secure token as email verification 15 token = default_token_generator.make_token(user) 16 uid = urlsafe_base64_encode(force_bytes(user.pk)) 17 18 # Build the password reset URL 19 reset_url = request.build_absolute_uri( 20 reverse('password-reset-confirm', kwargs={'uidb64': uid, 'token': token}) 21 ) 22 23 # Send the reset email 24 send_mail( 25 subject='Password Reset Request', 26 message=f'Reset your password here: {reset_url}\n\nIf you did not request this, please ignore this email.', 27 from_email=settings.DEFAULT_FROM_EMAIL, 28 recipient_list=[email], 29 ) 30 31 return Response({ 32 'message': 'If the email exists, a reset link has been sent.' 33 }) 34 35 except User.DoesNotExist: 36 # Return the SAME message whether the email exists or not. 37 # This prevents attackers from discovering which emails are registered. 38 return Response({ 39 'message': 'If the email exists, a reset link has been sent.' 40 })
🛡️ Security note: Never return different messages for "email not found" vs "email sent." Attackers can use this to enumerate registered users.
Confirm Password Reset
1class PasswordResetConfirmView(APIView): 2 """ 3 Accepts the token from the email and a new password. 4 Validates the token and updates the user's password. 5 """ 6 permission_classes = [AllowAny] 7 8 def post(self, request, uidb64, token): 9 try: 10 uid = force_str(urlsafe_base64_decode(uidb64)) 11 user = User.objects.get(pk=uid) 12 except (TypeError, ValueError, OverflowError, User.DoesNotExist): 13 user = None 14 15 # Validate token and update password 16 if user and default_token_generator.check_token(user, token): 17 new_password = request.data.get('new_password') 18 19 # Enforce minimum password length 20 if len(new_password) < 8: 21 return Response( 22 {'error': 'Password must be at least 8 characters.'}, 23 status=status.HTTP_400_BAD_REQUEST 24 ) 25 26 # Hash and save the new password 27 user.set_password(new_password) 28 user.save() 29 30 return Response({ 31 'message': 'Password reset successful. You can now log in.' 32 }) 33 34 return Response( 35 {'error': 'Invalid or expired token'}, 36 status=status.HTTP_400_BAD_REQUEST 37 )
Why this flow is secure:
- Tokens are tied to the user's current password hash. If the user already reset their password, the old token becomes invalid automatically.
- Tokens are time-sensitive and single-use in practice (because using them changes the user's state).
- The user ID is obfuscated in the URL.
🔗 5. URL Configuration
1# api/urls.py 2 3from django.urls import path 4from . import views 5 6urlpatterns = [ 7 # Registration with verification 8 path('auth/register/', views.RegisterWithVerificationView.as_view(), name='api-register'), 9 path('auth/verify/<uidb64>/<token>/', views.VerifyEmailView.as_view(), name='verify-email'), 10 11 # Password reset 12 path('auth/password-reset/', views.RequestPasswordResetView.as_view(), name='password-reset-request'), 13 path('auth/password-reset/<uidb64>/<token>/', views.PasswordResetConfirmView.as_view(), name='password-reset-confirm'), 14]
⚙️ 6. Email Backend Configuration
Development (Console Backend)
Emails are printed to your terminal — perfect for testing:
1# settings.py 2EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' 3DEFAULT_FROM_EMAIL = 'noreply@localhost'
Production (SMTP Backend)
1# settings.py 2EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' 3EMAIL_HOST = 'smtp.gmail.com' # Or SendGrid, AWS SES, Mailgun 4EMAIL_PORT = 587 5EMAIL_USE_TLS = True 6EMAIL_HOST_USER = 'your-email@gmail.com' 7EMAIL_HOST_PASSWORD = 'your-app-password' # Use app password, not your real password 8DEFAULT_FROM_EMAIL = 'noreply@yourdomain.com'
💡 Production tip: Use a transactional email service like SendGrid, Mailgun, or AWS SES instead of your personal Gmail account. They provide better deliverability, analytics, and rate limiting.
🧪 7. Practice Task — Add Rate Limiting to Password Reset
Task Requirements
Prevent abuse of the password reset endpoint by limiting users to 3 requests per hour per email address.
Solution
1from django.core.cache import cache 2from rest_framework import status 3 4 5class RequestPasswordResetView(APIView): 6 permission_classes = [AllowAny] 7 8 def post(self, request): 9 email = request.data.get('email') 10 11 # Rate limiting key 12 cache_key = f'password_reset_{email}' 13 attempt_count = cache.get(cache_key, 0) 14 15 if attempt_count >= 3: 16 return Response( 17 {'error': 'Too many requests. Please try again in an hour.'}, 18 status=status.HTTP_429_TOO_MANY_REQUESTS 19 ) 20 21 try: 22 user = User.objects.get(email=email) 23 token = default_token_generator.make_token(user) 24 uid = urlsafe_base64_encode(force_bytes(user.pk)) 25 26 reset_url = request.build_absolute_uri( 27 reverse('password-reset-confirm', kwargs={'uidb64': uid, 'token': token}) 28 ) 29 30 send_mail( 31 subject='Password Reset Request', 32 message=f'Reset your password: {reset_url}', 33 from_email=settings.DEFAULT_FROM_EMAIL, 34 recipient_list=[email], 35 ) 36 37 # Increment attempt counter with 1-hour expiry 38 cache.set(cache_key, attempt_count + 1, 60 * 60) 39 40 except User.DoesNotExist: 41 pass # Silently fail to prevent email enumeration 42 43 return Response({ 44 'message': 'If the email exists, a reset link has been sent.' 45 })
How rate limiting works:
- We use Django's cache to track how many times an email has requested a reset
cache.set(cache_key, attempt_count + 1, 60 * 60)stores the count for 1 hour- After 3 attempts, we return HTTP 429 Too Many Requests
❌ Common Mistakes & How to Fix Them
| Mistake | Error / Symptom | Solution |
|---|---|---|
is_active = False but no verification | Users can never log in | Ensure the verification view sets is_active = True |
force_text instead of force_str | NameError in Django 3.0+ | Use force_str (modern Django) |
urlsafe_base64_decode without force_str | TypeError: a bytes-like object is required | Wrap with force_str() |
send_mail without EMAIL_BACKEND | Emails appear to send but don't arrive | Configure EMAIL_BACKEND in settings |
| Revealing email existence | User enumeration attack | Return identical messages whether email exists or not |
set_password() not used | Password stored in plain text | Always use set_password() to hash |
default_token_generator on wrong user | Token always invalid | Ensure the same user object is used to check the token |
| Not catching decode exceptions | Server error (500) on bad URLs | Wrap decode in try/except |
fail_silently=True in development | Email errors hidden | Use fail_silently=False during development |
✅ Module 12 Summary
| Concept | Key Takeaway |
|---|---|
is_active = False | Deactivate new accounts until email is verified |
default_token_generator.make_token() | Creates secure, stateful tokens tied to the user |
default_token_generator.check_token() | Validates that a token matches the user's current state |
urlsafe_base64_encode | Safely encode user IDs for URLs |
urlsafe_base64_decode | Decode URL-safe strings back to normal values |
force_bytes | Convert string/int to bytes before encoding |
force_str | Convert bytes back to string after decoding |
request.build_absolute_uri() | Generate full URLs including domain |
send_mail() | Send transactional emails from Django |
set_password() | Hash and save a new password securely |
| Rate limiting | Use Django cache to track request frequency |
| User enumeration | Never reveal whether an email is registered |
🚪 What's Next?
In Module 13, we will explore Middleware, Signals & Performance Optimization. You'll learn how to hook into Django's request/response cycle, auto-create user profiles with signals, eliminate N+1 queries with select_related and prefetch_related, and implement Redis caching for production-grade speed.
Before proceeding, make sure:
- You can register a user and they receive a verification email (in console)
- Clicking the verification link activates the account (
is_active = True,is_verified = True) - The password reset flow sends an email with a working reset link
- After resetting, the old token no longer works
- You added rate limiting to the password reset endpoint
- You understand why we return the same message for existing and non-existing emails
Your authentication system is now secure and production-ready. Ready to optimize performance with middleware and caching? ⚡🚀