Skip to content
← Back to blog

The Formward journal

Slack and Discord webhooks: stripping and signing

A form field is text a stranger typed, and a chat webhook posts it into your team channel. Mention stripping, signing and a secret webhook URL come first.

A form submission is text typed by someone you do not know, and a chat webhook posts that text into a channel your whole team reads. That is the entire security problem in one sentence. Getting the message to appear in Slack or Discord is easy; the setup takes a few minutes and is covered step by step in our integration guides. This post is about the four details that decide whether the integration is safe to leave running: stripping mentions, telling Discord to ignore them, signing the requests you receive on your own endpoints, and keeping the webhook URL secret.

Strip mentions and markup

Start with mentions. A spammer who finds your form can put @everyone or @here in a message field. If your backend interpolates that value straight into the payload, the platform sees a real mention and pings every member of the channel at three in the morning. Nothing was hacked; the platform did exactly what the text told it to do. The same goes for markup: a value containing asterisks or backticks can bold, italicise or open a code span in the middle of your notification and make a message look like it says something else.

Formward handles this before the payload is built. For the Slack, Discord and Mattermost formats, every field name and value is sanitised: the at sign is followed by a zero-width space, so @everyone renders as plain text and the platform never resolves it to a mention, and the characters &, < and > plus backticks and asterisks are escaped so a value cannot open a code span or fake bold text. The AI summary line gets the same treatment. The zero-width space is invisible to a reader but breaks the token the platform looks for, which is why it is the standard trick for this. The generic JSON payload is different on purpose: it carries the submitted values verbatim, because a program reading them should see exactly what was sent.

Tell Discord to ignore mentions

Text sanitising alone is a single layer, and a single layer is one regex away from a bypass. So Discord payloads carry a second one. Every Discord message Formward sends includes allowed_mentions with an empty parse list, which tells Discord not to resolve any @everyone, @here, role or user mention in that message regardless of what the content says. If a future change to the escaping ever missed a case, Discord would still refuse to notify anyone. If you build your own sender, copy the pairing: neutralise the text, and also tell the platform not to act on mentions.

Sign what reaches your own endpoints

Now the receiving side. Slack and Discord authenticate the incoming webhook by the secrecy of its URL, so they have nothing to check a signature against. Your own endpoints are different: anyone who learns the URL can POST forged submissions, and downstream automation will believe them. That is what the signing on generic webhooks is for. When a form has a signing secret, each generic delivery carries an X-Formward-Signature header shaped t=<unix-timestamp>,v1=<hex>, where the hex is an HMAC-SHA256 of the string v1:<timestamp>:<body> under that secret. Signing applies only to the generic payload, and only when a secret is set on the form.

Verifying takes four steps. Parse t and v1 from the header. Recompute the HMAC over v1:<t>:<rawBody> with your copy of the secret. Compare it to v1 with a constant-time comparison, not a plain equals. Then reject the request if t is further from the current time than your tolerance, for example five minutes. Binding the timestamp into the signed string is what stops replay: a captured delivery cannot be re-sent next week and still pass. Hash the raw body exactly as received; parsing the JSON and re-serialising it changes the bytes and the signature will never match.

import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody: the request body exactly as received, before any JSON parsing
function verify(rawBody, header, secret, toleranceSec = 300) {
  const m = /^t=(\d+),v1=([0-9a-f]+)$/.exec(header || "");
  if (!m) return false;
  const t = Number(m[1]);
  if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = createHmac("sha256", secret)
    .update("v1:" + t + ":" + rawBody)
    .digest("hex");
  const a = Buffer.from(expected, "hex");
  const b = Buffer.from(m[2], "hex");
  return a.length === b.length && timingSafeEqual(a, b);
}

One compatibility detail you may see in your logs: during the move to the timestamped scheme, signed deliveries also include an X-Formward-Signature-Legacy header holding the bare hex HMAC of the body. New code should verify the v1 header and ignore the legacy one. And if you rotate a signing secret, accept both the old and new secret for a short overlap so in-flight deliveries still verify; Formward's own verification helper takes a list of candidate secrets for exactly that reason.

Keep the webhook URL secret

Last, the URL itself. A Slack or Discord incoming webhook URL is a bearer credential: whoever holds it can post to your channel as the integration, with no other check. Treat it like a password. Keep it out of public repositories and screenshots, paste it only into the form's webhook settings, and regenerate it in Slack or Discord if it ever leaks. On the sending side Formward requires https, rejects URLs with embedded credentials, resolves the host and refuses any address in a private or reserved range, and does not follow redirects, so a webhook URL cannot be pointed at your internal network. If you write your own sender, add the same guards.

The data question

There is also a data question hiding in all of this. A webhook moves personal data out of your form backend into whatever platform you point it at, and from then on that platform's retention and data location apply. That can be a perfectly reasonable choice for an internal notification, but it is a choice, so record the destination and, for sensitive forms, send a short notification with a link back to the dashboard instead of every field.

Test it

To test it, send one real submission and watch it arrive. Then submit a message containing @everyone and confirm the channel shows it as inert text. For a generic endpoint, send a request with a wrong signature and an old timestamp and confirm your code rejects both. An integration you have only seen succeed is one you have not tested. For the click-by-click setup, use the Slack and Discord guides linked below.

About the author

The Formward Team builds privacy-first form infrastructure in Stockholm. Read about our security and privacy practices. Our approach follows the principles set out by the European Data Protection Board at edpb.europa.eu.

Slack and Discord webhooks: stripping and signing | Formward