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: ... ---------->|
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 |
Reject delimiter in field values |
| 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();
}
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);
});
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.
Comments
No comments yet. Start the discussion.