Your team has approved passwordless authentication, choosing FIDO2. Now, you need a repeatable enrollment process that works for 500 users or 5,000.
This script automates passkey registration while maintaining security controls. It handles enrollment invitations, tracks completion status, and enforces your registration deadline. You can run it daily during your migration window without re-inviting users who've already registered.
Script Overview
The script manages FIDO2 credential enrollment across your user population. It queries your identity provider for users who haven't registered a passkey, sends enrollment invitations through your preferred channel, and logs completion status. You'll get a daily report showing who's enrolled, who's pending, and who needs follow-up.
This isn't a full IAM rollout automation. It's the enrollment orchestration piece that most organizations build manually with spreadsheets and reminder emails. If you're migrating from passwords to passkeys, you need this workflow whether you're using Okta, Entra ID, or another FIDO2-compatible provider.
Prerequisites
Before running this script:
- Your identity provider must support FIDO2 WebAuthn enrollment APIs.
- You need API credentials with user read and enrollment write permissions.
- Your user directory must include email addresses or mobile numbers for invitation delivery.
- You've defined your enrollment window (recommend 30-90 days for initial rollout).
- You have a communication template explaining what passkeys are and why users need to register.
Technical requirements:
- Python 3.8 or later
- Requests library (
pip install requests) - Access to your IdP's API documentation for endpoint URLs
- SMTP credentials if you're sending email invitations directly
The Script
#!/usr/bin/env python3
"""
FIDO2 Passkey Enrollment Orchestration
Automates user invitation and tracks registration completion
"""
import requests
import json
import csv
from datetime import datetime, timedelta
import os
# Configuration
IDP_API_BASE = os.getenv('IDP_API_URL') # Your IdP API endpoint
API_TOKEN = os.getenv('IDP_API_TOKEN') # Service account token
ENROLLMENT_DEADLINE = datetime.now() + timedelta(days=30)
OUTPUT_DIR = './enrollment_reports'
INVITATION_TEMPLATE = 'passkey_invitation.html'
# API headers
HEADERS = {
'Authorization': f'Bearer {API_TOKEN}',
'Content-Type': 'application/json'
}
def get_users_without_passkeys():
"""Query IdP for users lacking FIDO2 credentials"""
endpoint = f'{IDP_API_BASE}/users'
params = {
'filter': 'credentials.provider eq "FIDO2"',
'limit': 200
}
all_users = []
next_page = None
while True:
if next_page:
params['after'] = next_page
response = requests.get(endpoint, headers=HEADERS, params=params)
response.raise_for_status()
data = response.json()
# Filter for users WITHOUT passkeys
users_needing_enrollment = [
u for u in data.get('users', [])
if not any(c.get('provider') == 'FIDO2' for c in u.get('credentials', []))
]
all_users.extend(users_needing_enrollment)
# Check for pagination
next_page = data.get('_links', {}).get('next')
if not next_page:
break
return all_users
def send_enrollment_invitation(user):
"""Trigger enrollment flow for specific user"""
endpoint = f'{IDP_API_BASE}/users/{user["id"]}/credentials/enroll'
payload = {
'credentialType': 'FIDO2',
'sendEmail': True,
'expiresAt': ENROLLMENT_DEADLINE.isoformat()
}
response = requests.post(endpoint, headers=HEADERS, json=payload)
response.raise_for_status()
return response.json().get('enrollmentUrl')
def check_enrollment_status(user_id):
"""Verify if user has completed passkey registration"""
endpoint = f'{IDP_API_BASE}/users/{user_id}/credentials'
response = requests.get(endpoint, headers=HEADERS)
response.raise_for_status()
credentials = response.json()
return any(c.get('provider') == 'FIDO2' for c in credentials)
def generate_report(results):
"""Create CSV report of enrollment status"""
os.makedirs(OUTPUT_DIR, exist_ok=True)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
report_path = f'{OUTPUT_DIR}/enrollment_status_{timestamp}.csv'
with open(report_path, 'w', newline='') as f:
writer = csv.DictWriter(f, fieldnames=[
'user_id', 'email', 'invitation_sent', 'enrollment_complete', 'timestamp'
])
writer.writeheader()
writer.writerows(results)
return report_path
def main():
"""Execute enrollment workflow"""
print(f'Starting FIDO2 enrollment check at {datetime.now()}')
# Get users needing passkeys
users = get_users_without_passkeys()
print(f'Found {len(users)} users without passkeys')
results = []
for user in users:
user_result = {
'user_id': user['id'],
'email': user.get('profile', {}).get('email'),
'invitation_sent': False,
'enrollment_complete': False,
'timestamp': datetime.now().isoformat()
}
try:
# Send invitation
enrollment_url = send_enrollment_invitation(user)
user_result['invitation_sent'] = True
print(f'Sent invitation to {user_result["email"]}')
# Note: Actual enrollment happens asynchronously
# This script tracks invitation delivery, not immediate completion
except requests.exceptions.HTTPError as e:
print(f'Error inviting {user_result["email"]}: {e}')
results.append(user_result)
# Generate report
report_path = generate_report(results)
print(f'Enrollment report saved to {report_path}')
# Summary statistics
invited = sum(1 for r in results if r['invitation_sent'])
print(f'\nSummary:')
print(f'Total users needing enrollment: {len(results)}')
print(f'Invitations sent: {invited}')
print(f'Enrollment deadline: {ENROLLMENT_DEADLINE.strftime("%Y-%m-%d")}')
if __name__ == '__main__':
main()
Customizing the Script
Identity provider endpoints: Replace IDP_API_BASE and authentication method with your provider's specifics. Okta uses /api/v1/users, Entra ID uses Microsoft Graph, Auth0 has its own structure. Check your provider's FIDO2 enrollment documentation.
Filtering logic: The get_users_without_passkeys() function uses a generic filter. Your IdP might use different query syntax. Some providers require you to fetch all users and filter client-side. Adjust the credential type check if your provider labels FIDO2 differently (some use "webauthn" or "security_key").
Invitation delivery: This script relies on your IdP's built-in email delivery (sendEmail: True). If you want custom messaging, set that to False and add your own SMTP logic using the returned enrollmentUrl. You'll need the smtplib library and your mail server credentials.
Enrollment deadline: The 30-day window is arbitrary. Adjust timedelta(days=30) based on your change management timeline. Shorter windows create urgency but increase support load. Longer windows reduce pressure but extend your password debt exposure.
Batch size: The script processes 200 users per API call. If you're enrolling tens of thousands of users, add rate limiting with time.sleep() between batches to avoid hitting API quotas.
Scheduling: Run this daily via cron during your enrollment window. After the deadline, switch to weekly runs to catch new hires and previously inactive accounts.
Validation Steps
Test with a pilot group first: Create a test organizational unit with 10-20 users. Run the script against only that group by modifying the user query filter. Verify invitations arrive and enrollment URLs work before expanding to your full population.
Check API permissions: Run get_users_without_passkeys() manually in a Python shell. If you get authentication errors, your service account needs broader read permissions. If enrollment invitations fail, you're missing credential write scope.
Verify credential detection: After a test user completes enrollment, run check_enrollment_status() for their user ID. It should return True. If it returns False despite successful registration, your credential type filter is wrong.
Monitor invitation delivery: Check your IdP's email logs or your SMTP server logs. Invitations should arrive within minutes. If delivery fails, verify your sender domain isn't blocked and your template doesn't trigger spam filters.
Track completion rates: After three days, compare your CSV reports. You should see 40-60% enrollment in the first week for engaged user populations. Below 30% suggests unclear messaging or technical friction. Above 70% means your communication strategy is working.
Handle edge cases: Some users will be on leave, some will have lost their devices, some will refuse. Build a manual enrollment process for these cases. Don't let 5% of holdouts block your deadline.
Password debt accumulates every day you delay. This script doesn't eliminate it overnight, but it gives you the orchestration framework to execute your migration systematically.




