By Sagar Shankaran, Founder of CallSphere
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.
Key takeaways
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.
# 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},
}
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.
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"}
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
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.
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.
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.
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

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
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.
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.
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.
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.
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.
Head-to-head: OpenAI Frontier and Anthropic's managed agent stack — strengths, fit, and what each means for enterprise AI voice and chat deployment.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.
Try Live DemoBook a DemoCalculate Your ROI