Article · 2026-02-17

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:

  1. Worker sets a SameSite=Lax cookie
  2. Browser refuses to send SameSite=Lax cookies on cross-site fetch requests
  3. Login succeeds (state in JS memory), but navigating to another page triggers checkAuth() which can't send the cookie
  4. 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

Lessons Learned

  1. Bulk operations beat loops: Per-key wrangler kv key delete in a loop spawns N processes with N round-trips. wrangler kv bulk delete with a JSON file does it in one call.

  2. SameSite=None + Secure is required for cross-site auth: Lax fails silently on cross-origin fetches. Dual persistence—cookies for server-side validation, localStorage for instant UI recovery—is more reliable than either alone.

  3. 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.

  4. Email OTP minimizes auth friction for low-frequency visitors: No passwords to remember, just inbox access.

© 2026 Yuxu Ge ·