django-allauth implements sophisticated authentication flows that handle complex multi-step processes. These flows coordinate between forms, adapters, models, and stages to provide secure, flexible authentication experiences.
Flows are internal orchestration logic in allauth.account.internal.flows that manage the complex dance between different authentication steps.
Flexible login accepting either email or username. The system auto-detects which one the user entered.Auto-detection logic (flows/login.py:130-145):
def derive_login_method(login: str) -> LoginMethod: if len(app_settings.LOGIN_METHODS) == 1: return next(iter(app_settings.LOGIN_METHODS)) if LoginMethod.EMAIL in app_settings.LOGIN_METHODS: try: validators.validate_email(login) return LoginMethod.EMAIL except exceptions.ValidationError: pass if LoginMethod.PHONE in app_settings.LOGIN_METHODS: # Check if it's a phone number pass return LoginMethod.USERNAME
# settings.pyACCOUNT_LOGIN_METHODS = {"phone"}
Login using phone number and password (requires phone number verification).
Passwordless authentication via one-time codes sent by email:Configuration:
# settings.py# Enable login by codeACCOUNT_LOGIN_BY_CODE_ENABLED = True# Code expires after 3 minutesACCOUNT_LOGIN_BY_CODE_TIMEOUT = 180# Maximum 3 attempts per codeACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 3# Allow "Trust this browser" (requires MFA app)ACCOUNT_LOGIN_BY_CODE_TRUST_ENABLED = False# Require login codes for specific authentication methodsACCOUNT_LOGIN_BY_CODE_REQUIRED = {"password"} # or True for all methods
django-allauth maintains a session log of all authentication methods used:From source (flows/login.py:19-57):
def record_authentication(request, user, method, **extra_data): """ Keeps a log of all authentication methods used within the current session. Example data: {'method': 'password', 'at': 1701423602.7184925, 'username': 'john.doe'} {'method': 'socialaccount', 'at': 1701423567.6368647, 'provider': 'amazon', 'uid': 'amzn1.account.K2LI23KL2LK2'} {'method': 'mfa', 'at': 1701423602.6392953, 'id': 1, 'type': 'totp'} """ methods = request.session.get(AUTHENTICATION_METHODS_SESSION_KEY, []) data = { "method": method, "at": time.time(), } for k, v in extra_data.items(): if v is not None: data[k] = v methods.append(data) request.session[AUTHENTICATION_METHODS_SESSION_KEY] = methods
This enables:
Multi-factor authentication tracking
Step-up authentication
Security auditing
Conditional access based on authentication strength
class EmailVerificationStage(LoginStage): """ Handles email verification during login process. Only active when email verification is mandatory. """ key = LoginStageKey.VERIFY_EMAIL urlname = "account_email_verification" def is_resumable(self, request): # Stage is resumable if we're in the process of verifying return True
When a user with an unverified email tries to log in with ACCOUNT_EMAIL_VERIFICATION = "mandatory", they’re redirected to verify their email before fully logging in.
class LoginStageController: """ Orchestrates multi-step login flows. Tracks which stages have been completed and which are pending. """ def __init__(self, request, login): self.request = request self.login = login self.state = self.login.state.setdefault("stages", {}) def get_pending_stage(self) -> Optional[LoginStage]: """Returns the next stage that needs to be completed.""" ret = None stages = self.get_stages() for stage in stages: if self.is_handled(stage.key): continue ret = stage break return ret def handle(self): """Process the next pending stage or complete login.""" stage = self.get_pending_stage() if stage: self.set_current(stage.key) response, continue_flow = stage.handle() if not continue_flow: return response if response: return response return None
Enumeration attacks allow attackers to discover which email addresses have accounts by observing different responses for existing vs. non-existing emails.
System sends “account already exists” email to that address
User sees same “check your email” message
✅ Attacker cannot tell if email was already registeredFrom source (configuration.rst:35-45):
Whether or not enumeration can be prevented during signup depends on the email
verification method. In case of mandatory verification, enumeration can be
properly prevented because the case where an email address is already taken is
indistinguishable from the case where it is not.
User requests password reset for any email
Always shows “If that email exists, we sent a link”
If email exists: send password reset email
If email doesn’t exist: send “no account found” email (optional)
Configuration:
# Send email even if account doesn't existACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = True
With ACCOUNT_PREVENT_ENUMERATION = "strict":
Allows multiple accounts with same email (only one can be verified)
from allauth.account.adapter import DefaultAccountAdapterclass MyAccountAdapter(DefaultAccountAdapter): def is_open_for_signup(self, request): """Control if signups are allowed.""" # Only allow signups with valid invitation return 'invite_code' in request.session def pre_signup(self, request, form): """Called before user is saved.""" # Validate invite code invite_code = request.session.get('invite_code') if not InviteCode.objects.filter(code=invite_code, used=False).exists(): raise forms.ValidationError("Invalid invitation code") def save_user(self, request, user, form, commit=True): """Customize user creation.""" user = super().save_user(request, user, form, commit=False) # Extract data from invitation invite = InviteCode.objects.get(code=request.session['invite_code']) user.organization = invite.organization user.role = invite.role if commit: user.save() invite.mark_used() return user def get_signup_redirect_url(self, request): """Custom redirect after signup.""" # Send new users to onboarding return reverse('onboarding:welcome')
Token Generator:The default token generator includes email addresses in the hash, so the token becomes invalid if the user’s email changes:From source (forms.py:39-49):
class EmailAwarePasswordResetTokenGenerator(PasswordResetTokenGenerator): def _make_hash_value(self, user, timestamp): ret = super()._make_hash_value(user, timestamp) sync_user_email_address(user) email = user_email(user) emails = set([email] if email else []) emails.update( EmailAddress.objects.filter(user=user).values_list("email", flat=True) ) ret += "|".join(sorted(emails)) return ret
User receives email with a 6-digit code to enter manually.
from allauth.account.adapter import DefaultAccountAdapterclass MyAccountAdapter(DefaultAccountAdapter): def get_login_redirect_url(self, request): """ Determine where to redirect after login based on user role. """ user = request.user if user.is_staff: return reverse('admin:index') elif user.profile.is_premium: return reverse('premium:dashboard') elif not user.profile.is_complete: return reverse('profile:complete') else: return reverse('home')
from allauth.account.views import LoginView as AllauthLoginViewfrom allauth.account.internal.flows.login import perform_loginclass CustomLoginView(AllauthLoginView): def form_valid(self, form): """Override to add custom login logic.""" # Get login instance login = form.login # Check if user is from banned IP if is_ip_banned(self.request): return self.form_invalid(form) # Log login attempt LoginAttempt.objects.create( user=login.user, ip=get_client_ip(self.request), success=True ) # Continue with normal flow return perform_login(self.request, login)