หนึ่ง Webhook Endpoint หลาย Tenant: route ด้วย "destination identity" ในตัว payload ไม่ใช่ hardcode ต่อร้าน
เมื่อ LINE หรือ payment gateway ยิง webhook มาจุดเดียว แต่คุณมีหลายร้าน หลาย OA — อย่าสร้าง endpoint ต่อ tenant บทความนี้อธิบายวิธี route ด้วย identity ในตัว payload พร้อมลำดับที่ปลอดภัย (verify ก่อน route เสมอ) กัน spoof ข้าม tenant
อ่าน ~8 นาที
ปัญหา: provider ยิงมาจุดเดียว แต่คุณมีร้อย tenant
ผมทำระบบ SaaS ไทยที่ลูกค้าเป็นร้านหลายร้าน ร้านหนึ่งเชื่อม LINE Official Account ของตัวเอง อีกกลุ่มใช้ OA กลางที่ผมเตรียมให้ ทั้งหมดนี้รวมกันหลายสิบหลายร้อย tenant ปัญหาที่โผล่มาตั้งแต่วันแรกคือ ผู้ให้บริการภายนอกไม่สนใจว่าผมมีกี่ร้าน LINE ยิง event เข้ามาที่ URL เดียว payment gateway ก็ callback มาที่ endpoint เดียว ทั้งที่ event นั้นเป็นของคนละร้าน คนละกระเป๋าเงิน คนละ secret
สัญชาตญาณแรกของ dev หลายคน (รวมผมในอดีต) คือทำ endpoint ต่อ tenant เช่น /webhook/shop-a, /webhook/shop-b เพราะมันตรงไปตรงมา แต่พอร้านที่ 30 สมัครเข้ามา คุณจะเห็นว่ามันไม่ scale เลย บทความนี้คือวิธีคิดที่ถูก คือ รับที่จุดเดียว แล้ว route ด้วย identity ที่อยู่ในตัว payload เอง
ทำไม endpoint ต่อ tenant ไม่ scale
การมี URL แยกต่อร้านฟังดูสะอาด แต่มันผูกภาระ operational เข้ากับจำนวน tenant แบบ 1:1 ซึ่งเป็นกับดัก
- Provisioning เป็นงานมือทุกครั้ง: ร้านใหม่ = ต้องไปสร้าง route ใหม่, ไป config webhook URL ใหม่ในหน้า console ของ provider ทีละร้าน ลืมร้านเดียว event หาย
- Config drift: เมื่อ route กระจายเป็นสิบเป็นร้อย แต่ละอันมี middleware/handler ของตัวเอง เวลาแก้ logic กลาง (เช่นเพิ่ม signature check) คุณต้องไล่แก้ทุกอัน — พลาดอันเดียวคือช่องโหว่
- Cert และ domain: ถ้า provider บังคับ HTTPS ต่อ callback URL การกระจาย subdomain ต่อร้านทำให้เรื่อง cert บานปลาย
หลักที่ผมยึดคือ จำนวน tenant ควรเป็น "ข้อมูล" ไม่ใช่ "โครงสร้างของ URL" เพิ่มร้าน = insert หนึ่งแถวใน DB ไม่ใช่ deploy route ใหม่
Destination-based routing: ดึง identity จาก payload แล้ว resolve tenant ตอน runtime
ข่าวดีคือ provider ที่ทำ multi-tenant มาดีจะแนบ destination identity มาในตัว payload เสมอ LINE ใส่ field destination (คือ bot user id ของ OA ปลายทาง) payment gateway ใส่ merchantId หรือ partnerId field พวกนี้คือกุญแจที่บอกว่า "event นี้ควรไปหา tenant ไหน"
แทนที่จะ hardcode ผมทำ resolve ตอน runtime คือรับ event → อ่าน destination → เปิดตาราง mapping หา tenant → โหลด config ของ tenant นั้นมาใช้
// ตาราง mapping: destination identity -> tenant
// เพิ่มร้านใหม่ = insert แถวเดียว ไม่ต้อง deploy อะไร
// { destination: 'U1234...', tenantId: 'shop_a', mode: 'own_oa' }
app.post('/webhook/line', async (req, res) => {
const body = req.body;
// 1) ดึง destination identity จาก payload — provider เป็นคนแนบมาให้
const destination = body?.destination;
if (!destination) {
console.warn('[webhook] no destination in payload');
return res.status(200).end(); // ตอบ 2xx กัน retry storm ดูหัวข้อ unknown ด้านล่าง
}
// 2) resolve tenant ตอน runtime (cache ได้ แต่ source of truth คือ DB)
const tenant = await tenants.findOne({ destination });
// ... (verify signature ก่อน route — ดูหัวข้อถัดไป ห้ามสลับลำดับ)
});
จุดสำคัญคือ handler เดียวรองรับทุก tenant สิ่งที่ต่างกันต่อร้านคือ ข้อมูล (config, credential) ที่โหลดมา ไม่ใช่ โค้ด logic การรับเหมือนกันหมด
ลำดับสำคัญ: verify signature ก่อน แล้วค่อย route (กัน spoof ข้าม tenant)
นี่คือหัวใจด้านความปลอดภัยที่คนพลาดบ่อยที่สุด destination identity ในตัว payload คือสิ่งที่คนส่งเป็นคนกรอก มันปลอมได้ ถ้าใครก็ได้ยิง JSON ที่ใส่ destination ของร้าน A เข้ามา แล้วคุณ route ไปทำงานในบริบทของร้าน A ทันที เท่ากับเปิดช่องให้ attacker สั่งงานข้าม tenant ได้เลย
ทางแก้คือ verify signature ให้ผ่านก่อน แล้วจึงเชื่อ destination LINE เซ็น body ด้วย channel secret ของ OA นั้น payment gateway เซ็นด้วย partner secret การ verify จึงพิสูจน์สองอย่างพร้อมกัน — ทั้งว่า payload ไม่ถูกแก้ และว่ามันมาจากเจ้าของ secret ของ tenant นั้นจริง
// ลำดับที่ปลอดภัย: resolve tenant -> โหลด secret ของ tenant -> verify -> ค่อย route
const tenant = await tenants.findOne({ destination });
if (!tenant) return handleUnknownDestination(destination, res);
// โหลด secret เฉพาะของ tenant นี้ แล้ว verify ด้วย secret ตัวนั้น
const secret = await loadTenantSecret(tenant.id);
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody) // ต้องใช้ raw bytes ไม่ใช่ object ที่ re-serialize แล้ว
.digest('base64');
const signature = req.headers['x-line-signature'];
if (!timingSafeEqual(expected, signature)) {
// ปลอม/ผิด secret -> ตัดทิ้ง ห้ามแตะ business logic ของ tenant นี้
console.warn(`[webhook] bad signature for tenant=${tenant.id}`);
return res.status(200).end();
}
// ผ่านแล้วเท่านั้น จึงเชื่อ destination และ route เข้า context ของ tenant
await routeToTenant(tenant, body);
return res.status(200).end();
สังเกตว่าผมใช้ secret ของ tenant ที่ resolve ได้ มา verify ถ้า attacker ใส่ destination ของร้าน A แต่ไม่มี secret ของร้าน A เขาเซ็นให้ผ่านไม่ได้ signature check จะ fail และ payload ก็ตกไปก่อนแตะข้อมูลร้าน A แม้แต่นิดเดียว การเรียงลำดับผิด (route ก่อน verify) คือช่องโหว่ cross-tenant ที่ audit ไม่ค่อยจับเพราะ "มันก็ทำงานได้"
Per-tenant secret lookup: เก็บ key ที่ไหน decrypt ตอนไหน
เมื่อแต่ละ tenant มี secret ของตัวเอง คุณต้องมีที่เก็บที่ scale ตามจำนวนร้าน ผมเก็บ credential ของแต่ละ tenant เข้ารหัส at-rest ในฐานข้อมูล (per-tenant encryption) ไม่ใช่กอง plaintext ใน env var เดียว เพราะ env var ไม่ scale ต่อร้าน และถ้า DB หลุด ของยัง encrypted อยู่
- เก็บ: ciphertext ของ secret ต่อ tenant ใน DB โดย master key อยู่ที่ secret manager ไม่ใช่ในตาราง
- Decrypt: ทำ lazy ตอนต้องใช้จริงเท่านั้น (ตอน verify signature ของ event นั้น) แล้วถือ plaintext ไว้ในหน่วยความจำสั้นที่สุด อย่า decrypt ทุกร้านล่วงหน้า
- Cache อย่างระวัง: จะ cache decrypted secret เพื่อ performance ก็ได้ แต่ต้องมี TTL และ scope ต่อ tenant ชัดเจน อย่าให้ secret ของร้านหนึ่งรั่วไป context ของอีกร้าน
หลักคิด: secret เป็น data ต่อ tenant เหมือน config การเพิ่มร้าน = เพิ่มแถว ciphertext ไม่ใช่แก้ไฟล์ deploy
Unknown destination: fail-closed ไม่ใช่ drop เงียบ
จะมีวันที่ event เข้ามาด้วย destination ที่ไม่มีในตาราง — อาจเป็นร้านที่เพิ่ง disconnect, config ค้าง, หรือมีคนยิงมั่ว คำถามคือทำยังไงกับมัน
คำตอบที่ถูกคือ fail-closed: ไม่ route ไปไหน ไม่เดาว่าน่าจะเป็นร้านไหน แต่ ต้อง log ให้ตรวจสอบได้ ไม่ใช่ทิ้งเงียบ การ drop เงียบคือหลุมดำที่ debug ไม่ได้ — วันหนึ่งลูกค้าโวยว่า event หาย คุณจะไม่มีร่องรอยเลยว่ามันเคยเข้ามา
function handleUnknownDestination(destination, res) {
// fail-closed: ไม่ route ไม่เดา แต่ต้องมีร่องรอย
console.warn(`[webhook] unknown destination=${destination} — not routed`);
metrics.increment('webhook.unknown_destination', { destination });
// ยังตอบ 2xx ให้ provider (ไม่งั้นมัน retry วนถล่ม)
// แต่ event ถูกบันทึกไว้ในชั้น log/metric เพื่อสอบย้อนหลัง
return res.status(200).end();
}
จุดที่ subtle: fail-closed ในแง่ business (ไม่ทำงานให้ tenant ที่ไม่รู้จัก) แต่ยังตอบ 2xx ในแง่ transport เพื่อไม่ให้เกิด retry storm — สองมิตินี้แยกกัน
Idempotency ต่อ tenant: dedup key ต้องผูก tenant + event id
Webhook ถูก deliver ซ้ำเป็นเรื่องปกติ ทางแก้คือ idempotency แต่ในโลก multi-tenant มี pitfall เพิ่มมาหนึ่งชั้น: event id อาจไม่ unique ข้าม tenant ร้าน A กับร้าน B อาจมี webhookEventId เลขเดียวกันโดยบังเอิญ (โดยเฉพาะถ้า provider gen id ต่อ channel) ถ้าคุณ dedup ด้วย event id ล้วน event ของร้าน B จะถูกมองว่าเป็น "ซ้ำ" ของร้าน A แล้วถูกข้ามทั้งที่มันคนละงาน
กฎคือ dedup key = tenantId + eventId เสมอ ไม่ใช่ eventId ลอยๆ
// unique index บน (tenantId, eventId) — ไม่ใช่ eventId ตัวเดียว
// db.events.createIndex({ tenantId: 1, eventId: 1 }, { unique: true })
async function processOnce(tenant, event) {
try {
await db.events.insertOne({
tenantId: tenant.id, // ผูก tenant เข้าไปใน dedup key
eventId: event.id,
status: 'processing',
createdAt: new Date(),
});
} catch (err) {
if (err.code === 11000) { // duplicate: tenant นี้เคยรับ event นี้แล้ว
return; // no-op ปลอดภัย
}
throw err;
}
await doWork(tenant, event);
}
Observability: แยก metric และ error ต่อ tenant
เมื่อทุก tenant ไหลผ่าน endpoint เดียว log ที่ไม่ติด tenant tag คือฝันร้ายตอน debug ลูกค้าร้านหนึ่งบอกว่า "จองแล้วไม่มี noti" คุณต้องหา event ของร้านนั้นให้เจอในกองรวมของทุกร้าน
สิ่งที่ผมทำคือ ติด tenant id เป็น dimension ในทุก log และ metric ตั้งแต่จุดรับ ทำให้ query ต่อร้านได้ และตั้ง alert แบบแยกร้านได้ เช่นถ้า signature-fail rate ของร้านเดียวพุ่ง แปลว่า secret ของร้านนั้นอาจหมดอายุหรือถูกแก้ ไม่ใช่ระบบพังทั้งระบบ
- log ทุกบรรทัดมี
tenant=<id>และ correlation id ของ event - metric แยก label ต่อ tenant: จำนวน event, signature fail, unknown destination, latency
- error ต่อ tenant ต้อง isolate — ร้านหนึ่ง config พังต้องไม่ทำให้ handler ของทั้งระบบ throw
สรุป
- อย่าสร้าง endpoint ต่อ tenant — จำนวนร้านควรเป็นข้อมูลใน DB ไม่ใช่โครงสร้างของ URL เพิ่มร้าน = insert หนึ่งแถว
- Route ด้วย destination identity ในตัว payload (bot id / merchant id) resolve tenant ตอน runtime แล้วโหลด config + credential ของร้านนั้น
- Verify signature ก่อน route เสมอ — destination ปลอมได้ ต้อง verify ด้วย secret ของ tenant ที่ resolve ได้ก่อน ถึงจะเชื่อและ route มิฉะนั้นเปิดช่อง cross-tenant spoof
- เก็บ secret ต่อ tenant แบบ encrypt at-rest ใน DB decrypt lazy ตอนใช้ ถือ plaintext สั้นที่สุด อย่ากอง plaintext ใน env เดียว
- Unknown destination = fail-closed แต่ log — ไม่ route ไม่เดา แต่ต้องมีร่องรอยตรวจย้อนได้ ยังตอบ 2xx กัน retry storm
- Dedup key = tenantId + eventId ไม่ใช่ eventId ลอยๆ เพราะ id ชนกันข้าม tenant ได้
- ติด tenant id ในทุก log/metric เพื่อ debug และตั้ง alert แยกร้านได้จริง




