Authentication
Aug 21, 2025

FastAPI Passwordless: Magic link and OTP implementation

Srinivas Karre
Founding Engineer

TL;DR:

  • Headless control means Scalekit’s API-first model lets FastAPI own templates, copy, and routing logic without relying on a vendor UI.
  • Server-native security ensures secrets stay in environment variables, required fields are validated at startup, and tokens never leave the server.
  • Config per environment lets development, staging, and production load their own .env files, with HTTPS enforced outside dev.
  • Magic Link + OTP reliability provides fallback when links expire, devices switch, or parameters get stripped.
  • Same-browser enforcement prevents token replay by binding verification to the original auth_request_id.
  • Operational switches like expiries, resend windows, and attempt caps are managed in the Scalekit dashboard without code changes or redeploys.

Why a custom authentication flow became a nightmare

Technical debt in authentication stalled a hypothetical team using Python after a clean split to FastAPI services on Postgres. Product required fully branded emails and dual-path credentials, magic links (signed, short-lived tokens delivered by email) and OTPs (time-boxed numeric codes tied to the same request), while keeping the existing users table as the source of truth with no data migration or downtime.

Security required short-lived tokens, strict rate limits, same-browser enforcement (verification must occur from the initiating browser), and server-only verification with a CSRF-style state (a random value bound to the request and verified on callback). Client-heavy SDKs were a firm anti-pattern: secrets drifted into localStorage, tokens were handled in the browser, and policies couldn’t be enforced. Some mail clients and link scanners rewrite or strip query parameters, which is a real vulnerability if verification trusts client-supplied URLs.  

A headless, server-native approach with Scalekit + FastAPI fixed this. Scalekit issues, sends, and verifies credentials and enforces policy, delivering branded emails via your configured sender (e.g., SES/SendGrid) while FastAPI keeps all sensitive steps on the server: async routes request issuance, verify link tokens or OTPs, and set HttpOnly, SameSite=Lax JWT cookies as stateless session tokens (no server session store; the cookie holds signed claims).

Pydantic v2 models make contracts explicit, and FastAPI DI (e.g., ops: ScalekitOps = Depends(get_scalekit_ops) in the endpoint signature) keeps Scalekit calls mockable in tests. FastAPI 0.115+ / Pydantic 2.x compatibility is assumed. Same-browser is honored by round-tripping auth_request_id, and verified emails map to existing rows, no schema change, no data movement.

This guide shows how to build that backend end-to-end. You’ll configure the Python SDK securely, implement async routes for send/verify/resend, define precise models, wire DI for testability, mint and read stateless JWT session cookies correctly, integrate your database without migrating users, exercise flows with pytest, and harden production with rate limits, headers, observability, and clear error mapping. By the end, you’ll own a passwordless core, magic link and OTP, kept entirely server-side, branded to your product, and ready for audits. You can find the full working project that this guide is based on here.

How passwordless authentication maps to Python and FastAPI concepts

Passwordless authentication in Python means issuing and verifying short-lived credentials entirely on the server. A magic link is a signed, opaque token; an OTP is a numeric code tied to the same request. FastAPI calls Scalekit through a small service layer (app/core/scalekit.py), not directly from endpoints, to keep wiring testable. Tokens are short-lived (commonly 5-10 minutes) and verification never trusts client-supplied parameters without server checks. Email templates must not embed secrets; they render only a magic link that contains an opaque, short-lived token generated by Scalekit. JWTs are custom, stateless cookies you sign in Python; FastAPI’s built-in session store is not used.

Server-only verification keeps the attack surface small and the code testable. Async SDK/httpx calls talk to Scalekit; Pydantic v2 models validate inputs/outputs; FastAPI DI (e.g., ops: ScalekitOps = Depends(get_scalekit_ops)) makes the client mockable in pytest. Same-browser enforcement round-trips auth_request_id. A CSRF-style state (a random value bound to the request and checked on callback) protects against confused-deputy flows. After success, Python signs a JWT and sets an HttpOnly, SameSite=Lax cookie; downstream dependencies read it and reject unauthenticated requests early.

Operational controls turn policy into predictable behavior. Expiries, resend windows, and attempt caps live in the Scalekit dashboard; FastAPI mirrors them with clear errors and cooldowns. Structured logs capture send/verify/resend with a correlation id, never OTPs or tokens. Reverse-proxy rate limits and narrow CORS allow-lists harden edges. Environment needs: SCALEKIT_*, BASE_URL, JWT_SECRET. Database needs: a users table you can look up/create by email (no migration required). Testing strategy: pytest with DI overrides, expiry boundary tests, and status mapping for 400/401/429.    

Passwordless concept
Endpoint and request shape
Success (200)
Failure codes (examples)
Magic link verification
GET /api/auth/verify-magic-link?link_token={token}&auth_request_id={id}
Sets HttpOnly cookie; returns { "email": "user@example.com" }
400 token_invalid, 401 same_browser_mismatch
OTP verification
POST /api/auth/verify-otp body { "code": "123456", "authRequestId": "req_..." }
Sets HttpOnly cookie; returns { "email": "user@example.com" }
400 otp_invalid
Resend with cooldown
POST /api/auth/resend-passwordless body { "authRequestId": "req_..." }
Returns { "auth_request_id", "expires_in", "expires_at" }
429 cooldown_active
Session after verification
Cookie access_token (JWT) on subsequent requests
Dependency decodes cookie; protected routes proceed
401 unauthorized
Request/response contracts
Pydantic v2 models (EmailRequest, OTPVerifyRequest, PasswordlessSendResponse, …)
Typed validation reduces 400s; shapes stay consistent across handlers
422 for malformed payloads

What comes next is concrete. You’ll install the Scalekit Python SDK (scalekit-sdk-python), load environment with pydantic-settings, write async FastAPI handlers (FastAPI runs them natively), define Pydantic models, inject dependencies for testability, issue JWT cookies, integrate the database, test with pytest, and deploy with production-grade headers, rate limits, and observability.  

In the following sections, flow diagrams, send, verify link, verify OTP, resend, and session, map the exact paths and status codes.

Implementing passwordless authentication in FastAPI from setup to deployment

FastAPI implementation turns passwordless concepts into testable server code. The sequence starts with secure SDK configuration and environment loading, continues with async route handlers for send, verify, and resend, and formalizes inputs with Pydantic models while dependency injection keeps services swappable. JWT session cookies anchor server-native state, database lookups attach identities without migration, and pytest exercises both magic-link and OTP paths. Production hardening then locks down cookies, rate limits, logging, and monitoring so behavior stays predictable under real traffic and real email clients. With the context set, the next sections move step by step, small, skimmable increments that explain the purpose, key decisions, and common pitfalls, beginning with the Python SDK setup.

Project structure: Here’s the backend-only layout you’ll reference in the steps below:

fastapi-passwordless-auth/
├─ .env.example              # Environment variable template
├─ .env                      # Local environment variables (excluded from VCS)
├─ app/                      # FastAPI backend application
│  ├─ main.py                # App entrypoint (FastAPI instance)
│  ├─ core/                  # Core utilities & integrations
│  │  ├─ jwt.py              # JWT creation/validation helpers
│  │  └─ scalekit.py         # Scalekit SDK wrapper/integration
│  ├─ db/                    # Database/session related code
│  │  └─ session.py          # SQLAlchemy session setup
│  ├─ models/                # Pydantic/data models
│  │  └─ auth.py             # Auth-related models (requests/responses)
│  └─ routes/                # API route definitions
│     └─ auth.py             # Auth endpoints (passwordless flows)
├─ frontend/                 # Frontend (Next.js / React app structure)
│  └─ src/
│     └─ app/                # App router
│        ├─ page.tsx         # Landing / index page
│        ├─ dashboard/
│        │  └─ page.tsx      # Example protected dashboard page
│        └─ verify-magic-link/
│           └─ VerifyMagicLinkPage.tsx  # Magic link verification UI
├─ tests/                    # Pytest test suite
│  └─ test_auth.py           # Auth flow tests
├─ requirements.txt          # Python dependencies
├─ README.md                 # Documentation
└─ venv/                     # (Local) Virtual environment (excluded from VCS)

1) Python SDK setup with secure configuration

Typed settings keep secrets out of code and catch misconfig at startup. Load credentials from environment files, validate required fields, and use a single API base_url so magic-link callbacks always point to your backend (not a UI). We’ll assume FastAPI 0.115+ and Pydantic v2 with pydantic-settings.  

# app/core/settings.py from typing import Literal from pydantic import AnyUrl, SecretStr, ValidationError, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict import os class Settings(BaseSettings): # Environment/runtime app_env: Literal["development", "staging", "production"] = "development" # Scalekit scalekit_environment_url: AnyUrl scalekit_client_id: str scalekit_client_secret: SecretStr # App + JWT base_url: AnyUrl # public origin of this API, e.g., https://api.example.com jwt_secret: SecretStr jwt_days: int = 7 # Policy (override from dashboard when needed) passwordless_expires_seconds: int = 600 # 10 minutes model_config = SettingsConfigDict(env_file=".env", extra="ignore", case_sensitive=False) @field_validator("base_url") @classmethod def enforce_https_in_prod(cls, v: AnyUrl, info): # Require HTTPS in staging/production env = os.getenv("APP_ENV", "development").lower() if env in {"staging", "production"} and v.scheme != "https": raise ValueError("In staging/production, base_url must use https") return v def load_settings() -> "Settings": # Support per-environment files: .env.development / .env.staging / .env.production env = os.getenv("APP_ENV", "development").lower() env_file = f".env.{env}" if os.path.exists(f".env.{env}") else ".env" try: return Settings(_env_file=env_file) # type: ignore[arg-type] except ValidationError as e: # Fail fast with a clear message during boot raise SystemExit(f"[config] Invalid settings from {env_file}:\n{e}") from e settings = load_settings()

Why this matters (brief):

  • Required fields are enforced by Pydantic; the app fails fast if anything is missing or malformed.
  • Secrets use SecretStr so they don’t leak in logs.
  • HTTPS is enforced for base_url in staging/prod.
  • Per-environment files let you run the same code in dev/staging/production without edits.

Example .env.example (check into Git):

# Scalekit SCALEKIT_ENVIRONMENT_URL=your-scalekit-env-url SCALEKIT_CLIENT_ID=your-scalekit-client-id SCALEKIT_CLIENT_SECRET=your-scalekit-client-secret # API + JWT NEXT_PUBLIC_BASE_URL=http://localhost:3000 JWT_SECRET=your_super_secret_jwt_key

Local .env (not committed) mirrors .env.example with real values

For staging/production, prefer .env.staging / .env.production or inject vars from your platform’s secrets manager. Keep APP_ENV aligned so load_settings() picks the right file and HTTPS rule.

Before writing any code, start in the Scalekit dashboard and enable passwordless authentication for your environment.

Enable passwordless on Scalekit dashboard

In Authentication → Passwordless:

  • Select Magic Link + Verification Code (recommended for maximum reliability).
  • Set the Expiry Period (e.g., 600 seconds for a 10-minute link/code lifetime).
  • Enable Enforce same browser origin for phishing resistance.
  • (Optional) Enable Regenerate credentials on resend to invalidate older links/codes if a resend occurs.

Now to setup our python dependencies run the following:

# 1. Create and activate virtual environment python -m venv .venv source .venv/bin/activate # (Windows: .venv\Scripts\activate) # 2. Copy env template and set secrets cp .env.example .env # Add values for SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, SCALEKIT_CLIENT_SECRET, BASE_URL # Generate strong secret: python -c "import secrets; print('JWT_SECRET=' + secrets.token_urlsafe(48))" >> .env # 3. Install dependencies pip install -U pip pip install fastapi uvicorn[standard] pydantic-settings python-jose[cryptography] httpx python-dotenv pip install scalekit-sdk-python

This module wraps the synchronous Scalekit client with async helpers, letting FastAPI safely send, resend, and verify passwordless emails without blocking the event loop:

# app/core/scalekit.py (async wrapper over a sync client) import asyncio import os from dotenv import load_dotenv from scalekit import ScalekitClient load_dotenv() SCALEKIT_ENVIRONMENT_URL = os.getenv("SCALEKIT_ENVIRONMENT_URL") SCALEKIT_CLIENT_ID = os.getenv("SCALEKIT_CLIENT_ID") SCALEKIT_CLIENT_SECRET = os.getenv("SCALEKIT_CLIENT_SECRET") NEXT_PUBLIC_BASE_URL = os.getenv("NEXT_PUBLIC_BASE_URL", "http://localhost:3000") sc = ScalekitClient( SCALEKIT_ENVIRONMENT_URL, SCALEKIT_CLIENT_ID, SCALEKIT_CLIENT_SECRET ) async def send_passwordless_email(email: str, template: str = "SIGNIN", state: str = None, expires_in: int = 300, magiclink_auth_uri: str = None, template_variables: dict = None): loop = asyncio.get_event_loop() kwargs = { "email": email, "template": template, "expires_in": expires_in, } if state: kwargs["state"] = state if magiclink_auth_uri: kwargs["magiclink_auth_uri"] = magiclink_auth_uri if template_variables: kwargs["template_variables"] = template_variables response = await loop.run_in_executor(None, lambda: sc.passwordless.send_passwordless_email(**kwargs)) return response async def resend_passwordless_email(auth_request_id: str): loop = asyncio.get_event_loop() response = await loop.run_in_executor(None, lambda: sc.passwordless.resend_passwordless_email(auth_request_id)) return response async def verify_passwordless_email(code: str = None, link_token: str = None, auth_request_id: str = None): loop = asyncio.get_event_loop() def call(): kwargs = {} if code: kwargs["code"] = code if link_token: kwargs["link_token"] = link_token if auth_request_id: kwargs["auth_request_id"] = auth_request_id return sc.passwordless.verify_passwordless_email(**kwargs) response = await loop.run_in_executor(None, call) return response

Environment cheatsheet:

Name
Example
Purpose
SCALEKIT_ENVIRONMENT_URL
https://env.scalekit.com/abc
API base for your Scalekit environment
SCALEKIT_CLIENT_ID / SCALEKIT_CLIENT_SECRET
skc_live_xxx / ***
Server credentials for auth
BASE_URL
https://api.example.com
Host used in magic-link callbacks
JWT_SECRET
long random string
Signing key for session JWTs

2) Async route handlers for a clean server boundary

Handlers should stay non-blocking and server-only. Your API accepts email, asks Scalekit to issue a credential, and returns only safe metadata. Verification endpoints accept either a magic-link token or an OTP code and set the session cookie.

Send passwordless link_otp via fastapi and scalekit

This sequence shows the send-passwordless flow: the client calls FastAPI with an email, FastAPI validates input and generates a state, then calls Scalekit to issue a credential. Scalekit delivers both a magic link and an OTP through the configured email service. The client finally receives a JSON response with only safe metadata (auth_request_id, expiry), while sensitive tokens stay out of logs and client storage.

Before wiring the FastAPI routes, define strict Pydantic models for requests and responses. These keep email, OTP, and magic-link flows strongly typed and ensure consistent contracts across handlers:

# app/routes/auth.py (excerpt) from pydantic import BaseModel, EmailStr, Field # Request to send passwordless email class EmailRequest(BaseModel): email: EmailStr magiclink_auth_uri: str | None = None # Request to verify OTP class OTPVerifyRequest(BaseModel): code: str auth_request_id: str = Field(..., alias="authRequestId") model_config = { "populate_by_name": True } # Request to verify magic link class MagicLinkVerifyRequest(BaseModel): link_token: str = Field(..., alias="linkToken") auth_request_id: str | None = Field(None, alias="authRequestId") model_config = { "populate_by_name": True } # Response from sending passwordless email class PasswordlessSendResponse(BaseModel): auth_request_id: str expires_at: int expires_in: int passwordless_type: str # Response from verification class PasswordlessVerifyResponse(BaseModel): email: EmailStr state: str = None template: str = None passwordless_type: str = None
Verify magic link with same browser

This sequence shows what happens when a user clicks a magic link. FastAPI extracts the token and request ID, then calls Scalekit to verify. If the browser doesn’t match the original request, the flow fails with 401 same_browser_mismatch. Otherwise, Scalekit returns the verified email, FastAPI looks up (or creates) the user, signs a JWT, and sets it as a secure, HttpOnly cookie. From that point on, authenticated requests carry only the cookie, not the link token.

Define a GET endpoint for verifying magic links: it checks the token with Scalekit, enforces same-browser via auth_request_id, and on success signs a JWT and sets it as an HttpOnly cookie:

@router.get("/verify-magic-link", response_model=PasswordlessVerifyResponse) async def verify_magic_link_get(response: Response, link_token: str = Query(..., alias="link_token"), auth_request_id: str | None = Query(None, alias="auth_request_id")): try: verified = await verify_passwordless_email(link_token=link_token, auth_request_id=auth_request_id) data = verified[0] if isinstance(verified, tuple) else verified token = create_access_token({"sub": getattr(data, "email", data["email"])}) _set_session_cookie(response, token) return PasswordlessVerifyResponse( email=getattr(data, "email", data["email"]), state=getattr(data, "state", getattr(data, "state", None)), template=str(getattr(data, "template", getattr(data, "template", None))), passwordless_type=str(getattr(data, "passwordless_type", data.get("passwordlessType"))) ) except Exception as e: raise HTTPException(400, str(e))

Define a POST endpoint for OTP verification: it checks the code with Scalekit, ensures auth_request_id is present, and on success sets a signed JWT in an HttpOnly cookie:

@router.post("/verify-otp", response_model=PasswordlessVerifyResponse) async def verify_otp(req: OTPVerifyRequest, response: Response): if not req.auth_request_id: raise HTTPException(422, "Missing auth_request_id.") try: verified = await verify_passwordless_email(code=req.code, auth_request_id=req.auth_request_id) data = verified[0] if isinstance(verified, tuple) else verified token = create_access_token({"sub": getattr(data, "email", data["email"])}) _set_session_cookie(response, token) return PasswordlessVerifyResponse( email=getattr(data, "email", data["email"]), state=getattr(data, "state", data.get("state") if isinstance(data, dict) else None), template=str(getattr(data, "template", data.get("template") if isinstance(data, dict) else None)), passwordless_type=str(getattr(data, "passwordless_type", data.get("passwordlessType") if isinstance(data, dict) else None)), ) except Exception as e: raise HTTPException(400, str(e))

3) Pydantic models that validate request and response shapes

Typed contracts make failures obvious and tests predictable. Use field aliases to accept dashboard-style keys (authRequestId) while keeping Pythonic names in code. With strict models in place, here’s how the OTP request shape (OTPVerifyRequest) flows through verification and error mapping:

Verify otp fallack via fastapi and scalekit

This flow shows the OTP verification path: FastAPI validates the OTPVerifyRequest, calls Scalekit to check {code, auth_request_id}, and returns either 400 otp_invalid/otp_expired or 429 rate_limited; on success it signs a JWT and sets an HttpOnly cookie. Mirror dashboard attempt caps client‑side to avoid 429 loops.

Define Pydantic models to enforce strict request/response shapes, typed inputs (email, OTP, tokens) and consistent API responses so invalid payloads fail fast and tests stay predictable.

# app/models/auth.py (excerpt) from pydantic import BaseModel, EmailStr, Field class EmailRequest(BaseModel): email: EmailStr class OTPVerifyRequest(BaseModel): code: str auth_request_id: str = Field(..., alias="authRequestId") model_config = {"populate_by_name": True} class MagicLinkVerifyRequest(BaseModel): link_token: str = Field(..., alias="linkToken") auth_request_id: str | None = Field(None, alias="authRequestId") model_config = {"populate_by_name": True} class PasswordlessSendResponse(BaseModel): auth_request_id: str expires_at: int expires_in: int passwordless_type: str class PasswordlessVerifyResponse(BaseModel): email: EmailStr state: str | None = None template: str | None = None passwordless_type: str | None = None

4) Dependency injection to keep services cleanly swappable

FastAPI’s DI system is now wired into both the database and authentication flows. Instead of importing helpers directly, endpoints declare what they need and let Depends(...) provide it at runtime:

  • Database sessions come from get_db() in app/db/session.py, injected into /verify-otp, /verify-magic-link, and a new /db-ping health route.
  • Current user resolution is handled by a get_current_user dependency that decodes the JWT cookie. It’s injected into /send-passwordless, /session, and /protected.
  • Protected routes like /protected enforce authentication via DI (unauthenticated calls get a 401).
  • Session lookups now also use DI, removing the old manual cookie parsing helper.
from fastapi import Depends @router.get("/protected") async def protected_route(user: User = Depends(get_current_user)): return {"email": user.email}

Tests validate the setup:

  • /session returns user details when authenticated and 401 otherwise.
  • /protected requires a valid JWT.
  • /db-ping proves DB injection works.

This DI wiring is still minimal/demo-level, but it establishes the correct patterns: route handlers stay thin, services are swappable in tests, and both DB + auth concerns are enforced consistently.

5) JWT token management with secure HttpOnly cookies

HttpOnly cookies carry signed sessions without exposing tokens to scripts. Sign a short-lived JWT, prefer Secure in production, and keep SameSite=Lax to preserve link-based flows.

Session read and logout with httponly jwt

This sequence shows how sessions are read from the HttpOnly cookie and how logout is handled. When a client hits /session, FastAPI reads and decodes the access_token cookie and returns the user if valid (or null if missing/invalid). The logout path simply clears the cookie at /logout and returns a confirmation, ensuring a clean stateless sign-out flow.

Utility to sign short-lived JWTs with HS256; embeds claims + expiration so the server can trust cookies without storing sessions.

# app/core/jwt.py (excerpt) from datetime import datetime, timedelta from jose import jwt def create_access_token(claims: dict, secret: str, days: int) -> str: exp = datetime.utcnow() + timedelta(days=days) to_encode = {**claims, "exp": exp} return jwt.encode(to_encode, secret, algorithm="HS256")

Helper that stores the JWT in a secure, HttpOnly cookie with SameSite=Lax and max-age aligned to your settings.

# app/routes/auth.py (cookie helper) from app.core.settings import settings def _set_session_cookie(response: Response, token: str): response.set_cookie( key="access_token", value=token, httponly=True, secure=(settings.base_url.startswith("https://")), samesite="lax", path="/", max_age=60*60*24*settings.jwt_days, )

Reads the cookie, decodes the JWT, and returns the user’s email (or None on failure); used by protected endpoints to enforce auth.

# reading the session for protected endpoints from jose import JWTError, jwt from fastapi import Request, HTTPException def _get_current_user(request: Request): token = request.cookies.get("access_token") if not token: return None try: payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"]) return payload.get("sub") except JWTError: return None

6) Database integration without user migration risks

Verification yields a trusted email you can map onto your existing users. You don’t need to migrate your tables, simply look up (or create) the user on first verification and attach your own identifiers in the session if needed.

Example (concept only):

# app/db.py (sketch) from sqlalchemy.orm import Session from .models import User # id, email, created_at, etc. def get_or_create_user(db: Session, email: str) -> User: user = db.query(User).filter(User.email == email).one_or_none() if user: return user user = User(email=email) db.add(user); db.commit(); db.refresh(user) return user

How it would fit after verification (not wired in yet):

# app/routes/auth.py (after verification) # user = get_or_create_user(db, email) # token = create_access_token( # {"sub": email, "uid": user.id}, settings.jwt_secret, settings.jwt_days # ) # _set_session_cookie(response, token)

Route protection with DI (actual project code):

from fastapi import Depends @router.get("/session") async def get_session(current_user=Depends(get_current_user)): return {"email": current_user}

Your current setup already uses dependency injection (get_current_user) for protected routes. The DB snippet is illustrative only and can be added later if you want to persist users.

7) Testing end-to-end flows using pytest and httpx

Pytest lets you validate authentication flows without a browser. Using TestClient, you can simulate send, verify, session, and protected routes, assert typed responses, and confirm that JWT cookies behave as expected. This keeps your contract testable and prevents regressions when policies or DI wiring change.

# tests/test_auth.py import re from fastapi.testclient import TestClient from app.core.jwt import create_access_token from app.main import app client = TestClient(app) def test_send_passwordless(): response = client.post("/api/auth/send-passwordless", json={"email": "test@example.com"}) assert response.status_code == 200 data = response.json() assert "auth_request_id" in data assert "expires_at" in data assert "expires_in" in data assert "passwordless_type" in data def test_verify_otp_missing_fields(): response = client.post("/api/auth/verify-otp", json={"code": "123456"}) assert response.status_code == 422 assert "authRequestId" in response.text def test_verify_magic_link_missing_fields(): response = client.post("/api/auth/verify-magic-link", json={"link_token": "sometoken"}) assert response.status_code in (400, 422) data = response.json() assert "detail" in data assert "invalid link token" in data["detail"] or "BAD_REQUEST" in data["detail"] def test_invalid_email_format(): response = client.post("/api/auth/send-passwordless", json={"email": "not-an-email"}) assert response.status_code == 422 data = response.json() assert "detail" in data assert any("email" in str(item) for item in data["detail"]) def test_invalid_magic_link_url(): response = client.post("/api/auth/send-passwordless", json={"email": "test@example.com", "magiclink_auth_uri": "not-a-url"}) assert response.status_code in (400, 422) data = response.json() assert "detail" in data assert "magiclink_auth_uri" in str(data["detail"]) or "invalid" in str(data["detail"]).lower() def test_session_prevention(): token = create_access_token({"sub": "test@example.com"}) client.cookies.set("access_token", token) response = client.post("/api/auth/send-passwordless", json={"email": "test@example.com"}) assert response.status_code == 409 data = response.json() assert "Already authenticated" in data["detail"] client.cookies.clear() def test_rate_limiting_simulation(): responses = [client.post("/api/auth/send-passwordless", json={"email": f"test{i}@example.com"}) for i in range(5)] assert any(r.status_code in (429, 400, 403) for r in responses) or all(r.status_code == 200 for r in responses) def test_jwt_token_structure(): response = client.post("/api/auth/send-passwordless", json={"email": "test@example.com"}) assert response.status_code == 200 # Placeholder: would expand if you expose a /decode endpoint def test_session_endpoint_unauthenticated(): client.cookies.clear() r = client.get("/api/auth/session") assert r.status_code == 200 assert r.json()["email"] is None def test_session_endpoint_authenticated(): token = create_access_token({"sub": "user@example.com"}) client.cookies.set("access_token", token) r = client.get("/api/auth/session") assert r.status_code == 200 assert r.json()["email"] == "user@example.com" client.cookies.clear() def test_protected_requires_auth(): client.cookies.clear() r = client.get("/api/auth/protected") assert r.status_code == 401 def test_protected_with_auth(): token = create_access_token({"sub": "protected@example.com"}) client.cookies.set("access_token", token) r = client.get("/api/auth/protected") assert r.status_code == 200 body = r.json() assert body["email"] == "protected@example.com" assert "Protected content" in body["message"] client.cookies.clear() def test_db_ping(): r = client.get("/api/auth/db-ping") assert r.status_code == 200 assert r.json()["ok"] is True

What this suite validates today:

  • Send flow returns auth_request_id, expiry fields, and type.
  • Validation is enforced (422 on missing/invalid fields; bad URLs rejected).
  • Session guard blocks duplicate logins (409 when already authenticated).
  • Session API reflects auth state (email: null vs. actual email).
  • Protected route requires JWT via DI (401 unauthenticated, 200 with cookie).
  • DB wiring responds correctly via /db-ping.

⚠️ Dev note: test_rate_limiting_simulation is probabilistic. For stability, consider mocking Scalekit or your limiter to deterministically force a 429.

Next test ideas:

  • Happy-path verify link (GET) sets cookie.
  • Wrong OTP returns structured 400.
  • Resend cooldown triggers 429.
  • Same-browser mismatch fails for link but OTP fallback succeeds.

Run them all:

pytest -q

8) Production deployment with hardened settings and monitoring

Deployment favors HTTPS, strict cookies, and short lifetimes. Serve FastAPI behind a reverse proxy with TLS, enable Secure cookies, and keep link/OTP expiry tight (5-10 minutes).

Environment alignment ensures callbacks point to your API: set BASE_URL to your public API origin and use live SCALEKIT_* keys from the dashboard.

Observability tracks reality: log sends, verifications, resends, and 429s; alert on error spikes and delivery drops; sample request IDs and auth_request_id (not PII) for correlation.

Process model keeps the app responsive:
gunicorn -k uvicorn.workers.UvicornWorker -w 2 app.main:app

Network controls reduce abuse: add gateway rate limits, restrict CORS to trusted origins, and block unsolicited methods. Mirror Scalekit dashboard limits at the edge for defense in depth.

Minimal Next.js login page that pairs with the API

The backend you just built works standalone, you can test flows with cURL or pytest. But if you want to wire up a quick UI, here’s a minimal Next.js page that calls the FastAPI endpoints. This isn’t part of the main project, just a suggestion for teams who prefer a frontend to test the login.

// app/login/page.tsx "use client"; import { useState } from "react"; export default function Login() { const [email, setEmail] = useState(""); const [rid, setRid] = useState<string|null>(null); const [err, setErr] = useState<string|null>(null); async function send(e:React.FormEvent) { e.preventDefault(); setErr(null); const r = await fetch("/api/auth/send-passwordless", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ email }) }); if (!r.ok) return setErr("Could not send link"); const j = await r.json(); setRid(j.authRequestId); } async function verify(e:React.FormEvent<HTMLFormElement>) { e.preventDefault(); setErr(null); const otp = new FormData(e.currentTarget).get("otp"); const r = await fetch("/api/auth/verify-otp", { method:"POST", headers:{ "Content-Type":"application/json" }, body: JSON.stringify({ code: otp, authRequestId: rid }) }); if (!r.ok) return setErr("OTP failed"); window.location.href = "/dashboard"; } return ( <div className="mx-auto max-w-md p-6 space-y-4"> <h1 className="text-2xl font-semibold">Sign in</h1> <form onSubmit={send} className="space-y-3"> <input className="w-full border rounded p-2" type="email" required value={email} onChange={e=>setEmail(e.target.value)} placeholder="you@example.com" /> <button className="w-full bg-black text-white rounded p-2">Send magic link</button> </form> {rid && ( <form onSubmit={verify} className="space-y-3"> <input name="otp" className="w-full border rounded p-2 tracking-widest" inputMode="numeric" pattern="[0-9]*" placeholder="123456" required /> <button className="w-full bg-black text-white rounded p-2">Verify code</button> </form> )} {err && <p className="text-red-600 text-sm">{err}</p>} </div> ); }

Operational checklist before flipping the switch

Security policy must be explicit and enforceable. In Scalekit, set link expiry (5-10 min), OTP length and attempt caps (e.g., 6 digits, ≤5 tries), resend cooldown (≥30 s), and enable same-browser enforcement. Pin magiclink_auth_uri to your API origin; never accept caller-provided redirect targets. Always include a random state per request and verify it on callback.

Email delivery must be branded and authenticated. Use custom templates for magic link + OTP. Ensure SPF, DKIM, and DMARC pass for your sender. Add clear fallback copy explaining the OTP path and link lifetime. Track bounces and complaints; quarantine high-risk domains.

Cookies and headers must harden sessions. Set HttpOnly, Secure, SameSite=Lax, Path=/, and a strict Max-Age. Prefer short JWT lifetimes and rotate secrets quarterly; add a kid header if you plan key rotation. Deny framing (X-Frame-Options: DENY), set Referrer-Policy: strict-origin-when-cross-origin, and return Cache-Control: no-store on auth endpoints.

Rate limits and CORS must reflect reality. Mirror dashboard throttles at the edge (NGINX/Traefik/API-GW): per-IP and per-email limits for send/verify/resend. Allowlist exact origins in CORS; restrict methods and headers to what your handlers use. Block anonymous OPTIONS floods with gateway limits.

Observability must enable incident response. Log send/verify/resend outcomes with a correlation id (e.g., auth_request_id) and request id; never log link_token or OTP values. Export structured metrics for 2xx/4xx/5xx, latency, and 429s. Alert on spikes in expired/invalid tokens and delivery failures. Add a short runbook covering resend loops, device mismatch, expired links, and lockout resets.

What you shipped and why it matters

You shipped a server-native passwordless backend that you control end-to-end. FastAPI owns issuance, verification, and session creation; Scalekit supplies short-lived credentials, rate limits, and abuse protection without touching your user table. Handlers verify magic links or OTPs, mint HttpOnly JWT cookies, and gate routes via dependencies, no tokens ever reach client scripts. Pydantic contracts and DI make the flow testable with pytest and easy to stub in CI. Operationally, the dashboard tunes expiry and attempts, your gateway enforces limits, and logging ties every event to an auth_request_id for fast forensics. The result is fewer broken logins, simpler audits, and a branded experience, delivered by a lean Python service that scales without vendor UI or identity migration.

Troubleshooting real-world passwordless failures in FastAPI

Production failures follow repeatable patterns that your backend can detect and recover from. Most incidents reduce to expired tokens, same-browser enforcement mismatches, rate-limit lockouts, or email deliverability hiccups. FastAPI should translate Scalekit errors into precise HTTP statuses and machine-readable error codes, emit structured logs with a correlation id (auth_request_id), and guide users toward a safe next step (usually resend or restart). Server logs, not client screenshots, should be your source of truth: capture status, endpoint, latency, and error class without ever logging link_token or OTP values.

Operational responses work best when symptoms map directly to actions. The table below encodes a minimal runbook your on-call can follow under pressure. Pair it with alerts on spikes of otp_expired, same_browser_mismatch, and rate_limited, and keep a throttle-bypass procedure for verified support agents (e.g., manual resend with stricter cooldown).

Symptom at endpoint
Probable cause
Diagnostic signals
Server action
User-facing message
GET /verify-magic-link → 401
Same-browser enforcement failed
Error same_browser_
mismatch, new device UA
Return 401 same_browser_
mismatch; suggest OTP
“This link must be opened on the original device. Enter the code we sent instead.”
GET/POST /verify* → 400
Token or OTP invalid/expired
Error token_invalid or otp_expired; age and expiry
Return 400 otp_expired; allow resend
“This code/link expired. Request a new sign-in.”
POST /verify-otp → 429
Attempt limit exceeded
Error rate_limited; attempts ≥ cap
Return 429; start cooldown timer
“Too many attempts. Try again in N minutes.”
POST /send-passwordless → 409
Existing valid session
Cookie present and valid
Return 409 already_
authenticated
“You’re already signed in. Log out to start a new sign-in.”
Spike in 5xx or timeouts
Email provider or upstream issue
Elevated latency, upstream 5xx
Circuit-break resend/verify; degrade gracefully
“We’re having trouble sending/validating. Please try again shortly.”

Post-incident checks prevent repeats. Rotate JWT keys on schedule, confirm DMARC alignment after template changes, tighten edge rate limits if 429 spikes persist, and add synthetic probes that perform a full send→verify loop hourly from a neutral mailbox.

Conclusion: How the FastAPI backend solved passwordless pain

The Python team that split into FastAPI services hit a wall on authentication, brand-controlled emails, magic link + OTP fallback, no user migration, short-lived tokens, strict rate limits, and same-browser enforcement. FastAPI + Scalekit resolved the dead ends by keeping issuance and verification server-only, formalizing inputs with Pydantic, and issuing HttpOnly JWT cookies after verification. Secrets stayed in env vars, links and OTPs gained clear lifetimes, and the system stopped leaking state to clients. Support tickets dropped because errors mapped to precise statuses and resend paths, while audits passed with measurable rate limits and hardened headers.

A backend-only passwordless core that belongs to your product. FastAPI orchestrates send/verify/resend; Scalekit supplies short-lived credentials, abuse controls, and dashboard policies; your database remains the source of truth with no migrations. Dependency injection made external calls mockable; pytest covered happy paths and edge cases; production hardening locked cookies, CORS, and gateway limits. The result is a server-native flow that’s observable, testable, and brand-consistent.

Audit your code against the operational checklist above, especially cookie flags, expiry windows, and error mapping. Spin up a Scalekit environment and run a full cURL loop (send → verify OTP → read session) in staging. If you want to go deeper, explore JWT key rotation with kid, multi-tenant policy overrides, and rate-limit alignment at the edge. When you’re satisfied locally, roll this backend into your project and monitor sends, verifications, and 429s during the first week.

FAQ

How does same-browser enforcement work with Scalekit?

Same-browser enforcement hinges on the auth_request_id. Your send call returns it; Scalekit also includes it on magic-link clicks. Your verify endpoint must pass auth_request_id to verifyPasswordlessEmail(...). When the browser differs from the initiator, Scalekit returns a mismatch error, map that to 401 same_browser_mismatch and steer users to OTP. Never synthesize or guess auth_request_id; persist it only as metadata for correlation, not as a secret.

Can I tune expiry and attempt caps per request or tenant?

Expiry can be set per request via expiresIn. Attempt caps and resend cooldowns are dashboard policy. For per-tenant variance, enforce extra app-side controls: maintain counters in Redis keyed by (tenant_id, email) and gate sends/verify attempts beyond the dashboard caps. Record decisions in logs with a correlation id for auditability.

How do I avoid duplicate emails on retries or network flaps?

Implement idempotency on the send path. Generate a server idempotency key such as hash(email + time_window), record the first successful auth_request_id, and return that same payload for subsequent identical requests within the window. Combine with edge rate limits to prevent bursty duplicates and use a “resend” endpoint only after cooldown.

How should I rotate JWT signing keys without breaking sessions?

Use key identifiers. Sign tokens with a kid header and keep a keystore {kid -&gt; secret}. Verification selects the secret by kid, allowing parallel validity during rotation. Rotate by: 1) add new key, 2) start signing new tokens with new kid, 3) keep the old key for the token TTL, 4) remove the old key after expiry. For emergency revocation, include a jti and maintain a short-lived denylist.

What is the minimal test matrix for confidence?

Cover send/verify happy paths, OTP wrong-code, expired token, same-browser mismatch, and resend cooldown. Use respx to mock httpx calls, freezegun to simulate expiry boundaries, and table-driven pytest parametrization for status mapping (400/401/429/409). Assert cookie flags (HttpOnly/SameSite/Path/Secure), ensure no secrets are logged, and validate correlation ids appear on every log line for send/verify/resend.

No items found.
On this page
Share this article
Modernize login flows

Acquire enterprise customers with zero upfront cost

Every feature unlocked. No hidden fees.
Start Free
$0
/ month
1 FREE SSO/SCIM connection each
1000 Monthly active users
25 Monthly active organizations
Passwordless auth
API auth: 1000 M2M tokens
MCP auth: 1000 M2M tokens