DEV Community

Cover image for A Cookie-Free Embeddable Support Widget: What Adversarial Review Caught
Nasrul Hazim
Nasrul Hazim

Posted on

A Cookie-Free Embeddable Support Widget: What Adversarial Review Caught

TL;DR

  • Built an embeddable support widget for a helpdesk product: no cookies — a short-lived bearer token in a header, hashed at rest.
  • Entry is an HMAC-signed assertion from the host page. An adversarial review caught four real holes before launch.
  • Outbound webhooks: sign the exact bytes, dedupe key for idempotency, SSRF guard on destination URLs.

The requirement: end users file tickets from pages the product doesn't own. That means an embeddable widget — and embeddable means everything you know about sessions stops working.

Why cookie-free

The widget lives on customers' domains, so any cookie it sets is a third-party cookie — blocked or partitioned by modern browsers. Fighting that means flaky sessions, so: no cookies at all. The entry exchange mints a short-lived session token the widget sends in a header, and the server caches the session keyed by sha256(token) — a cache dump yields nothing replayable. Sessions last 60 minutes, and expiry shows a real recovery path in the UI instead of dying silently.

customer backend           widget (on customer page)         helpdesk API
   | signs ref|email|name     |                                  |
   | into HMAC assertion ---> |-- redeem assertion (single use) ->|
   |                          |<-- session token (60-min TTL) ---|
   |                          |-- X-Widget-Token: ... ---------->|
Enter fullscreen mode Exit fullscreen mode

What the adversarial review caught

Finding Fix
Replay burn keyed by client-chosen nonce Burn by HMAC signature — a leaked assertion can't mint extra sessions
`\ ` accepted inside signed fields
Origin check failed open when Origin/Referer absent Fall back to the unspoofable Sec-Fetch-Dest header to enforce embedding
Widget could request critical severity Clamp effective severity (including the channel default) to the widget's allowlist

My favourite is the delimiter one. If you sign ref|email|name and accept | inside a field, two different identity tuples can share one valid signature. Canonicalization bugs, not crypto bugs.

Webhooks out: sign the exact bytes

Outbound webhooks get composed once at enqueue time and stored; the delivery job re-encodes the stored payload with fixed JSON flags and signs those exact bytes. Nothing can mutate the payload after composition, so signature mismatches from re-serialization are extinct. On top: a dedupe_key with a unique index per channel so retry storms can't double-deliver, and an egress guard that blocks private, link-local, and metadata IPs (and refuses redirects) — destination URLs are operator-supplied, so SSRF is on the menu.

Render as text, not HTML

Agent replies are stored as HTML; the widget rendered them via textContent, so users saw literal <p> tags. The tempting fix is innerHTML — on a third-party page, that's an XSS bet. Instead: parse in an inert DOMParser document (no scripts run, nothing loads) and extract the text, keeping block structure:

function htmlToText(raw) {
    const doc = new DOMParser().parseFromString(String(raw), 'text/html');
    doc.body.querySelectorAll('p, div, li, br').forEach((node) => {
        node[node.tagName === 'BR' ? 'before' : 'append']('\n');
    });
    return doc.body.textContent.replace(/\n{3,}/g, '\n\n').trim();
}
Enter fullscreen mode Exit fullscreen mode

Test the paranoid paths

it('rejects identity fields containing the tuple delimiter', function () {
    widgetEntry(['email' => 'victim|attacker@evil.test'])->assertForbidden();
});

it('does not double-deliver a webhook for the same dedupe key', function () {
    $send = app(SendWebhook::class);
    $send->execute($channel, 'ticket.replied', $payload, "ticket.replied:{$uuid}");
    $send->execute($channel, 'ticket.replied', $payload, "ticket.replied:{$uuid}");

    expect(WebhookDelivery::count())->toBe(1);
});
Enter fullscreen mode Exit fullscreen mode

Takeaway

Every external surface is three jobs: who gets in (authn), what they can do (clamps and limits), and what you send out (signed, idempotent, egress-guarded). And book the adversarial review before launch — mine paid for itself four findings over.

Top comments (0)