Passwordless Admin Auth with Cloudflare Turnstile + Email OTP
# A personal blog's admin panel doesn't need a password. The design and implementation of a Turnstile-gated email OTP login, including atomic rate limiting on D1 and timing-attack defenses.
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。
[ ]まだコメントはありません。
Why I dropped passwords
Giving a personal blog's admin panel a password means inheriting password storage, hashing, and breach response. With exactly one user, sending a one-time password (OTP) to a pre-registered email address is enough.
The naive version, however, invites a different attack: bots hammering the "send OTP" button and bombing your inbox. This article covers the design that handles that too.
The flow
txt
1. Enter email address2. Turnstile (bot check, usually invisible)3. Send OTP → server verifies the Turnstile token → sends mail4. Enter the 6-digit code → verify → issue session cookie
Key decision: Turnstile runs exactly once, before sending the OTP. Requiring it again at verification hurts UX without adding security — possession of the OTP itself is the proof. Verification is protected by an attempt limit instead.
Server-side Turnstile verification
The widget token must always be checked against siteverify on the server:
ts
export async function verifyTurnstileToken(token: unknown, remoteIp?: string | null) { const secret = await getRuntimeValue("TURNSTILE_SECRET_KEY"); if (!secret) return { ok: false, error: "turnstile_not_configured" as const }; if (typeof token !== "string" || token.length < 8) return { ok: false, error: "turnstile_required" as const }; const body = new FormData(); body.set("secret", secret); body.set("response", token); if (remoteIp) body.set("remoteip", remoteIp); const res = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", { method: "POST", body }).catch(() => null); if (!res?.ok) return { ok: false, error: "turnstile_failed" as const }; const json = await res.json().catch(() => null); return json?.success ? { ok: true as const } : { ok: false, error: "turnstile_failed" as const };}
Small but important: only trust the cf-connecting-ip header for remoteip. x-forwarded-for is caller-controlled and must not be trusted.
Mail-bombing defense: rate limiting in D1
Workers may run a fresh instance per request, so in-memory rate limits leak. The OTP cooldown lives in D1 — and the trick is reserving in one statement, not read-then-write:
sql
UPDATE otp_request_limitsSET count = CASE WHEN ? - window_start >= ? THEN 1 ELSE count + 1 END, window_start = CASE WHEN ? - window_start >= ? THEN ? ELSE window_start END, last_sent_at = ?WHERE key = ? AND (? - window_start >= ? OR (? - last_sent_at >= ? AND count < ?))RETURNING count;
Empty RETURNING means you're in cooldown. Because the UPDATE is atomic, two concurrent requests can never both send mail.
And if mail delivery fails, the reservation is released — otherwise users burn their cooldown without receiving anything, which is the worst possible UX.
Storing and verifying the OTP
The OTP is never stored in plaintext; it lives in an HMAC-SHA256-signed cookie. Verification uses constant-time comparison to resist timing attacks, plus an attempt limit:
This is why Turnstile isn't needed at the verification step: a 6-digit space is a million possibilities, and with a handful of attempts allowed, brute force is effectively impossible.
Don't leak which addresses exist
When a non-admin address is entered, respond exactly as if it succeeded:
ts
if (!allowed || email !== allowed) { // Don't reveal whether this address is the admin. Send nothing, answer generically. return NextResponse.json({ ok: true });}
Returning an error would leak "this address is not the admin."
Takeaways
• For a single-user site, email OTP beats passwords in both simplicity and safety
• Turnstile once before sending; protect verification with attempt limits
• Persist rate limits in D1 and reserve atomically with a single UPDATE
• Refund the cooldown on delivery failure; never leak address existence
Turnstile site key が未設定のため、このフォームは送信できません。管理者は NEXT_PUBLIC_TURNSTILE_SITE_KEY を設定してください。