Email verification is a critical security feature that confirms users own the email addresses they register with. django-allauth provides flexible email verification with multiple strategies to balance security and user experience.
Email verification prevents users from registering with email addresses they don’t control, which could be used for spam, impersonation, or account takeover.
Hello {{ user.username }},Please confirm your email address by clicking the link below:{{ verification_url }}This link will expire in 3 days.
Features:
✅ Works in all email clients
✅ One-click verification
✅ Longer expiration (default: 3 days)
✅ No typing required
Configuration:
# settings.py# Token expirationACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 3# Use HMAC tokens (stateless, more secure)ACCOUNT_EMAIL_CONFIRMATION_HMAC = True # Default# Allow verification via GET requestACCOUNT_CONFIRM_EMAIL_ON_GET = False # Default (requires POST for safety)
Setting ACCOUNT_CONFIRM_EMAIL_ON_GET = True allows email verification via GET request, which violates HTTP semantics (GET shouldn’t modify state). Only enable if absolutely necessary for compatibility.
Allow users to correct their email if they made a typo:
# settings.py# Allow changing email during verificationACCOUNT_EMAIL_VERIFICATION_MAX_CHANGE_COUNT = 2 # Or True/False# Old boolean setting (deprecated)# ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_CHANGE = True
Values:
0 or False - Cannot change email
2 or True - Can change up to 2 times
Any integer - Custom limit
Enumeration risk: With enumeration prevention enabled, changing email during verification has limitations. See source (configuration.rst:243-249):
If enumeration prevention is turned on, no account is created when a user signs up using an already existing email. If the user then were able to change to a new email address that is not taken, we would have to create an account as we did not do so yet. Currently, this is not implemented.
Allow users to request a new verification email/code:
# settings.py# Allow resending verificationACCOUNT_EMAIL_VERIFICATION_MAX_RESEND_COUNT = 2 # Or True/False# Old boolean setting (deprecated)# ACCOUNT_EMAIL_VERIFICATION_SUPPORTS_RESEND = True
Implementation:
from allauth.account.internal.flows.email_verification import ( send_verification_email_to_address)from allauth.account.models import EmailAddressdef resend_verification(request): """Resend verification email to user's primary email.""" email_address = EmailAddress.objects.get_primary(request.user) if email_address and not email_address.verified: sent = send_verification_email_to_address( request, email_address, signup=False ) if sent: messages.info(request, "Verification email sent!") else: messages.error(request, "Please wait before requesting another email.")
Prevent abuse of verification email sending:From source (app_settings.py:270-276):
if self.EMAIL_VERIFICATION_BY_CODE_ENABLED: confirm_email_rl = "1/10s/key"else: cooldown = self._setting("EMAIL_CONFIRMATION_COOLDOWN", 3 * 60) confirm_email_rl = None if cooldown: confirm_email_rl = f"1/{cooldown}s/key"
Configuration:
# settings.pyACCOUNT_RATE_LIMITS = { # For code-based: max 1 per 10 seconds per email "confirm_email": "1/10s/key", # For link-based: max 1 per 3 minutes per email # Controlled by ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN}# Deprecated setting (still works)ACCOUNT_EMAIL_CONFIRMATION_COOLDOWN = 180 # 3 minutes
Hard vs. Soft Rate Limiting:From source (flows/email_verification.py:169-187):
def handle_verification_email_rate_limit( request, email: str, raise_exception: bool = False) -> bool: """ For email verification by link, it is not an issue if the user runs into rate limits. The reason is that the link is session independent. Therefore, if the user hits rate limits, we can just silently skip sending additional verification emails, as the previous emails that were already sent still contain valid links. This is different from email verification by code. Here, the session contains a specific code, meaning, silently skipping new verification emails is not an option, and we must hard fail (429) instead. """ rl_ok = consume_email_verification_rate_limit( request, email, raise_exception=raise_exception ) if not rl_ok and app_settings.EMAIL_VERIFICATION_BY_CODE_ENABLED: raise ImmediateHttpResponse(respond_429(request)) return rl_ok
Link-based: Rate limit failures are silent (old link still works) Code-based: Rate limit failures return HTTP 429 (new code needed)
class EmailAddress(models.Model): user = models.ForeignKey( settings.AUTH_USER_MODEL, verbose_name=_("user"), on_delete=models.CASCADE, ) email = models.EmailField( db_index=True, max_length=app_settings.EMAIL_MAX_LENGTH, verbose_name=_("email address"), ) verified = models.BooleanField(verbose_name=_("verified"), default=False) primary = models.BooleanField(verbose_name=_("primary"), default=False) class Meta: unique_together = [("user", "email")] constraints = [ # Each user can have only one primary email UniqueConstraint( fields=["user", "primary"], name="unique_primary_email", condition=Q(primary=True), ) ] # If ACCOUNT_UNIQUE_EMAIL = True if app_settings.UNIQUE_EMAIL: constraints.append( # Only one user can have a verified email UniqueConstraint( fields=["email"], name="unique_verified_email", condition=Q(verified=True) ) )
Mark an email as verified.From source (models.py:75-82):
def set_verified(self, commit=True): """Mark email as verified if no conflicts exist.""" if self.verified: return True if self.can_set_verified(): self.verified = True if commit: self.save(update_fields=["verified"]) return self.verified
Checks for conflicts (uniqueness constraint) before verifying.
set_as_primary()
Set email as primary for the user.From source (models.py:84-99):
def set_as_primary(self, conditional=False): """ Marks the email address as primary. In case of `conditional`, it is only marked as primary if there is no other primary email address set. """ old_primary = EmailAddress.objects.get_primary(self.user) if old_primary: if conditional: return False old_primary.primary = False old_primary.save() self.primary = True self.save() user_email(self.user, self.email, commit=True) return True
Also syncs with User model’s email field.
send_confirmation()
Send verification email for this address.From source (models.py:101-105):
from allauth.account.models import EmailAddress# Get primary emailprimary = EmailAddress.objects.get_primary(user)# Get specific email for useremail = EmailAddress.objects.get_for_user(user, 'test@example.com')# Check if email exists for userexists = EmailAddress.objects.filter( user=user, email='test@example.com').exists()# Get all verified emailsverified = EmailAddress.objects.filter(user=user, verified=True)
In previous versions, a record was stored in the database for each ongoing email confirmation, keeping track of these keys. Current versions use HMAC based keys that do not require server side state.
When ACCOUNT_EMAIL_VERIFICATION = "mandatory":From source (flows/email_verification.py:281-308):
def send_verification_email_at_login(request: HttpRequest, login: Login) -> bool: """ At this point, it has already been confirmed that email verification is needed. Email verification mails are sent: a) Explicitly: when a user signs up b) Implicitly: when a user attempts to log in using an unverified email while EMAIL_VERIFICATION is mandatory. """ if not login.user: sent = send_verification_email_at_fake_login(request, login) else: sent = send_verification_email_at_real_login(request, login) return sent
Security considerations from source (flows/email_verification.py:108-147):
def login_on_verification(request, email_address) -> Optional[HttpResponse]: """ Simply logging in the user may become a security issue. If you do not take proper care (e.g. don't purge used email confirmations), a malicious person that got hold of the link will be able to login over and over again and the user is unable to do anything about it. Even restoring their own mailbox security will not help, as the links will still work. All in all, we only login on verification when the user that is in the process of signing up is present in the session to avoid all of the above. This may not 100% work in case the user closes the browser (and the session gets lost), but at least we're secure. """
Verify email based on external validation (e.g., OAuth provider):
from allauth.account.internal.flows.email_verification import ( verify_email_indirectly)# User logged in via Google OAuth# We trust Google's email verificationif provider == 'google': verify_email_indirectly(request, user, email='user@gmail.com')
Configure where users go after verifying their email:
# settings.py# For authenticated usersACCOUNT_EMAIL_CONFIRMATION_AUTHENTICATED_REDIRECT_URL = '/dashboard/'# For anonymous users (e.g., clicked link after session expired)ACCOUNT_EMAIL_CONFIRMATION_ANONYMOUS_REDIRECT_URL = '/accounts/login/'
# tests.pyfrom allauth.account.models import EmailAddressdef test_email_verification(): # Create user user = User.objects.create_user('test', 'test@example.com', 'password') # Manually mark email as verified email = EmailAddress.objects.create( user=user, email='test@example.com', verified=True, primary=True ) # Test verified user behavior assert email.verified assert user can access protected features