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.
Implementing Magic Links in FastAPI
Start with the token generation and storage:
# 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
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.
See AI Voice Agents Handle Real Calls
Book a free demo or calculate how much you can save with AI voice automation.
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:
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
CallSphere Team
Expert insights on AI voice agents and customer communication automation.
Try CallSphere AI Voice Agents
See how AI voice agents work for your industry. Live demo available -- no signup required.