A single-user admin panel can avoid storing a password by sending a six-digit one-time password (OTP) to a pre-registered email address.
“Passwordless” does not make the flow secure by itself.
It moves the trust boundary to the email account, Turnstile validation, OTP lifetime, attempt limits, and the signed admin session.
This blog validates Cloudflare Turnstile before requesting an OTP and keeps both delivery and verification limits in D1.
The emailed OTP expires after 10 minutes, while successful verification creates a signed session cookie that lasts seven days.
The only authentication credential accepted is the emailed OTP that expires after ten minutes.
There is no reusable fixed code or alternate route that bypasses mail delivery.
This article documents that email-OTP flow and the operational boundaries that remain.
Authentication flow from the browser through Turnstile validation, email delivery, attempt-limited OTP verification, and a seven-day admin session
*Diagram: bot assessment, mail delivery, proof of OTP possession, and the admin session are separate boundaries.*
Authentication flow
txt
1. Enter the administrator email address2. Obtain a Turnstile token for the admin_login action3. Validate success, hostname, and action through Siteverify4. Reserve a delivery allowance in D1 and email a six-digit OTP5. Verify the OTP under an attempt limit and issue a seven-day session cookie
The Turnstile token is used only by the OTP-request endpoint.
It is not replayed at the OTP-verification endpoint because Cloudflare tokens are single-use and expire after five minutes.
OTP verification has a separate D1-backed attempt limit instead.
Validate more than the Siteverify success flag
A successful client widget is not enough to authorize the request.
Cloudflare documents a maximum token length of 2,048 characters, a 300-second (five-minute) lifetime, and single-use validation.
The server calls Siteverify and compares the response hostname and action with its expected values in addition to requiring success.
The client and server share TURNSTILE_ACTIONS.adminLogin in this project.
Both the apex and www hosts can reach the Worker, so the route does not assume one fixed hostname; it binds validation to the hostname of that request URL.
The verifier rejects non-string tokens, tokens shorter than eight characters, and tokens longer than 2,048 characters before making a network request.
An AbortController bounds both the fetch and response-body read to a 5-second timeout.
Timeouts, non-2xx responses, invalid JSON, and metadata mismatches all fail closed.
The five-second timeout is an application policy, not Cloudflare’s five-minute token lifetime.
Only the trusted Cloudflare edge header cf-connecting-ip is sent as remoteip.
The route does not trust caller-controlled x-forwarded-for values.
Dummy metadata is accepted only when TURNSTILE_TEST_MODE=1 and NODE_ENV is explicitly development or test.
Reserve OTP delivery in D1
In-memory counters are not shared across Worker instances.
The route therefore stores request limits in D1’s otp_request_limits table.
For the same normalized email and IP pair, it applies a 60-second cooldown and a maximum of five reservations per 15-minute window.
The route does not perform an unguarded read followed by a write.
It reserves capacity with a conditional UPDATE ... RETURNING and sends mail only when a row is returned.
If the D1 binding is unavailable, the production route does not fall back to process memory; it returns 503 without sending an OTP.
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;
After reserving capacity, the server creates a six-digit code with randomInt(100000, 1000000).
Before passing the code to Cloudflare Email, it stores an HMAC-SHA256 value derived from the email, OTP, and expiry in a signed HttpOnly cookie.
The cookie lasts 10 minutes, uses SameSite=Lax, and is Secure in production.
A storage failure prevents mail delivery.
If Cloudflare Email fails, the route clears the OTP cookie and releases the D1 reservation.
An undelivered code must not leave either a valid verifier cookie or a consumed cooldown behind.
Limit OTP verification separately
OTP verification uses the otp_attempt_limits table.
In production, a missing D1 binding or a failure while acquiring and initializing that D1 binding is treated as locked instead of falling back to a local counter.
The later SELECT, UPSERT, and DELETE operations run outside that same try, however.
Those later failures may surface as a 5xx response, so the implementation does not convert every D1 error into a 429 lock response.
Only development and test environments may use signed-cookie attempt state as a fallback.
The attempt key is an HMAC of the email and IP.
Five failures establish a 15-minute lock state.
Node.js timingSafeEqual compares the stored HMAC with the value recomputed from the email, expiry, and submitted OTP.
A successful verification clears the OTP cookie and attempt state.
This is a per-email-and-IP limit, not a global per-account lockout.
Attempts from different IP addresses are not aggregated into one account counter.
OWASP recommends considering an account-associated counter while also warning that lockout can be abused for denial of service.
This single-user implementation chooses a specific trade-off; a deployment that requires account-wide throttling needs a separate counter and a deliberate recovery policy.
Generic responses still leave status and timing differences
After a valid Turnstile check, a non-administrator address receives the normal { ok: true } response instead of a direct “unknown account” error.
That prevents the happy-path message itself from naming whether the address is registered.
The current OTP-request route returns 200 for a non-administrator before reserving D1 capacity or sending mail.
The administrator path can instead return 503 for otp_rate_limit_unavailable or otp_store_failed, and 502 for mail_failed.
During those failures, the 200 versus 502 or 503 status and body become an account-validity discrepancy.
Even during success, the administrator path waits for delivery work and can have different latency.
The verification endpoint has another direct difference.
A non-administrator address returns 401 before touching D1, while an administrator candidate consumes attempts and eventually returns 429 after the lock is established.
That endpoint has no Turnstile check, so the 401 versus 429 behavior is a remaining enumeration oracle in the current implementation.
OWASP treats status codes and response times, not only body text, as discrepancy factors.
A deployment that requires enumeration resistance must separate delivery from request latency and also make both delivery failures and the verifier’s 401/429 behavior independent of account existence.
Keep authentication limited to emailed OTPs
verifyOtp recomputes the HMAC from the submitted email, OTP, and expiry, then compares it with the value in the signed OTP cookie.
An admin session can be issued only after that ten-minute OTP verification succeeds.
There is no branch that directly compares a runtime secret with submitted input.
No reusable credential can bypass the Turnstile-gated OTP request, mail delivery, and signed OTP cookie.
The seven-day session cookie
After a matching OTP, the server signs the administrator email and expiry into the kirin_blog_admin cookie.
It is HttpOnly, SameSite=Lax, Secure in production, and valid for seven days.
The signature protects integrity; it does not encrypt the payload.
There is no server-side session table in this design.
Logout deletes the browser cookie, but there is no per-session revocation record for a stolen copy; a valid signed cookie may remain usable until its seven-day expiry.
Deployments that require stronger revocation need server-side session identifiers, rotation, or a revocation list.
Email OTP removes a password database, but it is not MFA that treats email as a second factor.
If the email account is compromised, the OTP is compromised as well.
This design fits only when the single-user scope, protection of the administrator mailbox, and fail-closed dependencies on D1 and Cloudflare Email are acceptable.