Verify Logged-In Chat Visitors With HMAC Signatures
How to verify logged-in chat visitors with HMAC-SHA256: sign identity on your server, keep secrets out of the browser, handle expiry and verify signed webhooks.
When a customer is logged in to your site, you want your chat operators to know who they are talking to. The tempting shortcut is to pass the user’s name and e-mail from the page to the chat widget in JavaScript. The problem is that anything the page can say, the visitor can say too. This guide explains how to verify logged-in visitors with HMAC signatures instead: your server signs the identity, the chat service checks the signature, and nobody can impersonate a customer by typing into the browser console. The examples use AgentlyDesk’s format and Node.js, but the reasoning applies to any chat tool that supports signed identity.
Why unsigned identity is dangerous
Imagine your page does this:
chat.identify({ id: "4821", email: "[email protected]", name: "Dana" });
Any visitor can open developer tools and run the same line with another customer’s details. Your operator now sees “Dana” in the console, with Dana’s past conversations, and may happily discuss Dana’s order, address or account with a stranger. No password was guessed and no server was breached; the chat simply believed what the browser told it.
The fix is to make the claim verifiable. The browser still delivers the identity, but it also delivers a proof that only your server could have produced.
How HMAC signing works
HMAC-SHA256 is a keyed hash. You feed it a secret key and a message, and it produces a fixed-length signature. Two properties matter here:
- Anyone with the key can produce the same signature for the same message, so the chat service can check it.
- Without the key, producing a valid signature for a new message is not feasible, so a visitor cannot forge one.
Your server and the chat service share a secret: the site’s identity key (in AgentlyDesk it starts with sk_). Your server signs the logged-in user’s details with it. The browser passes those details and the signature to the widget. The chat service recomputes the signature with its copy of the key and trusts the identity only if the two match.
Why the secret never goes to the browser
This is the one rule you cannot bend. If the identity key is in your front-end bundle, a page variable or a public config file, anyone can read it and sign any identity they like, and the whole scheme is worthless. The key belongs in your server’s environment, next to your database password, and nowhere else.
The browser only ever sees the output: the signature for this one user at this one moment. That signature is useless for signing anyone else.
The public site key in your embed code (pk_live_…) is different. It only tells the widget which site it belongs to, and it is designed to be public:
<script src="https://app.agentlydesk.com/widget.js" data-site-key="pk_live_xxx" async></script>
The exact message that gets signed
The signed message is these bytes, joined by newline characters:
identity.v2\n<ts>\n<id>\n<email>\n<name>
identity.v2is a fixed label. Webhook signatures use a different label (webhook.v1.), so a signature made for one purpose can never be accepted for the other, even if the same value were accidentally configured for both keys.tsis the signing time in unix seconds.idis your internal user ID, as a string.emailandnameare the user’s details. If you do not have one, sign it as an empty string; do not leave the line out.- No field may contain a newline, because newlines separate the fields.
Because the email and name are inside the signature, a visitor cannot keep a valid signature and swap in a different name. Change any byte and verification fails.
Expiry and replay
A signature that worked forever would be a liability: if one leaked from a shared computer or a log file, it could be replayed indefinitely. That is why the timestamp is signed. A signature is accepted only if it is no older than 24 hours and not more than 5 minutes in the future (the small window allows for clock differences). The practical consequence is simple: generate a fresh signature on each page load or session start, rather than storing one.
A Node.js server example
Here is a minimal Express endpoint that returns a signature for the logged-in user. Adapt the session lookup to your own auth.
import crypto from "node:crypto";
import express from "express";
const app = express();
const IDENTITY_KEY = process.env.AGENTLYDESK_IDENTITY_KEY; // sk_..., server-only
function clean(value) {
// Fields may not contain newlines; missing values are signed as "".
return String(value ?? "").replace(/[\r\n]/g, " ");
}
app.get("/api/chat/identity", (req, res) => {
const user = req.session?.user;
if (!user) return res.status(401).json({ error: "not logged in" });
const ts = Math.floor(Date.now() / 1000);
const id = clean(user.id);
const email = clean(user.email);
const name = clean(user.name);
const hash = crypto
.createHmac("sha256", IDENTITY_KEY)
.update(`identity.v2\n${ts}\n${id}\n${email}\n${name}`)
.digest("hex");
res.set("Cache-Control", "no-store");
res.json({ id, ts, hash, email, name });
});
A few details worth noticing:
- The endpoint only answers for the session’s own user. It never takes a user ID from the query string; otherwise anyone could ask it to sign someone else.
- The response is marked
no-storeso a shared cache never hands one user’s signature to another. - The values returned are exactly the values that were signed. If you clean or normalise a field, send the cleaned version to the browser too.
You can also render the same values directly into the page template on the server. Either approach works, as long as the signing happens on the server.
Calling identify in the browser
Once the page has the signed values, pass them to the widget:
const res = await fetch("/api/chat/identity", { credentials: "same-origin" });
if (res.ok) {
const { id, ts, hash, email, name } = await res.json();
AgentlyDesk.identify({ id, ts, hash, email, name });
}
If the signature does not verify, the visitor is not rejected; they simply stay anonymous, and operators see them marked as unverified. That makes mistakes safe: a bug in your signing code shows up as missing names, not as a wrong name.
Logout and account switching
Shared computers are common: a family laptop, a shop-floor terminal. When a user logs out or switches accounts, call:
AgentlyDesk.reset();
The widget starts a clean session, so the next person never inherits the previous person’s conversation. Put the call in the same code path that clears your own session.
Signed webhooks: the other direction
Signing identity protects the chat service from forged claims made by browsers. Webhooks protect your server from forged requests. When an operator turns a chat into a ticket, the chat service sends the conversation to your ticket endpoint with two headers:
X-Chat-Timestamp: <unix seconds>
X-Chat-Signature: sha256=<hex>
The hex value is HMAC-SHA256 with your webhook key (whsec_…) over webhook.v1.<timestamp>.<raw body>. To verify it:
function verifyWebhook(rawBody, timestamp, signatureHeader) {
const age = Math.floor(Date.now() / 1000) - Number(timestamp);
if (!Number.isFinite(age) || age > 300 || age < -300) return false;
const expected = "sha256=" + crypto
.createHmac("sha256", process.env.AGENTLYDESK_WEBHOOK_KEY)
.update(`webhook.v1.${timestamp}.${rawBody}`)
.digest("hex");
return signatureHeader.split(",").some((s) => {
const a = Buffer.from(s.trim());
const b = Buffer.from(expected);
return a.length === b.length && crypto.timingSafeEqual(a, b);
});
}
Why each part matters:
- Raw body. Verify the bytes exactly as they arrived, before JSON parsing. Parsing and re-serialising can change whitespace or key order and break the signature. In Express, use
express.raw({ type: "application/json" })on this route. - Timing-safe comparison. A normal string comparison can stop at the first differing character, which in principle leaks information through response timing.
crypto.timingSafeEqualtakes the same time regardless of where the difference is. - Freshness and replay. Reject timestamps older than five minutes, and remember signatures you have already accepted so the same request cannot be replayed within that window.
- Several signatures. During a webhook key rotation the header carries two signatures, newest first, separated by a comma. Accept the request if any of them matches, which is what the
split(",").some(...)does.
Your endpoint should reply with a 2xx status and a JSON body like { "ticketId": "...", "url": "..." }, where url may be null.
Why the identity key and webhook key are separate
It would be simpler to have one secret. It would also be worse:
- The code that verifies tickets should not hold a key that can mint customer identities. Separate keys keep each part of your system limited to what it needs.
- If one key leaks, the other is unaffected.
- Each key can be rotated on its own schedule.
The different labels (identity.v2 versus webhook.v1.) add a second layer: even if someone pasted the same value into both settings by mistake, a signature of one kind still could not pass as the other.
Rotating and revoking keys
Keys should be rotated from time to time and whenever someone with access leaves. There are two options. A routine rotation keeps the old key working for 24 hours, so you can deploy the new one without logged-in customers suddenly appearing anonymous or tickets being rejected. An instant revoke stops the old key immediately; use it when you know a key has leaked, and accept that verification will fail until the new key is live on your site.
Implementation checklist
- The identity key lives only in server environment variables.
- The signing endpoint signs only the current session’s user.
- Signatures are generated fresh per page load or session, with a unix-seconds timestamp.
- Missing email or name is signed as an empty string; fields contain no newlines.
AgentlyDesk.reset()runs on logout and account switch.- Webhooks are verified over the raw body, with a timing-safe comparison.
- Old timestamps and repeated signatures are rejected.
- Identity and webhook keys are separate, and you know how to rotate and revoke each.
More on how we handle keys, operator 2FA and tenant isolation is on the security page, and the setup flow is on how it works. If you are still comparing tools, our live chat widget checklist covers what else to ask.
To try signed identity on your own site, join early access.