Twilio webhook signature validation in Express with TwiML replies: working versions (tested 2026-10-15)
Validate X-Twilio-Signature on inbound SMS webhooks in Express with twilio.validateRequest, including query strings and tampered params, and reply with MessagingResponse TwiML, tested offline with supertest.
passed5 passed · 0 failed2026-10-15 20:10 UTC- Status
- passed
- Last passed
- 2026-10-15 20:10 UTC
- Tests
- 5 / 5
- Language
- TypeScript
- Runtime
- node v22.23.3
npm install --no-audit --no-fund --ignore-scripts=false npm test
node v22.23.3 linux 6.17.0-1022-azure x64 runner: github-actions 38799498972
The code
Exactly the files that passed. Your agent can pull them with get_recipe("twilio-webhook-signature-express") (install), or see them on GitHub.
{
"name": "twilio-webhook-signature-express",
"private": true,
"type": "module",
"scripts": {
"test": "node --import tsx --test test/*.test.ts"
},
"dependencies": {
"twilio": "6.2.0",
"express": "5.3.0"
},
"devDependencies": {
"@types/node": "26.6.5",
"tsx": "4.23.15",
"supertest": "7.3.1"
}
}
import express from "express";
import twilio from "twilio";
const { MessagingResponse } = twilio.twiml;
/**
* Inbound SMS webhook. Twilio signs the full public URL plus the sorted POST params with your
* auth token (HMAC-SHA1, X-Twilio-Signature). Behind a proxy, rebuild the public URL yourself.
*/
export function makeApp(authToken: string, publicBaseUrl: string) {
const app = express();
app.use(express.urlencoded({ extended: false }));
app.post("/sms", (req, res) => {
const signature = req.header("x-twilio-signature") ?? "";
const url = publicBaseUrl + req.originalUrl;
if (!twilio.validateRequest(authToken, signature, url, req.body)) {
res.status(403).type("text/plain").send("invalid signature");
return;
}
const twiml = new MessagingResponse();
const text = String(req.body.Body ?? "").trim().toUpperCase();
twiml.message(text === "STATUS" ? "Class starts Nov 1st" : `Got: ${req.body.Body}`);
res.type("text/xml").send(twiml.toString());
});
return app;
}
import { test } from "node:test";
import assert from "node:assert/strict";
import request from "supertest";
import twilio from "twilio";
import { makeApp } from "../src/app.ts";
const TOKEN = "12345abcdef12345abcdef12345abcde";
const BASE = "https://example.ngrok.app";
const params = { From: "+19075550100", To: "+19075550199", Body: "status", MessageSid: "SM123" };
const sign = (url: string, p: Record<string, string>) => twilio.getExpectedTwilioSignature(TOKEN, url, p);
test("signature helper matches validateRequest", () => {
const sig = sign(`${BASE}/sms`, params);
assert.equal(twilio.validateRequest(TOKEN, sig, `${BASE}/sms`, params), true);
assert.equal(twilio.validateRequest(TOKEN, sig, `${BASE}/other`, params), false);
});
test("valid webhook gets TwiML reply", async () => {
const res = await request(makeApp(TOKEN, BASE))
.post("/sms")
.type("form")
.set("X-Twilio-Signature", sign(`${BASE}/sms`, params))
.send(params);
assert.equal(res.status, 200);
assert.match(res.headers["content-type"], /text\/xml/);
assert.equal(res.text, '<?xml version="1.0" encoding="UTF-8"?><Response><Message>Class starts Nov 1st</Message></Response>');
});
test("query string is part of the signed URL", async () => {
const sig = sign(`${BASE}/sms?tenant=aia`, { ...params, Body: "hi" });
const ok = await request(makeApp(TOKEN, BASE)).post("/sms?tenant=aia").type("form").set("X-Twilio-Signature", sig).send({ ...params, Body: "hi" });
assert.equal(ok.status, 200);
assert.match(ok.text, /<Message>Got: hi<\/Message>/);
});
test("tampered params are rejected", async () => {
const res = await request(makeApp(TOKEN, BASE))
.post("/sms")
.type("form")
.set("X-Twilio-Signature", sign(`${BASE}/sms`, params))
.send({ ...params, Body: "something else" });
assert.equal(res.status, 403);
});
test("wrong token or missing header is rejected", async () => {
const wrong = twilio.getExpectedTwilioSignature("x".repeat(32), `${BASE}/sms`, params);
const app = makeApp(TOKEN, BASE);
assert.equal((await request(app).post("/sms").type("form").set("X-Twilio-Signature", wrong).send(params)).status, 403);
assert.equal((await request(app).post("/sms").type("form").send(params)).status, 403);
});
Connect to ProvenPin
Add the free MCP server so your agent can pull this recipe and others like it. Same setup as the docs.
claude mcp add --transport http provenpin https://mcp.provenpin.com/mcp
{ "mcpServers": { "provenpin": { "url": "https://mcp.provenpin.com/mcp" } } }{ "servers": { "provenpin": { "type": "http", "url": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "serverUrl": "https://mcp.provenpin.com/mcp" } } }{ "mcpServers": { "provenpin": { "type": "streamableHttp", "url": "https://mcp.provenpin.com/mcp" } } }Any other MCP client: Streamable HTTP at https://mcp.provenpin.com/mcp. No API key, no signup.
Related stacks
express-validator request checks with supertest
Validate Express JSON bodies and query params with express-validator, returning field errors, tested with supertest offline.
Joi request validation middleware for Express 5
Validate Express 5 bodies and query strings with Joi schemas in a reusable middleware, with defaults, type conversion, stripUnknown and 400 error details, tested with supertest.
Express 5 + express-session login flow, e2e-style over real HTTP with a cookie jar
Session login/logout with express-session (regenerate on login, httpOnly cookies), tested end to end against a real listening server using fetch-cookie, no browser or Playwright.
Express 5 + Helmet + express-rate-limit hardening
Add security headers with Helmet and per-IP rate limiting with express-rate-limit to an Express 5 API.