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.
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.
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.
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)
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.
Why this matters (brief):
Example .env.example (check into Git):
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.
In Authentication → Passwordless:
Now to setup our python dependencies run the following:
This module wraps the synchronous Scalekit client with async helpers, letting FastAPI safely send, resend, and verify passwordless emails without blocking the event loop:
Environment cheatsheet:
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.
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:
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:
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:
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:
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.
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:
Tests validate the setup:
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.
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.
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.
Helper that stores the JWT in a secure, HttpOnly cookie with SameSite=Lax and max-age aligned to your settings.
Reads the cookie, decodes the JWT, and returns the user’s email (or None on failure); used by protected endpoints to enforce auth.
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):
How it would fit after verification (not wired in yet):
Route protection with DI (actual project code):
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.
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.
What this suite validates today:
⚠️ Dev note: test_rate_limiting_simulation is probabilistic. For stability, consider mocking Scalekit or your limiter to deterministically force a 429.
Next test ideas:
Run them all:
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.
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.
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.
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.
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).
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.
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.
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 -> 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.