← Back to blog

Magic Link Login in FastAPI (2026) with a Plain HTTP Email API

Build passwordless magic-link login in FastAPI: Pydantic models, hashed tokens, httpx.AsyncClient to POST /emails, Idempotency-Key, and click tracking off for security URLs.

Updated November 4, 2026
Magic Link Login in FastAPI (2026) with a Plain HTTP Email API

Why FastAPI + a plain HTTP email API

FastAPI already gives passwordless login a clean shape: Pydantic request models, APIRouter handlers for “email me a link” and “redeem this token,” and async outbound calls with httpx.AsyncClient. You do not need a Rovela Python SDK, a FastAPI “magic login” plugin, or a special product endpoint for magic links.

What you need is the same token recipe as password reset links: your app creates a single-use token, stores a hash, puts the raw token only in the email URL, and POST /emails delivers the message. Rovela queues and DKIM-signs; it does not mint sessions or validate tokens.

Rovela does not provide a magic-link feature. Your FastAPI app owns cryptography, the database row, expiry, single-use redeem, and the session cookie / JWT. Rovela only delivers the mail.

This article is FastAPI + Python focused (httpx, Pydantic, route handlers). For Nest modules, see Magic Link Login in NestJS. For App Router Route Handlers, see Magic Link Login in Next.js. For the product-agnostic recipe, see Magic Login Email Links. For code-vs-link product choice, see Email verification: code vs link. Generic Python send patterns live in Send email with Python—this post does not rehash that.

What you own vs what Rovela sends

Your FastAPI app owns:

  • secrets.token_urlsafe(32) (or 32 random bytes → URL-safe) as the raw token
  • SHA-256 hex of the token in your database (never store the raw token)
  • Expiry (the docs example uses ~30 minutes for reset-style links)
  • Absolute URL on your origin: https://app.example.com/auth/magic?token=…
  • Atomic redeem (delete-and-return) + session / JWT issuance
  • Identical API responses whether or not the email is registered
  • Rate limits on request and redeem

Rovela owns:

  • POST https://api.rovela.dev/emails with Authorization: Bearer + a sending_access key (ROVELA_API_KEY in env—never commit it, never expose it to the browser or a public OpenAPI “try it”)
  • Queueing, signing, retries, bounce/complaint handling

A 200 from the send endpoint means accepted and queued, not “in the inbox.”

Settings and Pydantic models

Keep secrets in environment / a settings object—not in request bodies:

# app/settings.py
from pydantic_settings import BaseSettings, SettingsConfigDict

class Settings(BaseSettings):
    model_config = SettingsConfigDict(env_file=".env", extra="ignore")

    rovela_api_key: str  # ROVELA_API_KEY — sending_access
    magic_from: str  # e.g. "Acme <security@mail.acme.com>"
    magic_link_base_url: str  # e.g. "https://app.example.com"
    magic_ttl_minutes: int = 30

settings = Settings()
# app/schemas/magic.py
from pydantic import BaseModel, EmailStr, Field

class MagicRequestIn(BaseModel):
    email: EmailStr

class MagicRequestOut(BaseModel):
    message: str = (
        "If that address has an account, a sign-in link is on its way."
    )

class MagicRedeemIn(BaseModel):
    token: str = Field(min_length=16, max_length=256)

class MagicRedeemOut(BaseModel):
    ok: bool = True

Wire ROVELA_API_KEY, MAGIC_FROM, and MAGIC_LINK_BASE_URL the same way you wire any other server secret in FastAPI.

Token helper (32 bytes, hash only, short TTL)

# app/auth/magic_token.py
import hashlib
import secrets
from datetime import datetime, timedelta, timezone

def create_magic_token(ttl_minutes: int = 30) -> tuple[str, str, datetime]:
    token = secrets.token_urlsafe(32)
    token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
    expires_at = datetime.now(timezone.utc) + timedelta(minutes=ttl_minutes)
    return token, token_hash, expires_at

Upsert by user: replace any previous unused link so only the latest email works. Plain SHA-256 is enough for a high-entropy URL-safe token (unlike six-digit OTPs, which need HMAC + pepper—see verification codes).

Service: create, store, send via httpx.AsyncClient

# app/auth/magic_service.py
import hashlib
import logging
from typing import Optional

import httpx

from app.auth.magic_token import create_magic_token
from app.settings import settings
# from app.db import users, magic_logins  # your store

log = logging.getLogger("magic")

SAFE_MSG = "If that address has an account, a sign-in link is on its way."

async def request_link(raw_email: str) -> dict:
    email = raw_email.strip().lower()
    user = await users.find_by_email(email)

    if user:
        token, token_hash, expires_at = create_magic_token(settings.magic_ttl_minutes)
        await magic_logins.upsert(
            user_id=user.id, token_hash=token_hash, expires_at=expires_at
        )
        link = f"{settings.magic_link_base_url.rstrip('/')}/auth/magic?token={token}"
        await send_magic_email(email, link, token_hash)

    return {"message": SAFE_MSG}


async def redeem(token: str) -> Optional[str]:
    if not token or not isinstance(token, str):
        return None
    token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
    # Spend in one step so two parallel clicks cannot both succeed.
    return await magic_logins.consume_if_valid(token_hash)  # user_id or None


async def send_magic_email(to: str, link: str, token_hash: str) -> None:
    headers = {
        "Authorization": f"Bearer {settings.rovela_api_key}",
        "Content-Type": "application/json",
        "Idempotency-Key": f"magic-{token_hash[:32]}",
    }
    body = {
        "from": settings.magic_from,
        "to": to,
        "subject": "Your Acme sign-in link",
        "html": (
            "<p>Use this link to sign in to Acme.</p>"
            f'<p><a href="{link}">Sign in</a></p>'
            "<p>The link works once and expires in 30 minutes. "
            "If you did not request it, ignore this email.</p>"
        ),
        "text": (
            "Use this link to sign in to Acme.\n\n"
            f"Sign in: {link}\n\n"
            "The link works once and expires in 30 minutes."
        ),
    }

    async with httpx.AsyncClient(timeout=20.0) as client:
        res = await client.post(
            "https://api.rovela.dev/emails",
            headers=headers,
            json=body,
        )

    if res.status_code >= 400:
        # Log server-side; still return the same client message from request_link.
        log.error("rovela send failed %s %s", res.status_code, res.text)

Always send both html and text, with the full URL in the text part. Use a verified subdomain for auth (security@mail.…). Derive Idempotency-Key from the token hash so a timeout retry does not enqueue a second message; a deliberate “resend” means a new token and a new key (API: emails, Authentication).

Reuse one long-lived httpx.AsyncClient on app lifespan if you send mail often—the snippet above uses a short-lived client for clarity.

Route handlers: request + redeem

# app/routers/magic.py
from fastapi import APIRouter, HTTPException, Response, status

from app.auth import magic_service
from app.schemas.magic import (
    MagicRedeemIn,
    MagicRedeemOut,
    MagicRequestIn,
    MagicRequestOut,
)
# from app.sessions import create_session_cookie

router = APIRouter(prefix="/auth/magic", tags=["auth"])


@router.post("/request", response_model=MagicRequestOut)
async def magic_request(payload: MagicRequestIn) -> MagicRequestOut:
    # Apply rate limiting here (IP + email): slowapi, a reverse proxy, or Redis.
    data = await magic_service.request_link(payload.email)
    return MagicRequestOut(**data)


@router.post("/redeem", response_model=MagicRedeemOut)
async def magic_redeem(payload: MagicRedeemIn, response: Response) -> MagicRedeemOut:
    user_id = await magic_service.redeem(payload.token)
    if not user_id:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="expired_or_used",
        )
    cookie = await create_session_cookie(user_id)
    response.set_cookie(
        key="session",
        value=cookie,
        httponly=True,
        secure=True,
        samesite="lax",
        max_age=60 * 60 * 24 * 14,
    )
    return MagicRedeemOut(ok=True)

Mount the router on your app:

# app/main.py
from fastapi import FastAPI
from app.routers.magic import router as magic_router

app = FastAPI()
app.include_router(magic_router)

Serve a page at /auth/magic that reads ?token= and POSTs JSON { "token": "…" } to /auth/magic/redeem. On failure, show a generic “link expired or already used” UI and a button to request a new link—do not distinguish unknown vs spent vs expired if that helps an attacker.

Prefer POST redeem over creating a session on GET: email scanners and link prefetchers often hit GET URLs. If you must support a GET landing page, show “Continue” and only issue the session after an explicit user action.

For ownership confirmation (not login), the same service shape applies with a different table and path—see Email verification links. Password reset is the docs twin: Secure password reset email.

Keep click tracking off for the login URL

Open/click tracking is a per-domain setting. When click tracking is on, Rovela rewrites links through a tracking host. The login still works after the redirect, but the token takes an extra hop.

For magic links (and password resets / invite tokens), prefer a separate sending domain or subdomain with click tracking off, so the token goes straight to your FastAPI app. That is the same guidance as the password-reset example. If auth mail still lands in spam, use the checklist in Why OTP and Magic-Link Emails Go to Spam.

Rate limit / single-use / expiry checklist

  1. Issue throttle — e.g. one new link per email per minute; also cap by IP.
  2. Short TTL — ~15–30 minutes for login; shorter is fine for step-up.
  3. Hash at rest — store SHA-256 hex only; raw token lives in the email URL.
  4. Upsert — new request invalidates the previous unused hash for that user.
  5. Consume atomically — DELETE … WHERE hash AND expires_at > now() RETURNING (or equivalent) so double-click races fail safely.
  6. Enumeration-safe request — same status and body whether the address exists.
  7. Idempotency-Key — stable per logical send (magic- + hash prefix); new token → new key.
  8. Prefetch-safe redeem — prefer POST redeem (as above); if you must support GET, show “Continue” without creating a session until an explicit action.

curl equivalent (agents / smoke tests)

Same wire format your FastAPI service posts—useful for local smoke tests and AI agents that call HTTP directly (AI agent send email):

curl -X POST https://api.rovela.dev/emails \
  -H "Authorization: Bearer re_your_sending_key" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: magic-smoke-2026-11-05" \
  -d '{
    "from": "Acme <security@mail.acme.com>",
    "to": "alice@example.com",
    "subject": "Your Acme sign-in link",
    "html": "<p><a href=\"https://app.acme.com/auth/magic?token=TEST\">Sign in</a></p><p>Expires in 30 minutes.</p>",
    "text": "Sign in: https://app.acme.com/auth/magic?token=TEST\n\nExpires in 30 minutes."
  }'

Replace TEST with a real token only in non-production. Production keys need sending_access; from must be on a verified domain (Quick Start).

Magic link vs OTP in the same FastAPI app

Magic linkOTP (six digits)
Docs shapeSame as password resetVerification codes
FastAPI placementmagic_service + /auth/magic/*Separate otp_service + routes
UXOne clickType digits
StorageSHA-256 of high-entropy tokenHMAC of email:code + pepper
Typical TTL~30 minutes~10 minutes
Good forDesktop/mobile browser sign-inShared screens, step-up

Both call the same POST /emails. Keep separate tables and services—do not reuse an OTP row as a magic-link token. Product choice: code vs link.

Deploy checklist

  1. Domain verified; SPF/DKIM/DMARC green for the auth subdomain.
  2. Click tracking off on that subdomain (or a dedicated security domain).
  3. ROVELA_API_KEY with sending_access only in server env / secrets manager—never in a SPA bundle or public docs “try it.”
  4. MAGIC_FROM uses the verified subdomain; html + text both present; full URL in text.
  5. Idempotency-Key on every logical send; redeem is delete-and-return.
  6. Enumeration-safe request response; rate-limit request (and redeem).
  7. Treat send 200 as queued; use webhooks for bounces/complaints if you need delivery truth (Webhooks).

What this post is not

  • Not a full production auth framework (OAuth2, refresh rotation, MFA).
  • Not a duplicate of the NestJS, Next.js, or generic magic login posts—those cover Nest modules, App Router, and framework-agnostic flows.
  • Not a rehash of generic Python send email tutorials.
  • Not a claim that Rovela stores, validates, or expires your tokens.
  • Not China-domestic ESP comparison or invented product features.

Next steps

  1. Implement token + FastAPI service from the password-reset example.
  2. Send with POST /emails · Quick Start · Authentication.
  3. Harden deliverability: OTP / magic-link spam.
  4. Compare UX primitives: code vs link.
  5. Sibling stacks: NestJS magic link · Next.js magic link.
  6. Limits: Pricing.

Bottom line: put magic login in FastAPI route handlers that hash tokens and call one httpx POST to Rovela—keep click tracking off on the security domain, and never pretend the email API is an identity provider.