---
title: "Implementing Passwordless Auth for AI Agent Platforms: Magic Links and Passkeys"
description: "Build passwordless authentication for AI agent platforms using magic links and WebAuthn passkeys. Covers the complete flow from email-based login to biometric authentication with FastAPI implementation."
canonical: https://callsphere.ai/blog/implementing-passwordless-auth-ai-agent-platforms-magic-links-passkeys
category: "Learn Agentic AI"
tags: ["Passwordless", "WebAuthn", "Passkeys", "Magic Links", "FastAPI", "AI Agents"]
author: "CallSphere Team"
published: 2026-03-17T00:00:00.000Z
updated: 2026-05-20T19:32:56.236Z
---

# Implementing Passwordless Auth for AI Agent Platforms: Magic Links and Passkeys

> Build passwordless authentication for AI agent platforms using magic links and WebAuthn passkeys. Covers the complete flow from email-based login to biometric authentication with FastAPI implementation.

## Why Passwordless for AI Agent Platforms

Passwords are the leading cause of security breaches. Users reuse them across services, choose weak ones, and fall for phishing attacks. For AI agent platforms where users may grant agents access to sensitive tools and data, the authentication layer must be stronger than a password that might be "password123" in a credential dump.

Passwordless authentication eliminates these risks entirely. Magic links deliver one-time login tokens via email — there is no password to steal, reuse, or phish. Passkeys use public-key cryptography with biometric verification, providing phishing-resistant authentication that is also faster and more convenient than typing a password.

## Magic Link Authentication Flow

The magic link flow works in four steps: the user enters their email, the server generates a cryptographically random token with a short expiration, sends it as a link in an email, and when the user clicks the link, the server validates the token and issues a session.

```mermaid
flowchart LR
    CLIENT(["Client SDK"])
    GW["API Gateway
auth plus rate limit"]
    APP["FastAPI app
handlers and DI"]
    VAL["Pydantic validation"]
    SVC["Service layer
business logic"]
    DB[(Database)]
    QUEUE[(Background queue)]
    OBS[(Tracing)]
    CLIENT --> GW --> APP --> VAL --> SVC
    SVC --> DB
    SVC --> QUEUE
    SVC --> OBS
    SVC --> CLIENT
    style GW fill:#4f46e5,stroke:#4338ca,color:#fff
    style APP fill:#f59e0b,stroke:#d97706,color:#1f2937
    style DB fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
```

## Implementing Magic Links in FastAPI

Start with the token generation and storage:

```python
# auth/magic_links.py
import secrets
import hashlib
from datetime import datetime, timezone, timedelta
import redis.asyncio as redis

redis_client = redis.from_url("redis://localhost:6379/0")

MAGIC_LINK_TTL_MINUTES = 10
MAGIC_LINK_PREFIX = "magic_link:"

async def create_magic_link(email: str) -> str:
    """Generate a magic link token and store it."""
    token = secrets.token_urlsafe(32)
    token_hash = hashlib.sha256(token.encode()).hexdigest()

    # Store the hash -> email mapping
    await redis_client.setex(
        f"{MAGIC_LINK_PREFIX}{token_hash}",
        MAGIC_LINK_TTL_MINUTES * 60,
        email,
    )

    # Rate limit: max 5 magic links per email per hour
    rate_key = f"magic_link_rate:{email}"
    count = await redis_client.incr(rate_key)
    if count == 1:
        await redis_client.expire(rate_key, 3600)
    if count > 5:
        await redis_client.delete(f"{MAGIC_LINK_PREFIX}{token_hash}")
        raise ValueError("Too many login attempts. Try again later.")

    return token

async def verify_magic_link(token: str) -> str | None:
    """Verify a magic link token and return the email. Single use."""
    token_hash = hashlib.sha256(token.encode()).hexdigest()
    key = f"{MAGIC_LINK_PREFIX}{token_hash}"

    # Atomic get-and-delete to prevent reuse
    pipe = redis_client.pipeline()
    pipe.get(key)
    pipe.delete(key)
    results = await pipe.execute()

    email = results[0]
    return email.decode() if email else None
```

Notice the security measures: the token is hashed before storage so a Redis compromise does not leak valid tokens. The verification is atomic (get then delete in a pipeline) so the token cannot be used twice. Rate limiting prevents an attacker from flooding an email inbox.

## The Magic Link API Endpoints

```python
from fastapi import APIRouter, HTTPException, BackgroundTasks
from pydantic import BaseModel, EmailStr

router = APIRouter(prefix="/auth")

class MagicLinkRequest(BaseModel):
    email: EmailStr

class MagicLinkVerify(BaseModel):
    token: str

@router.post("/magic-link")
async def request_magic_link(
    body: MagicLinkRequest,
    background_tasks: BackgroundTasks,
):
    try:
        token = await create_magic_link(body.email)
    except ValueError as e:
        raise HTTPException(status_code=429, detail=str(e))

    login_url = f"https://app.example.com/auth/verify?token={token}"

    # Send email in background — never block the response
    background_tasks.add_task(
        send_login_email,
        to=body.email,
        login_url=login_url,
    )

    # Always return success even if email does not exist
    # This prevents email enumeration attacks
    return {"message": "If an account exists, a login link has been sent"}

@router.post("/magic-link/verify")
async def verify_magic_link_endpoint(body: MagicLinkVerify):
    email = await verify_magic_link(body.token)
    if not email:
        raise HTTPException(status_code=401, detail="Invalid or expired link")

    # Find or create user
    user = await get_or_create_user(email)

    # Issue JWT tokens
    token_payload = TokenPayload(
        sub=user.id,
        org_id=user.org_id,
        role=user.role,
        scopes=user.scopes,
    )

    return {
        "access_token": create_access_token(token_payload),
        "refresh_token": create_refresh_token(token_payload),
        "user": {"id": user.id, "email": user.email, "name": user.name},
    }
```

## WebAuthn and Passkeys

Passkeys represent the future of authentication. They use public-key cryptography where the private key never leaves the user's device. The authenticator (device biometrics, security key, or phone) signs a challenge, and the server verifies the signature using the stored public key. There is nothing to phish because the credential is bound to the origin domain.

## Passkey Registration Flow

Implement the WebAuthn registration ceremony with the `py_webauthn` library:

```python
# auth/passkeys.py
import json
from webauthn import (
    generate_registration_options,
    verify_registration_response,
    generate_authentication_options,
    verify_authentication_response,
)
from webauthn.helpers.structs import (
    AuthenticatorSelectionCriteria,
    ResidentKeyRequirement,
    UserVerificationRequirement,
    PublicKeyCredentialDescriptor,
)
from webauthn.helpers import bytes_to_base64url

RP_ID = "app.example.com"
RP_NAME = "AI Agent Platform"
ORIGIN = "https://app.example.com"

# Store challenges temporarily in Redis
CHALLENGE_PREFIX = "webauthn_challenge:"

async def start_registration(user_id: str, user_email: str):
    options = generate_registration_options(
        rp_id=RP_ID,
        rp_name=RP_NAME,
        user_id=user_id.encode(),
        user_name=user_email,
        authenticator_selection=AuthenticatorSelectionCriteria(
            resident_key=ResidentKeyRequirement.REQUIRED,
            user_verification=UserVerificationRequirement.REQUIRED,
        ),
    )

    # Store challenge for verification
    await redis_client.setex(
        f"{CHALLENGE_PREFIX}{user_id}",
        300,  # 5 minutes
        bytes_to_base64url(options.challenge),
    )

    return options

async def complete_registration(user_id: str, credential_response: dict):
    challenge_b64 = await redis_client.get(f"{CHALLENGE_PREFIX}{user_id}")
    if not challenge_b64:
        raise ValueError("Registration challenge expired")

    verification = verify_registration_response(
        credential=credential_response,
        expected_challenge=challenge_b64,
        expected_rp_id=RP_ID,
        expected_origin=ORIGIN,
    )

    # Store the credential public key
    await store_passkey(
        user_id=user_id,
        credential_id=verification.credential_id,
        public_key=verification.credential_public_key,
        sign_count=verification.sign_count,
    )

    return {"status": "registered"}
```

## Passkey Authentication Flow

```python
async def start_authentication(user_id: str | None = None):
    """Start passkey authentication. If user_id is None, allow discoverable credentials."""
    existing_credentials = []
    if user_id:
        passkeys = await get_user_passkeys(user_id)
        existing_credentials = [
            PublicKeyCredentialDescriptor(id=pk.credential_id)
            for pk in passkeys
        ]

    options = generate_authentication_options(
        rp_id=RP_ID,
        allow_credentials=existing_credentials,
        user_verification=UserVerificationRequirement.REQUIRED,
    )

    challenge_key = f"{CHALLENGE_PREFIX}auth:{user_id or 'discoverable'}"
    await redis_client.setex(
        challenge_key, 300, bytes_to_base64url(options.challenge),
    )
    return options
```

## Fallback Strategy

No single authentication method works for every user and every situation. Build a fallback chain:

```python
AUTH_METHODS = {
    "passkey": {"priority": 1, "phishing_resistant": True},
    "magic_link": {"priority": 2, "phishing_resistant": False},
    "totp": {"priority": 3, "phishing_resistant": False},
}

@router.get("/auth/methods")
async def get_available_methods(email: str):
    user = await get_user_by_email(email)
    if not user:
        # Return generic methods to prevent enumeration
        return {"methods": ["magic_link"]}

    methods = ["magic_link"]  # Always available

    if await user_has_passkeys(user.id):
        methods.insert(0, "passkey")

    if user.totp_enabled:
        methods.append("totp")

    return {"methods": methods}
```

This ensures that users who have registered passkeys get the strongest authentication first, while all users can always fall back to magic links. There is no password in the chain at all.

## FAQ

### Are magic links secure enough for production AI agent platforms?

Magic links are significantly more secure than passwords because they eliminate credential reuse, phishing of stored credentials, and brute force attacks. The main risk is email account compromise — if an attacker controls the user's email, they can intercept magic links. Mitigate this by keeping token TTLs short (ten minutes), allowing single use only, and encouraging users to register passkeys as a more secure primary method.

### How do passkeys work across multiple devices?

Modern passkey implementations sync across devices through the platform's cloud account — Apple Keychain, Google Password Manager, or a password manager like 1Password. When a user registers a passkey on their iPhone, it becomes available on their Mac and iPad automatically. For cross-platform scenarios (registering on Apple, logging in on Windows), the user can scan a QR code with their phone to authenticate via Bluetooth proximity.

### What happens if a user loses access to their email and their passkey device?

This is the account recovery problem that every passwordless system must solve. Implement a recovery flow that requires identity verification: a recovery code generated at sign-up (stored securely by the user), admin-initiated account recovery with identity verification, or a secondary email address. Make the recovery code generation mandatory during onboarding and explain its importance clearly. Store recovery codes hashed, just like API keys.

---

#Passwordless #WebAuthn #Passkeys #MagicLinks #FastAPI #AIAgents #AgenticAI #LearnAI #AIEngineering

---

Source: https://callsphere.ai/blog/implementing-passwordless-auth-ai-agent-platforms-magic-links-passkeys
