Skip to content
Implementing Passwordless Auth for AI Agent Platforms: Magic Links and Passkeys
Learn Agentic AI13 min read10 views

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.

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.

flowchart LR
    CLIENT(["Client SDK"])
    GW["API Gateway<br/>auth plus rate limit"]
    APP["FastAPI app<br/>handlers and DI"]
    VAL["Pydantic validation"]
    SVC["Service layer<br/>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

Start with the token generation and storage:

Hear it before you finish reading

Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.

Try Live Demo →
# 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.

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:

# 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

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:

Still reading? Stop comparing — try CallSphere live.

CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.

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

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

Share

Try CallSphere AI Voice Agents

See how AI voice agents work for your industry. Live demo available -- no signup required.

Related Articles You May Like

AI Agents

Personal AI Assistant: How to Pick One for Business in 2026

A founder's guide to the personal AI assistant market: best AI assistant apps, business-grade options, and how CallSphere's voice agent fits in.

AI Agents

Free AI Agents in 2026: When Free Wins and When It Costs You

A founder's guide to free AI agents, low-code AI agent builders, and how to know when you should pay for a real platform like CallSphere.

Agentic AI

Graphiti: How Temporal Knowledge Graphs Give AI Voice Agents Persistent Memory (2026 Guide)

Graphiti is the open-source temporal knowledge graph for AI agents in 2026. Learn how bi-temporal memory beats vector RAG for voice agents and long-running LLMs.

AI Agents

Chatbot App vs ChatGPT: What's the Difference, and Which Do I Need?

Chatbot app vs ChatGPT in 2026: a founder's clear take on the difference, when to use which, and how a real AI chatbot app development works.

HVAC

Building an HVAC After-Hours Emergency Escalation System: A Complete Engineering Guide

How we built a fault-tolerant HVAC emergency triage and tech-dispatch platform on Kubernetes — three-tier CQRS, 11 micro-agents on the OpenAI Agents SDK + LangGraph, NATS JetStream, DTMF/SMS/WebSocket acceptance, circuit breakers, and an evaluation pipeline that catches regressions before they wake a tech at 3 AM.

Enterprise AI

OpenAI Frontier vs Anthropic Managed Agents: 2026 Comparison

Head-to-head: OpenAI Frontier and Anthropic's managed agent stack — strengths, fit, and what each means for enterprise AI voice and chat deployment.