WebAuthn (Web Authentication) provides phishing-resistant authentication using:
- Hardware security keys (YubiKey, Google Titan, etc.)
- Platform authenticators (Touch ID, Face ID, Windows Hello)
- Passkeys for passwordless authentication
WebAuthn implements the FIDO2 standard and uses public-key cryptography, making it resistant to phishing, credential stuffing, and man-in-the-middle attacks.
Installation
WebAuthn support requires the fido2 package:
pip install "django-allauth[mfa]"
Configuration
WebAuthn is disabled by default. Enable it in settings.py:
# Enable WebAuthn
MFA_SUPPORTED_TYPES = ["totp", "webauthn", "recovery_codes"]
# Optional: Enable passkey login (passwordless)
MFA_PASSKEY_LOGIN_ENABLED = True
# Optional: Enable passkey signup
MFA_PASSKEY_SIGNUP_ENABLED = True
# Required for default templates
INSTALLED_APPS = [
# ...
"django.contrib.humanize", # For displaying dates nicely
]
Development Setup
For local development on localhost:
# Only use in development - never in production!
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True
Versions of fido2 up to 1.1.3 do not regard localhost as a secure origin. Set MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True only for local development, never in production.
URL Endpoints
WebAuthn URLs are available at:
/accounts/mfa/webauthn/add/ - Add a new WebAuthn authenticator
/accounts/mfa/webauthn/ - List all WebAuthn authenticators
/accounts/mfa/webauthn/<pk>/edit/ - Edit authenticator name
/accounts/mfa/webauthn/<pk>/delete/ - Remove an authenticator
/accounts/mfa/webauthn/login/ - Passwordless login endpoint
/accounts/mfa/webauthn/signup/ - Passwordless signup endpoint
/accounts/mfa/webauthn/reauthenticate/ - Reauthentication endpoint
Adding a WebAuthn Authenticator
Flow
- User navigates to
/accounts/mfa/webauthn/add/
- Server generates a challenge and registration options
- User’s browser/device prompts for authentication (fingerprint, security key, etc.)
- Device creates a new key pair and returns the public key
- Server validates and stores the public key
Template Example
templates/mfa/webauthn/add_form.html
{% extends "account/base.html" %}
{% block content %}
<h1>Add Security Key</h1>
<form method="post" id="webauthn-form">
{% csrf_token %}
{{ form.as_p }}
<button type="button" id="register-button">
Register Security Key
</button>
</form>
<script>
const creationOptions = {{ js_data.creation_options|safe }};
document.getElementById('register-button').addEventListener('click', async () => {
try {
// Create credential
const credential = await navigator.credentials.create({
publicKey: creationOptions
});
// Set form field and submit
document.getElementById('id_credential').value = JSON.stringify(credential);
document.getElementById('webauthn-form').submit();
} catch (error) {
console.error('WebAuthn registration failed:', error);
alert('Failed to register security key: ' + error.message);
}
});
</script>
{% endblock %}
Passkey Login (Passwordless)
Passkeys allow users to log in without entering a username or password.
Enable Passkey Login
MFA_SUPPORTED_TYPES = ["totp", "webauthn", "recovery_codes"]
MFA_PASSKEY_LOGIN_ENABLED = True
How It Works
- User clicks “Sign in with passkey” button
- Browser prompts user to select a passkey
- User authenticates (fingerprint, face, security key)
- Server validates the signature and logs user in
Implementation Example
templates/account/login.html
<button type="button" id="passkey-login">
Sign in with a passkey
</button>
<script>
async function passkeyLogin() {
try {
// Get authentication options from server
const response = await fetch('/accounts/mfa/webauthn/login/', {
headers: {'X-Requested-With': 'XMLHttpRequest'}
});
const { request_options } = await response.json();
// Prompt for passkey
const credential = await navigator.credentials.get({
publicKey: request_options
});
// Submit credential
const formData = new FormData();
formData.append('credential', JSON.stringify(credential));
formData.append('csrfmiddlewaretoken', '{{ csrf_token }}');
await fetch('/accounts/mfa/webauthn/login/', {
method: 'POST',
body: formData
});
window.location.href = '/'; // Redirect after login
} catch (error) {
console.error('Passkey login failed:', error);
}
}
document.getElementById('passkey-login').addEventListener('click', passkeyLogin);
</script>
Passkey Signup
Users can create an account using only a passkey, no password required.
Requirements
# Enable passkey signup
MFA_PASSKEY_SIGNUP_ENABLED = True
MFA_SUPPORTED_TYPES = ["webauthn", "recovery_codes"]
# Require email verification with code
ACCOUNT_EMAIL_VERIFICATION = "mandatory"
ACCOUNT_EMAIL_VERIFICATION_BY_CODE_ENABLED = True
Passkey signup requires email verification by code because the traditional email verification link approach needs a password, which passkey users don’t have.
Programmatic Usage
Begin Registration
from allauth.mfa.webauthn.internal.auth import begin_registration
# Generate registration options
registration_data = begin_registration(
user=request.user,
passwordless=False # True for passkeys
)
# Returns dict with challenge, RP info, user info, etc.
# Pass to frontend for navigator.credentials.create()
Complete Registration
from allauth.mfa.webauthn.internal.auth import (
complete_registration,
parse_registration_response
)
from allauth.mfa.webauthn.internal.auth import WebAuthn
# Parse credential from frontend
credential = parse_registration_response(request.POST.get('credential'))
# Validate and get authenticator data
authenticator_data = complete_registration(credential)
# Store the authenticator
webauthn = WebAuthn.add(
user=request.user,
name="My Security Key",
credential=credential
)
Begin Authentication
from allauth.mfa.webauthn.internal.auth import begin_authentication
# For specific user (2FA)
request_options = begin_authentication(user=request.user)
# For any user (passkey login)
request_options = begin_authentication(user=None)
# Pass to frontend for navigator.credentials.get()
Complete Authentication
from allauth.mfa.webauthn.internal.auth import (
complete_authentication,
extract_user_from_response,
parse_authentication_response
)
# For passkey login, extract user from response
if passwordless:
user = extract_user_from_response(response)
# Validate authentication
authenticator = complete_authentication(user, response)
authenticator.record_usage()
List User’s Authenticators
from allauth.mfa.models import Authenticator
authenticators = Authenticator.objects.filter(
user=request.user,
type=Authenticator.Type.WEBAUTHN
)
for auth in authenticators:
webauthn = auth.wrap()
print(f"Name: {webauthn.name}")
print(f"Passwordless: {webauthn.is_passwordless}")
print(f"Last used: {auth.last_used_at}")
Customizing the Adapter
Customize WebAuthn behavior by overriding the adapter:
from allauth.mfa.adapter import DefaultMFAAdapter
class CustomMFAAdapter(DefaultMFAAdapter):
def get_public_key_credential_rp_entity(self):
"""Customize Relying Party information."""
return {
"id": "example.com", # Must match your domain
"name": "Example Corp",
}
def get_public_key_credential_user_entity(self, user):
"""Customize user information in credentials."""
return {
"id": str(user.pk).encode('utf8'),
"display_name": user.get_full_name(),
"name": user.email,
}
def generate_authenticator_name(self, user, type):
"""Customize default authenticator names."""
count = Authenticator.objects.filter(
user=user,
type=type
).count()
return f"Security Key #{count + 1}"
MFA_ADAPTER = 'myapp.adapter.CustomMFAAdapter'
Override WebAuthn forms:
MFA_FORMS = {
'add_webauthn': 'myapp.forms.CustomAddWebAuthnForm',
'edit_webauthn': 'myapp.forms.CustomEditWebAuthnForm',
}
from allauth.mfa.webauthn.forms import AddWebAuthnForm
class CustomAddWebAuthnForm(AddWebAuthnForm):
def clean_name(self):
name = self.cleaned_data['name']
# Add custom validation
if len(name) < 3:
raise forms.ValidationError("Name must be at least 3 characters")
return name
Database Model
WebAuthn credentials are stored in the Authenticator model:
class Authenticator(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
type = models.CharField(max_length=20) # "webauthn"
data = models.JSONField() # Stores credential data
created_at = models.DateTimeField(auto_now_add=True)
last_used_at = models.DateTimeField(null=True)
The data field stores:
{
"name": "My YubiKey",
"credential": {
"id": "...", # Credential ID
"rawId": "...",
"response": {
"attestationObject": "...",
"clientDataJSON": "..."
},
"type": "public-key",
"clientExtensionResults": {
"credProps": {"rk": true} # Indicates passwordless capability
}
}
}
Security Features
Phishing Resistance
WebAuthn credentials are bound to your domain. They won’t work on phishing sites:
# Credentials only work for the registered RP ID
rp_entity = {
"id": "example.com", # Must match the current domain
"name": "Example"
}
User Verification
For passwordless flows, require user verification (biometric/PIN):
# From webauthn/internal/auth.py
registration_data = server.register_begin(
user=user,
credentials=credentials,
resident_key_requirement=ResidentKeyRequirement.REQUIRED, # For passkeys
user_verification=UserVerificationRequirement.REQUIRED, # Require biometric/PIN
)
Attestation
WebAuthn supports attestation to verify the authenticator’s authenticity. The fido2 library handles this automatically.
Browser Support
WebAuthn is supported in:
- Chrome/Edge 67+
- Firefox 60+
- Safari 13+
- Opera 54+
Feature Detection
if (window.PublicKeyCredential) {
// WebAuthn is supported
document.getElementById('webauthn-button').style.display = 'block';
} else {
// Fall back to password/TOTP
console.log('WebAuthn not supported');
}
Common Issues
”Localhost not secure” Error
In development, set:
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True
Credentials Not Working Across Subdomains
Set the RP ID to the parent domain:
class CustomMFAAdapter(DefaultMFAAdapter):
def get_public_key_credential_rp_entity(self):
return {
"id": "example.com", # Works for *.example.com
"name": "Example"
}
User Verification Fails
Some authenticators don’t support user verification. For non-passwordless flows, use:
user_verification=UserVerificationRequirement.DISCOURAGED
Testing
WebAuthn requires HTTPS or localhost. For testing:
- Use
python manage.py runserver (localhost)
- Or use a tool like
ngrok for HTTPS tunneling
- Or configure
MFA_WEBAUTHN_ALLOW_INSECURE_ORIGIN = True
Virtual Authenticators
Chrome DevTools supports virtual authenticators for testing:
- Open DevTools → More Tools → WebAuthn
- Enable virtual authenticator environment
- Add a virtual authenticator
- Test WebAuthn flows without physical hardware