Backend-only flow: Next.js 15 API routes + Scalekit issue, verify, and consume magic links entirely server-side, no frontend dependency.
Session security: Short-lived JWTs stored in HttpOnly, Secure cookies enforce stateless, tamper-proof authentication.
Middleware enforcement: Global guards add security headers, rate limiting, and session checks before any route logic runs.
Abuse protection: Per-IP and per-email throttling prevent spam, brute force, and provider credit drain.
Observability built-in: Structured logging, correlation IDs, and pluggable persistence (in-memory → Redis/SQL) make flows auditable and production-ready.
The case for backend-only magic links
A Friday release at Helix Analytics, a fictional SaaS company we’ll use as an example, triggered a login outage that no one could quickly fix. OTP emails backed up behind a throttled provider, SMS fell over to an empty pool, and half-created sessions stranded paying customers at the door. Engineers juggled retries and expiry windows across three services while incident command begged for a single lever to pull. The review was blunt: too many moving parts in the login path, too little server-side visibility, and too much trust in client flows the team couldn’t observe. Operations wanted fewer dependencies, security wanted fewer tokens in transit, and product wanted a path any client could share.
Backend-only magic links offered the reset they needed. The login journey collapsed into one signed URL and one server-side session. Next.js 15 Route Handlers issued single-use links, Middleware enforced access and safe redirects, and HttpOnly cookies carried authenticated state without exposing secrets. Email became the single external dependency, hardened with rate limits, allowlists, and one-time tokens. Centralized logging finally told a coherent story: who requested a link, who consumed it, when the session was created, and why a request was denied. The blast radius shrank from multi-service choreography to an auditable backend flow.
This guide shows how to build that system. You’ll see how to create API routes that issue and consume magic links, wire Middleware to gate protected paths, and manage sessions with Secure, HttpOnly cookies. You’ll design database tables for users, tokens, and sessions; apply security headers that harden defaults; add rate limiting that throttles abuse; and instrument monitoring and structured logs that make login health visible before it breaks.
Understanding passwordless in a backend-only context
Passwordless authentication is the idea that users can log in without remembering or typing a password. Instead of a static secret, they prove ownership of an identity channel, commonly email or phone, each time they sign in. The most common patterns are magic links (click a link in email) or one-time codes (type a code from SMS or email). Both approaches move the burden of security from human memory to controlled server logic.
For Helix Analytics, magic links were the better fit. They reduce moving parts, eliminate the need for SMS providers, and collapse the login into a single server-verified click. Magic links aren’t “less secure” than codes when implemented correctly: each link is signed, expires quickly, and can only be used once. What you lose in multi-factor flexibility, you gain in operational simplicity, exactly what the incident demanded.
Focusing on backend-only means that authentication doesn’t depend on any particular UI. Whether the user comes from a web client, a mobile app, or even a CLI tool, the logic stays in the Next.js backend. Clients only see JSON responses or redirects; all critical steps, token generation, validation, and session creation, happen on the server. This separation improves observability, lets you harden flows with middleware and rate limits, and keeps sensitive operations off the client.
Before we walk into the passwordless flow, it helps to anchor three building blocks that will keep showing up:
API routes are the entry points where clients request a login link or redeem one.
Sessions are the state objects that represent a logged-in user once the link is consumed.
Cookies (specifically HttpOnly and Secure ones) are the transport mechanism that lets the server remember sessions without exposing them to client-side scripts.
With those concepts in place, the passwordless flow becomes less about “magic” and more about wiring these pieces together in a safe and predictable way.
Setting up the Next.js 15 backend for passwordless authentication
To keep the solution practical and reproducible, the backend runs entirely inside a Next.js 15 App Router project. All authentication logic lives in API routes and middleware, so no frontend code is required. The only dependencies are the Scalekit Node SDK, JWT handling, and a few utilities for logging, rate limiting, and OpenAPI documentation. Alternatively, you can clone this repo to try it out yourself.
npm run dev
# Visit http://localhost:3000/api-docs for Swagger UI
This setup gives you:
Magic link endpoints that send and verify login tokens.
JWT session cookies (sk_session) for authenticated requests.
In-memory stores for verification and rate limiting (simple for local use, replaceable with Redis or a database in production).
Security headers and middleware that guard requests before they reach your APIs.
Swagger docs at /api-docs to interactively test each route.
With the project scaffolded, the next step is to see how the passwordless flow maps into API routes.
Mapping the passwordless flow into API routes
The passwordless journey in this backend consists of three core steps: send a magic link, verify the link, and issue a session. Everything else, logout, introspection, rate limiting, extends from these basics. By structuring them as clean API routes, the backend becomes client-agnostic: a web app, mobile app, or even CLI can all use the same flow.
The flow works like this:
User → POST /api/send-magic-link → [Scalekit issues link + sets cookie] User clicks link → POST/GET /api/verify-magic-link → [email marked verified] Client → GET /api/session?email=... → [JWT session cookie issued]
1. Requesting a magic link
The /api/send-magic-link route accepts an email, calls the Scalekit SDK to create a passwordless request, and emails a single-use link. It also sets a short-lived httpOnly cookie (sk_auth_request_id) to bind the verification step back to this origin.
// src/app/api/send-magic-link/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { client } from '@/lib/backend'
import { ok, fail } from '@/lib/apiResponse'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { email } = body
if (!email) return fail('Missing email', 'VALIDATION')
const resp = await client.passwordless.createAuthRequest({
email,
passwordlessType: 'MAGIC_LINK',
expiresIn: 600,
})
const res = ok(resp)
res.cookies.set('sk_auth_request_id', resp.authRequestId, {
httpOnly: true, secure: true, sameSite: 'lax'
})
return res
} catch (err) {
return fail('Failed to send magic link', 'SEND_FAILED', err)
}
}
2. Verifying the magic link
The /api/verify-magic-link route validates the token from the clicked link. It cross-checks with the stored auth_request_id (via cookie or request body), and if valid, marks the email as verified in the in-memory store.
// src/app/api/verify-magic-link/route.ts
import { NextRequest } from 'next/server'
import { client } from '@/lib/backend'
import { ok, fail } from '@/lib/apiResponse'
import { markVerified } from '@/lib/verificationStore'
export async function POST(req: NextRequest) {
try {
const body = await req.json()
const { link_token, auth_request_id } = body
const result = await client.passwordless.verifyAuthRequest({
authRequestId: auth_request_id,
linkToken: link_token,
})
markVerified(result.email) // in-memory
return ok(result)
} catch (err) {
return fail('Verification failed', 'VERIFY_FAILED', err)
}
}
3. Issuing a session
Once verified, the user can request a session via /api/session. This issues a signed JWT (default 30 minutes), returned in the response, and set as the sk_session httpOnly cookie.
For Helix Analytics, this split of send → verify → session turned a brittle OTP system into three auditable backend calls. Structured responses, cookies, and logs mean operations can trace each login attempt without guessing at client behavior. And because all flows are API-based, any client can integrate without custom logic.
Enforcing authentication with middleware guards
Having sessions is only useful if protected routes can trust them. In Next.js 15, middleware runs before your route handlers and lets you enforce security policies at the edge of the request. This backend uses middleware for three things: adding security headers, applying per-route rate limits, and checking for valid sessions on protected paths.
For Helix Analytics, middleware meant no more sprinkling if (!session) checks across handlers. Every protected route inherits the same guarantees: valid JWT, rate limits applied, strict headers enforced. This shrinks the attack surface and centralizes auditing, so the next incident report doesn’t include “forgot to guard endpoint X.”
Managing sessions with JWT cookies
Once a user has verified their email, the backend must give them a durable way to stay authenticated. This project uses short-lived JWT session cookies instead of storing server-side session state. JWTs are compact, stateless, and easy to verify inside middleware. The cookie is marked httpOnly, Secure, and SameSite=Lax, which prevents JavaScript access and reduces CSRF risk.
Creating a session
The /api/session route issues a JWT for a verified email. It encodes the email and expiry, signs with SESSION_JWT_SECRET, and sets the cookie sk_session.
Session lifecycle: verified email → JWT cookie (sk_session) → protected route uses middleware to trust it.
Introspecting a session
Protected APIs often need to confirm whether the session is still active and who the user is. The /api/protected/session-info route demonstrates this. It decodes the JWT from either the Authorization header or the sk_session cookie.
// src/app/api/protected/session-info/route.ts
import { NextRequest } from 'next/server'
import { ok, fail } from '@/lib/apiResponse'
import { decodeSession } from '@/middleware/auth'
export async function GET(req: NextRequest) {
const session = decodeSession(req)
if (!session) return fail('Unauthorized', 'UNAUTHORIZED')
return ok({
message: 'active',
user: session.email,
})
}
Logging out
Because JWTs are stateless, logout is mostly client-side: the server clears the cookie, and the client must drop any Authorization headers.
// src/app/api/logout/route.ts
import { NextRequest } from 'next/server'
import { ok } from '@/lib/apiResponse'
export async function POST(_req: NextRequest) {
const res = ok({ message: 'Logged out' })
res.cookies.set('sk_session', '', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 0, // expire immediately
})
return res
}
Why this matters
For Helix Analytics, sessions replaced fragile OTP state machines with a single, predictable cookie. Middleware enforces it automatically, Swagger UI can test it with Bearer tokens, and structured logs show each issuance and logout event. The result is a login system that’s both simpler to operate and easier to observe.
Hardening requests with stricter security headers
In the middleware overview, we saw that every request passes through applySecurityHeaders, which already adds a baseline set: nosniff, DENY for frames, a simple Content-Security-Policy, and HSTS in production. That baseline is enough to block the most common vectors, but production APIs usually benefit from tightening further.
Security headers complement session cookies and rate limits: they don’t authenticate users, but they reduce how much damage a compromised client or embedded page can do.
Expanded security headers
// src/middleware/security.ts
import { NextRequest, NextResponse } from 'next/server'
export function applySecurityHeaders(_req: NextRequest, res: NextResponse) {
res.headers.set('X-Content-Type-Options', 'nosniff')
res.headers.set('X-Frame-Options', 'DENY')
res.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
// Extra layer: opt out of dangerous browser features by default
res.headers.set(
'Permissions-Policy',
'geolocation=(), microphone=(), camera=(), payment=()'
)
// Stricter CSP for APIs
res.headers.set(
'Content-Security-Policy',
[
"default-src 'self'",
"base-uri 'none'",
"frame-ancestors 'none'",
"connect-src 'self'", // allow API fetches from same origin
].join('; ')
)
if (process.env.NODE_ENV === 'production') {
res.headers.set(
'Strict-Transport-Security',
'max-age=63072000; includeSubDomains; preload'
)
}
return res
}
Why these matter
X-Frame-Options/frame-ancestors prevent clickjacking by blocking your API from being embedded in iframes.
Referrer-Policy ensures sensitive query strings (like tokens) don’t leak to third parties.
Permissions-Policy disables powerful features (camera, microphone, payment) unless you opt in later.
Content-Security-Policy limits what the browser can load; for an API, it can be almost completely locked down.
Strict-Transport-Security ensures clients always use HTTPS once they’ve seen it.
Balancing Swagger UI
Swagger UI at /api-docs still works under this policy because it only loads its own scripts and fetches your own JSON spec. If you later add images or fonts, you can relax CSP only on that page by extending img-src or font-src for Swagger’s assets. Keeping API endpoints strict means production incidents won’t trace back to a forgotten CSP hole.
For Helix Analytics, these headers closed off an entire class of support tickets: strange behavior caused by browsers embedding the API in third-party sites or misinterpreting content types. They don’t replace authentication, but they raise the floor of security across every request.
Containing abuse with rate limiting
Auth endpoints attract automation. Without throttles, a bot can burn your email credits by spamming /api/send-magic-link, or brute-force tokens against /api/verify-magic-link. Rate limiting gives you a pressure valve: cap requests per IP and per identifier (like email), fail fast with 429, and surface clear retry signals. In this backend, the limiter runs in middleware, so every protected path inherits consistent controls. Local demos use an in‑memory map; production can swap in Redis with the same interface.
Layered throttling: global IP caps + route caps + per-email checks before calling Scalekit.
Global IP limiter (middleware helper)
// src/middleware/rateLimit.ts
import { NextRequest, NextResponse } from 'next/server'
const WINDOW_MS = 60_000 // 1 minute
const MAX_PER_WINDOW = 5 // per IP (global)
const buckets = new Map()
function track(key: string, now = Date.now()) {
const cutoff = now - WINDOW_MS
const arr = (buckets.get(key) ?? []).filter(ts => ts > cutoff)
arr.push(now)
buckets.set(key, arr)
return arr.length
}
export function rateLimit(req: NextRequest) {
const ip = req.ip ?? 'unknown'
const count = track(`ip:${ip}`)
if (count > MAX_PER_WINDOW) {
const res = NextResponse.json(
{ success: false, error: 'Too many requests', errorCode: 'RATE_LIMIT' },
{ status: 429 }
)
// Optional guidance for clients
res.headers.set('Retry-After', String(Math.ceil(WINDOW_MS / 1000)))
return res
}
return null
}
Route‑specific caps (stricter for send/verify)
Some endpoints deserve tighter limits than others. Add a per‑route gate that runs before your handler logic. Here, sending links is stricter than general API usage; verification is also capped to blunt token guessing.
// src/middleware/rateLimitRoute.ts
import { NextRequest, NextResponse } from 'next/server'
import { rateLimitByKey } from './rateLimitByKey'
export function applyRouteLimits(req: NextRequest) {
const p = req.nextUrl.pathname
// POST /api/send-magic-link → very strict
if (p === '/api/send-magic-link' && req.method === 'POST') {
return rateLimitByKey(req, 'send', { windowMs: 60_000, max: 3 })
}
// POST/GET /api/verify-magic-link → strict
if (p === '/api/verify-magic-link') {
return rateLimitByKey(req, 'verify', { windowMs: 60_000, max: 10 })
}
// Default: no extra per-route cap
return null
}
// src/middleware/rateLimitByKey.ts
import { NextRequest, NextResponse } from 'next/server'
type Opts = { windowMs: number; max: number }
const store = new Map()
function bump(key: string, windowMs: number, now = Date.now()) {
const cutoff = now - windowMs
const arr = (store.get(key) ?? []).filter(ts => ts > cutoff)
arr.push(now)
store.set(key, arr)
return arr.length
}
export function rateLimitByKey(req: NextRequest, bucket: string, opts: Opts) {
const ip = req.ip ?? 'unknown'
const key = `${bucket}:ip:${ip}`
const count = bump(key, opts.windowMs)
if (count > opts.max) {
const res = NextResponse.json(
{ success: false, error: 'Too many requests', errorCode: 'RATE_LIMIT' },
{ status: 429 }
)
res.headers.set('Retry-After', String(Math.ceil(opts.windowMs / 1000)))
return res
}
return null
}
Per‑email throttling (defense‑in‑depth)
Bots often rotate IPs. Add a light per‑identifier cap using the request body. This runs inside the send handler before contacting your provider. Keep responses identical to avoid probing which emails exist.
// src/app/api/send-magic-link/route.ts (excerpt)
import { NextRequest } from 'next/server'
import { ok, fail } from '@/lib/apiResponse'
import { throttleEmail } from '@/lib/throttleEmail'
import { client } from '@/lib/backend'
export async function POST(req: NextRequest) {
const { email, ...rest } = await req.json().catch(() => ({}))
if (!email) return fail('Missing email', 'VALIDATION')
if (throttleEmail(email, { windowMs: 10 * 60_000, max: 5 })) {
return fail('Too many requests', 'RATE_LIMIT', undefined, 429)
}
const resp = await client.passwordless.createAuthRequest({
email, passwordlessType: 'MAGIC_LINK', expiresIn: 600, ...rest,
})
const res = ok(resp)
res.cookies.set('sk_auth_request_id', resp.authRequestId, {
httpOnly: true, secure: true, sameSite: 'lax',
})
return res
}
// src/lib/throttleEmail.ts
const heap = new Map()
export function throttleEmail(email: string, opts: { windowMs: number; max: number }) {
const key = email.trim().toLowerCase()
const now = Date.now(), cutoff = now - opts.windowMs
const arr = (heap.get(key) ?? []).filter(ts => ts > cutoff)
arr.push(now)
heap.set(key, arr)
return arr.length > opts.max
}
Wiring into global middleware
Call the global IP limiter and the route‑specific caps before auth checks to save work under load.
// src/middleware.ts (excerpt)
import { rateLimit } from '@/middleware/rateLimit'
import { applyRouteLimits } from '@/middleware/rateLimitRoute'
export async function middleware(req: NextRequest) {
let res = NextResponse.next()
const limited = rateLimit(req) || applyRouteLimits(req)
if (limited) return limited
// ...security headers + auth guard as shown earlier
return res
}
Why this matters, for Helix Analytics, limits turned noisy bot storms into short 429 blips instead of cascading incidents. Operators kept email spend predictable, logs stayed readable, and real users weren’t blocked after a handful of legitimate attempts. When you later move to Redis, the same keys (ip:*, send:ip:*, and email:*) map cleanly to a distributed counter, so the code path stays nearly identical.
Monitoring and logging every authentication flow
Operational visibility prevents a Friday-night repeat. The goal is simple: every request carries a correlationId, every critical step emits a structured log, and sensitive fields are redacted. With this in place, you can reconstruct a user’s journey from “send link” through “verify” to “session issued,” spot abuse patterns, and alert on anomalies, without digging through unstructured noise.
Observability pipeline: every step emits structured logs with correlationId for end-to-end tracing.
Add a request-scoped correlation ID. Generate a UUID at the edge and pass it through responses and logs.
// src/lib/apiResponse.ts (excerpt)
import { NextResponse } from 'next/server'
export function ok(data: any, status = 200) {
const correlationId = crypto.randomUUID()
const res = NextResponse.json({ success: true, data, correlationId }, { status })
res.headers.set('x-correlation-id', correlationId)
return res
}
export function fail(message: string, errorCode: string, err?: unknown, status = 400) {
const correlationId = crypto.randomUUID()
const error = process.env.NODE_ENV === 'production' ? message : (message + '')
const res = NextResponse.json({ success: false, error, errorCode, correlationId }, { status })
res.headers.set('x-correlation-id', correlationId)
return res
}
Configure a structured logger. Use Winston with JSON output in production and a readable format in dev. Include the correlationId, route, method, and sanitized inputs.
// src/lib/logger.ts
import winston from 'winston'
const redact = (v: unknown) => {
if (!v) return v
const s = JSON.stringify(v)
.replace(/(link_token|code|authRequestId|session|sk_session)":"[^"]+/g, '$1":"[redacted]')
.replace(/(authorization)":\s*"Bearer [^"]+/gi, '$1:"Bearer [redacted]')
return JSON.parse(s)
}
export const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: process.env.NODE_ENV === 'production'
? winston.format.json()
: winston.format.combine(winston.format.colorize(), winston.format.simple()),
transports: [new winston.transports.Console()],
})
export function logInfo(message: string, meta: Record = {}) {
logger.info(message, redact(meta))
}
export function logError(message: string, meta: Record = {}) {
logger.error(message, redact(meta))
}
Emit logs in each auth step. Log intent before the call, the normalized result after, and errors with context. Avoid logging tokens or raw codes.
Capture denials and limits in middleware. When rate limits or auth checks block a request, log a single-line record with the same shape so dashboards stay consistent.
Establish minimal metrics without a full stack. Even without Prometheus, counters can be approximated by tailing logs. Use stable event names (passwordless.request.*, passwordless.verify.*, session.issue.*, rate.limit.block, auth.guard.deny). Alert on spikes in *.error, a rise in verify.start without matching verify.success, or unusual IP concentration for send-magic-link.
Why this matters: for Helix Analytics, these logs turned guesswork into timelines. Operators could answer “who requested a link, did the link verify, and when was the session issued?” in seconds. When something breaks, the correlationId threads all steps together, Swagger calls, middleware decisions, and route outcomes, so it fixes the target causes, not symptoms.
Adding lightweight persistence without changing route contracts
Persistence helps incidents stay boring. Verification flags and rate‑limit counters survive restarts, and optional revocation lists enable stricter session control. This backend keeps persistence pluggable: APIs don’t change whether the store is in‑memory or Redis/SQL. Start with the default in‑memory helpers; swap in Redis (or a DB) by implementing the same tiny interfaces.
// src/lib/stores/index.ts
import { verificationStore as memV } from './verificationStore'
import { counterStore as memC } from './rateLimitStore'
export const stores = {
verification: process.env.REDIS_URL ? (await import('./verificationStore.redis')).verificationStoreRedis : memV,
counters: process.env.REDIS_URL ? (await import('./rateLimitStore.redis')).counterStoreRedis : memC,
}
Switching is a single import change (or an env‑gated factory):
// src/lib/stores/index.ts
import { verificationStore as memV } from './verificationStore'
import { counterStore as memC } from './rateLimitStore'
export const stores = {
verification: process.env.REDIS_URL ? (await import('./verificationStore.redis')).verificationStoreRedis : memV,
counters: process.env.REDIS_URL ? (await import('./rateLimitStore.redis')).counterStoreRedis : memC,
}
Update callers to use stores.verification / stores.counters, leaving route handlers untouched.
Optional SQL for longer retention and audits
If you prefer SQL, a minimal schema works:
-- verified emails (TTL via job/cron)
CREATE TABLE verified_email(
email TEXT PRIMARY KEY,
verified_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- session revocation (only if you add jti to JWTs)
CREATE TABLE revoked_jti(
jti TEXT PRIMARY KEY,
revoked_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Sessions remain JWT‑stateless by default; add a jti claim and check revoked_jti if you need forced logout. Rate limits map cleanly to Redis counters or a rolling‑window table, but counters in Redis are usually sufficient for auth endpoints.
Why this design hold up? The interfaces decouple storage from behavior. Local development stays simple; production can turn the same code paths durable with a one‑line switch. Operations gain continuity across deploys without re‑authoring APIs, and incident reviews get consistent evidence even after restarts.
This backend demonstrates how to deliver passwordless login with nothing but Next.js 15 API routes, Scalekit SDK, and strict server-side control. We walked through the full journey: issuing magic links, verifying them, creating JWT sessions, guarding protected routes with middleware, layering on security headers, rate limits, and structured monitoring. Persistence is pluggable, start with in-memory, swap to Redis or SQL when needed, without changing route contracts. The design keeps authentication reliable, observable, and client-agnostic, avoiding the pitfalls that burned Helix Analytics in its Friday-night outage.
What this means is simple: you now have a backend pattern that any web, mobile, or CLI client can trust without extra UI complexity using a simple integration with Scalekit. The moving parts are few, the failure modes are clear, and every request leaves a trail in your logs.
Next steps for you as a reader:
Clone or scaffold the project layout, wire your own SMTP or email provider, and run through the send-verify-session flow.
Explore the Swagger UI at /api-docs to interact with endpoints and test flows in real time.
Swap the in-memory stores for Redis if you want persistence across deploys.
Tighten headers and rate-limit thresholds based on your environment’s risk tolerance.
Instrument logs into whatever monitoring stack your team already uses.
This backend isn’t just a demo, it’s a foundation. Extend it with refresh tokens, multi-factor authentication if your domain requires it, or richer logging as you scale. But even as it stands, you now have the tools to ship passwordless authentication with confidence, without shipping another midnight incident.
FAQ
How does Scalekit handle magic link security in Next.js?
Scalekit signs every magic link request with a unique auth_request_id and enforces single-use validation. In this backend, that ID is stored in an httpOnly cookie, so the verify step can confirm origin without exposing it to client JavaScript.
Can Scalekit passwordless flows be extended to OTP codes as well?
Yes. The same Scalekit API that issues magic links can also generate one-time passcodes (OTP). This backend only implements magic links for simplicity, but switching passwordlessType to OTP in the SDK call gives you both options with the same verification route.
Is JWT session management secure enough without a database?
JWT sessions are stateless, which means they don’t require server-side storage. As long as tokens are short-lived, signed with a strong secret, and set as httpOnly cookies, they are safe for most use cases. For forced revocation, add a jti claim and check it against a Redis blacklist.
How do Next.js Middleware and API routes work together for authentication?
Next.js 15 runs global middleware before API routes. This allows you to enforce JWT checks, rate limits, and security headers at the edge of every request, while the API route focuses only on business logic like sending or verifying a link.
What are the best practices for rate limiting passwordless endpoints?
Apply stricter limits on /api/send-magic-link (e.g., 3 requests per IP per minute) and moderate limits on /api/verify-magic-link. Add per-email throttling to stop abuse across rotating IPs. Redis or another distributed counter ensures consistency across multiple server instances.