← Back to Blog

Adding Passwordless Login and AI Comments to My Chat Widget

My personal website has a RAG-powered AI chat assistant in the bottom-right corner. Recently I wanted visitors to be able to leave comments through the same chat window. Traditional comment systems require complex backends and databases, and users hate creating accounts. To keep things lightweight and low-friction, I designed an email OTP passwordless auth system with two ways to comment: an explicit comment button and AI-powered auto-detection.

The entire system runs on the Cloudflare ecosystem: vanilla JavaScript frontend, Cloudflare Worker backend, KV for storage, and Resend API for emails. Comments are pulled from KV during the build process, merged incrementally into per-page JSON files, and rendered 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 in the chat header 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 comments, giving users both explicit control and seamless AI-powered 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 as a comment:

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 (via Send Button)

When a user sends a message through the regular send button, the AI also 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 LLM 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:

  • Green button: Click to guarantee a comment is saved
  • Red send button: Type naturally — "Great article!" is 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 of key names 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);

Why per-page files instead of a single comments.json?

  • Incremental updates: New comments are merged without overwriting. Manually deleting a comment from a JSON file won't be undone by the next build (since KV comments are deleted after pulling).
  • Smaller payloads: Each post only fetches its own comments, not the entire site's.
  • Clean structure: blog/comments/2026/chat-auth-comment-system.json maps naturally to the post URL.

Step 3: Display on Post Pages

Both the dynamic viewer (post.html) and static HTML pages fetch their per-page comment file and render a comments section 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 in the entire project. 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 is 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

  • 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 touch otp:*, sess:*, or user:* data

Lessons Learned

  1. Offer both explicit and implicit actions: The green comment button gives users certainty ("I clicked comment, it will be saved"), while AI auto-detection handles natural conversation flow. Users shouldn't have to think about modes.

  2. Bulk operations over loops: Per-key wrangler kv key delete in a loop spawns N processes with N network round-trips. wrangler kv bulk delete with a JSON file does it in one call. Always check if bulk APIs exist.

  3. Per-page files beat monolithic JSON: Splitting comments into blog/comments/2026/foo.json enables incremental merges, smaller payloads, and clean manual moderation (delete a file or edit entries).

  4. Understand SameSite cookie policies deeply: Lax works for same-site, but any cross-site fetch (especially local dev) requires None + Secure.

  5. Dual persistence (Cookie + localStorage) is more robust: Cookies handle server-side auth, localStorage handles instant client-side UI. They complement each other.

  6. Cloudflare KV works as a lightweight message queue: Write comments to KV, pull with a script, bulk delete after processing. Not a real queue, but perfect for a personal site.

  7. Email OTP beats passwords for low-frequency sites: Users don't need to remember passwords — just check their inbox. Minimal friction for occasional visitors.