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 |
| 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.
Top comments (0)