Skip to main content
green gradient background, "The Future of Application Security Is Already Here." and a read the report button.
FIDO2 Passkey Enrollment ScriptFIDO & Passkeys
5 min readFor PAM Engineers

FIDO2 Passkey Enrollment Script

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.

Promotional banner for the Penetration Report Template Kit

You Might Also Like