Adding Passwordless Login and AI Comments to My Chat Widget
My website's chat widget needed comments, but traditional systems add friction—databases, authentication, signup burden. I built a lightweight passwordless solution using email OTP and dual-mode submission: an explicit comment button for certainty, and AI auto-detection for natural conversation.
The entire system runs on Cloudflare: vanilla JavaScript frontend, Cloudflare Worker backend, KV for storage, and Resend API for emails. Comments flow from KV during the build process, merge incrementally into per-page JSON files, and render on each blog post.
Architecture Overview
┌─────────────────────────────────────────────────────────────────┐
│ Browser (chat-widget.js) │
│ │
│ User input ──▶ [Send] button ──▶ /api/chat (AI, RAG) │
│ [Comment] button ──▶ /api/comment (direct save) │
│ AI also auto-detects comments via [COMMENT] │
│ │
│ Login flow ──▶ Enter email ──▶ Enter OTP ──▶ Set name (new) │
│ localStorage persists auth state │
└─────────────┬───────────────────────────────────────────────────┘
│ credentials: 'include' (cross-origin cookies)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare Worker (embed-worker.js) │
│ │
│ /api/auth/send-code ──▶ Generate OTP ──▶ Resend API │
│ /api/auth/verify-code ──▶ Verify OTP ──▶ Session Cookie │
│ /api/auth/me ──▶ Check session │
│ /api/comment ──▶ Validate + Save to KV (explicit) │
│ /api/chat ──▶ [COMMENT] marker ──▶ Save KV / AI reply │
│ │
│ Session Cookie: HttpOnly; Secure; SameSite=None │
└─────────────┬───────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────┐
│ Cloudflare KV (AI_AUTH_KV) OpenAI API │
│ ├─ otp:{email} (TTL 5min) gpt-4o-mini │
│ ├─ sess:{token} (TTL 7days) │
│ ├─ user:{email} Resend API │
│ └─ comment:{page}:{ts}:{uuid} (sends OTP emails) │
└─────────────┬───────────────────────────────────────────────────┘
│ build.sh (pull → merge → display)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Build Pipeline │
│ pull-comments.mjs ──▶ .cache/comments.json │
│ merge-comments.mjs ──▶ blog/comments/2026/foo.json (per-page) │
│ post.html ──▶ fetch per-page JSON ──▶ render comments │
└─────────────────────────────────────────────────────────────────┘
Email OTP Passwordless Authentication
Sending the Verification Code
When a user clicks the login button and enters their email, the Worker generates a 6-digit OTP and sends it via Resend:
// Cryptographically secure 6-digit code
function generateOTP() {
const array = new Uint32Array(1);
crypto.getRandomValues(array);
return String(array[0] % 1000000).padStart(6, '0');
}
// Store in KV with 5-minute expiration
const code = generateOTP();
await env.AI_AUTH_KV.put(
`otp:${email}`,
JSON.stringify({ code, attempts: 0 }),
{ expirationTtl: 300 }
);
// 60s cooldown to prevent email bombing
await env.AI_AUTH_KV.put(`cooldown:${email}`, '1', { expirationTtl: 60 });
// Send via Resend
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}` },
body: JSON.stringify({
from: 'Yuxu.ge <[email protected]>',
to: [email],
subject: 'Your verification code',
html: `<p>Your code: <strong>${code}</strong></p>
<p>Expires in 5 minutes.</p>`,
}),
});
Verifying OTP and Creating Sessions
After successful verification, the Worker generates a 32-byte hex session token and returns it via Set-Cookie:
// Verify OTP (max 5 attempts)
const otpRecord = await env.AI_AUTH_KV.get(`otp:${email}`, { type: 'json' });
if (otpRecord.attempts >= 5) {
await env.AI_AUTH_KV.delete(`otp:${email}`);
return jsonResponse({ error: 'Too many attempts' }, 429);
}
if (otpRecord.code !== code) {
otpRecord.attempts += 1;
await env.AI_AUTH_KV.put(`otp:${email}`, JSON.stringify(otpRecord), { expirationTtl: 300 });
return jsonResponse({ error: 'Invalid code', attemptsLeft: 5 - otpRecord.attempts }, 400);
}
// Create session
const token = generateSessionToken(); // 16 random bytes as hex
await env.AI_AUTH_KV.put(
`sess:${token}`,
JSON.stringify({ email, name: user.name }),
{ expirationTtl: 7 * 24 * 3600 }
);
// Set HttpOnly cookie
return jsonResponse({ ok: true, name: user.name }, 200, {
'Set-Cookie': `session=${token}; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=604800`
});
Client-Side Auth Persistence
To handle unreliable cross-origin cookies, I persist auth state in localStorage for instant UI restoration:
async init() {
// 1. Restore from localStorage immediately (no UI flicker)
this.restoreAuthFromStorage();
// 2. Validate with server in background
this.checkAuth();
}
restoreAuthFromStorage() {
const stored = localStorage.getItem('chat_auth_user');
if (stored) {
this.authUser = JSON.parse(stored);
this.updateAuthUI(); // Show username, green icon instantly
}
}
async checkAuth() {
const res = await fetch(`${authApi}/me`, { credentials: 'include' });
const data = await res.json();
if (data.loggedIn) {
this.authUser = { email: data.email, name: data.name };
} else {
this.authUser = null; // Server says not logged in, trust server
}
this.saveAuthToStorage();
this.updateAuthUI();
}
Dual-Mode Comment Submission
The comment system offers two ways to submit, giving users both explicit control and seamless convenience.
Explicit Comment Button
When logged in, a green comment button appears next to the send button. Clicking it sends the message directly to /api/comment—no AI involved, guaranteed to be saved:
async submitComment(text) {
const response = await fetch('/api/comment', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
text: text.trim(),
pageUrl: window.location.pathname,
}),
});
if (response.ok) {
this.addSystemMessage('Comment saved!');
}
}
The Worker validates and saves directly to KV:
// POST /api/comment
const user = await getAuthUser(request, env);
if (!user) return jsonResponse({ error: 'Not authenticated' }, 401);
const { text, pageUrl } = await request.json();
const commentKey = `comment:${encodeURIComponent(pageUrl)}:${Date.now()}:${crypto.randomUUID()}`;
await env.AI_AUTH_KV.put(commentKey, JSON.stringify({
email: user.email, name: user.name,
text: text.trim(), pageUrl, timestamp: Date.now(),
}));
AI Auto-Detection
When a user sends a message through the regular send button, the AI attempts to classify it. The Worker appends a classification instruction to the system prompt:
if (user) {
systemPrompt += `\n\nUser "${user.name}" is logged in, current page: ${pageUrl}\n` +
'If the user\'s message is a comment, opinion, or feedback (not a question), ' +
'prefix your response with [COMMENT] on the first line. ' +
'For questions and normal conversation, do NOT add this marker.';
}
After receiving the response, the Worker checks for the [COMMENT] prefix:
let reply = data.choices[0].message.content;
let isComment = false;
if (user && reply.startsWith('[COMMENT]')) {
isComment = true;
reply = reply.replace(/^\[COMMENT\]\s*/, '');
// Save the user's original message to KV
const lastUserMsg = messages.filter(m => m.role === 'user').pop();
const commentKey = `comment:${encodeURIComponent(pageUrl)}:${Date.now()}:${crypto.randomUUID()}`;
await env.AI_AUTH_KV.put(commentKey, JSON.stringify({
email: user.email, name: user.name,
text: lastUserMsg.content.trim(), pageUrl, timestamp: Date.now(),
}));
}
return new Response(JSON.stringify({ reply, isComment }));
This dual approach means the green button guarantees a comment is saved, while typing naturally—"Great article!" gets auto-saved, "What tech stack is this?" gets a normal AI answer.
Build Pipeline: Pull, Merge, Display
Comments flow from KV to the website through a three-step build pipeline integrated into build.sh.
Step 1: Pull from KV
pull-comments.mjs lists all comment: keys from KV, fetches each value, and writes them grouped by page URL to .cache/comments.json:
// List all comment keys
const keysJson = execSync(
`npx wrangler kv key list --prefix="comment:" --namespace-id="${namespaceId}" --remote`
);
const keys = JSON.parse(keysJson);
// Fetch and group by pageUrl
for (const keyObj of keys) {
const value = execSync(`npx wrangler kv key get "${keyObj.name}" ...`);
const comment = JSON.parse(value);
comments[comment.pageUrl].push({ name: comment.name, text: comment.text, timestamp: comment.timestamp });
}
fs.writeFileSync('.cache/comments.json', JSON.stringify(comments, null, 2));
// Bulk delete from KV (one API call, not per-key)
const keyNames = keys.map(k => k.name);
fs.writeFileSync(bulkFile, JSON.stringify(keyNames));
execSync(`npx wrangler kv bulk delete "${bulkFile}" --namespace-id="${namespaceId}" --remote --force`);
An earlier version used per-key deletion (wrangler kv key delete in a loop), which was extremely slow. Each call spawns a new process with network overhead. Switching to wrangler kv bulk delete with a JSON file reduced deletion from minutes to seconds.
Step 2: Merge Incrementally
merge-comments.mjs reads .cache/comments.json and merges new comments into per-page JSON files under blog/comments/:
// Map pageUrl to file path:
// "/blog/2026/foo.html" → "blog/comments/2026/foo.json"
function pageUrlToFile(pageUrl) {
let rel = pageUrl.replace(/^\/blog\//, '').replace(/\.html$/, '');
return path.join('blog', 'comments', rel + '.json');
}
// Deduplicate by timestamp to handle re-pulls
const existingTimestamps = new Set(existing.map(c => c.timestamp));
const newOnes = incoming.filter(c => !existingTimestamps.has(c.timestamp));
const merged = [...existing, ...newOnes].sort((a, b) => a.timestamp - b.timestamp);
Per-page files enable incremental updates without overwriting: manually deleting a comment won't be undone by the next build (KV comments are deleted after pulling), each post only fetches its own comments, and the structure maps cleanly to post URLs.
Step 3: Display on Post Pages
Both the dynamic viewer (post.html) and static HTML pages fetch their per-page comment file and render below the article:
// Derive comments file URL from current page
// Static page: /blog/2026/foo.html → /blog/comments/2026/foo.json
// Dynamic viewer: postName "posts/2026/foo" → /blog/comments/2026/foo.json
fetch(commentsUrl)
.then(r => r.ok ? r.json() : null)
.then(comments => {
if (!comments || !comments.length) return;
section.style.display = 'block';
section.innerHTML = '<h2>Comments (' + comments.length + ')</h2>' +
comments.map(c => `
<div class="comment-item">
<strong>${c.name}</strong>
<span>${new Date(c.timestamp).toLocaleDateString()}</span>
<p>${c.text}</p>
</div>
`).join('');
});
Build Integration
The complete flow in build.sh:
# Pull from KV (if configured)
if [ -n "$CF_AUTH_KV_NAMESPACE_ID" ]; then
node _tools/pull-comments.mjs # fetch + bulk delete
fi
node _tools/merge-comments.mjs # incremental merge to per-page files
# ... build search index, static HTML, etc.
node _tools/build.js # static pages include comment rendering JS
The Cross-Origin Cookie Trap
This was the biggest gotcha. My site is on GitHub Pages (yuxu.ge), and the API runs on a Cloudflare Worker (yuxu.ge/api/*). In production they share the same domain, but during local development, localhost:8080 calling yuxu.ge is a cross-site request.
The problem chain:
- Worker sets a
SameSite=Laxcookie - Browser refuses to send
SameSite=Laxcookies on cross-site fetch requests - Login succeeds (state in JS memory), but navigating to another page triggers
checkAuth()which can't send the cookie - Server returns
{ loggedIn: false }— user appears logged out
The fix was two-pronged:
1. Change cookie to SameSite=None (requires Secure):
session=token; HttpOnly; Secure; SameSite=None; Path=/; Max-Age=604800
2. localStorage as a local cache: Even if the cookie fails to send, the UI restores auth state from localStorage immediately, then silently validates in the background.
Security Measures
- Email whitelist: Optional env var to restrict allowed emails during testing
- 60s OTP cooldown: Prevents email bombing
- 5 verification attempts max: Must request a new code after exceeding
- HttpOnly + Secure cookies: JS can't read them, HTTPS only
- CORS whitelist: Only
yuxu.ge,www.yuxu.ge,localhost:8080 - pageUrl regex validation:
/^\/[\w\-\/\.]*$/prevents injecting arbitrary paths - KV prefix isolation:
comment:prefix ensures pull/delete scripts never touchotp:*,sess:*, oruser:*data
Lessons Learned
Bulk operations beat loops: Per-key
wrangler kv key deletein a loop spawns N processes with N round-trips.wrangler kv bulk deletewith a JSON file does it in one call.SameSite=None+Secureis required for cross-site auth:Laxfails silently on cross-origin fetches. Dual persistence—cookies for server-side validation, localStorage for instant UI recovery—is more reliable than either alone.Cloudflare KV works as a lightweight message queue: Comments write to KV, a build script pulls and bulk-deletes. Not a real queue, but well-suited to small-scale projects.
Email OTP minimizes auth friction for low-frequency visitors: No passwords to remember, just inbox access.