Verify GitHub webhook signatures and log deliveries to a database.
A webhook receiver that verifies GitHub's HMAC SHA-256 signature before trusting a request. GitHub signs every delivery with your webhook secret and sends it in the X-Hub-Signature-256 header; this service recomputes the signature over the raw request body and compares it in constant time, so an unsigned or tampered payload is rejected with a 401.
Verified deliveries are recorded in a linked libsql database (event type, delivery id and timestamp), and a GET request renders the most recent ones. The webhook secret is provided as the WEBHOOK_SECRET secret, and the database is attached automatically at deploy time as DELIVERIES_DB.
import { createClient } from "npm:@libsql/client@0.14.0/web"; // Receive GitHub webhooks, verify their signature, and keep a log of recent // deliveries in a libsql database. // // GitHub signs every delivery with your webhook secret using HMAC SHA-256 and // sends it in the `X-Hub-Signature-256: sha256=<hex>` header. We recompute the // signature over the RAW request body and compare it in constant time, then // record the delivery. Configure the WEBHOOK_SECRET secret with the same value // you set in the repository's webhook settings. // Read an environment variable, treating an unset or unreadable one as // undefined. Frontback scopes each service's env access to its declared secrets, so // a name that was never configured simply reads as "not set". function readEnv(name: string): string | undefined { try { return Deno.env.get(name); } catch { return undefined; } } // Open the deliveries database and make sure its table exists. The deploy links // a dedicated database as DELIVERIES_DB; if that link is absent the service falls // back to its own built-in database (DATABASE_URL). async function openDb() { const url = readEnv("DELIVERIES_DB") ?? readEnv("DATABASE_URL"); if (!url) throw new Error("no database URL in the environment"); const db = createClient({ url }); await db.execute( `CREATE TABLE IF NOT EXISTS deliveries ( id INTEGER PRIMARY KEY AUTOINCREMENT, delivery_id TEXT, event TEXT, received_at TEXT NOT NULL )`, ); return db; } // Recompute the HMAC SHA-256 of the raw body and compare it to the signature // header in constant time. async function verifySignature(secret: string, rawBody: string, header: string): Promise<boolean> { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( "raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const mac = await crypto.subtle.sign("HMAC", key, enc.encode(rawBody)); let hex = ""; for (const byte of new Uint8Array(mac)) hex += byte.toString(16).padStart(2, "0"); return timingSafeEqual("sha256=" + hex, header); } // Constant-time string comparison: differing lengths are never equal, and equal // lengths are compared without an early return so timing cannot leak the answer. function timingSafeEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } // Escape text before placing it into the HTML page. function escapeHtml(value: string): string { return value .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } export default async (req: Request): Promise<Response> => { // POST: a webhook delivery. Verify the signature BEFORE touching the database // so an unsigned request cannot make us do any work. if (req.method === "POST") { const secret = readEnv("WEBHOOK_SECRET"); if (!secret) return Response.json({ error: "WEBHOOK_SECRET is not set" }, { status: 500 }); // Read the RAW body exactly as received; the HMAC is computed over these bytes. const rawBody = await req.text(); const header = req.headers.get("x-hub-signature-256") ?? ""; if (!(await verifySignature(secret, rawBody, header))) { // An unsigned or tampered delivery is an expected rejection, so answer 401 // rather than a 5xx. return Response.json({ error: "invalid signature" }, { status: 401 }); } const event = req.headers.get("x-github-event") ?? "unknown"; const deliveryId = req.headers.get("x-github-delivery") ?? ""; const db = await openDb(); await db.execute({ sql: "INSERT INTO deliveries (delivery_id, event, received_at) VALUES (?, ?, datetime('now'))", args: [deliveryId, event], }); return Response.json({ ok: true, event }); } // GET: render the most recent verified deliveries. const db = await openDb(); const result = await db.execute( "SELECT delivery_id, event, received_at FROM deliveries ORDER BY id DESC LIMIT 20", ); const rows = result.rows .map((row) => { const cells = [row.received_at, row.event, row.delivery_id] .map((cell) => "<td>" + escapeHtml(String(cell ?? "")) + "</td>") .join(""); return "<tr>" + cells + "</tr>"; }) .join(""); const tableBody = rows || `<tr><td colspan="3">No deliveries yet.</td></tr>`; const html = `<!doctype html> <html lang="en"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <title>GitHub webhook deliveries</title> <style> body { font: 16px/1.5 system-ui, sans-serif; max-width: 48rem; margin: 3rem auto; padding: 0 1rem; } table { border-collapse: collapse; width: 100%; } th, td { text-align: left; padding: 0.4rem 0.6rem; border-bottom: 1px solid #e4e4e7; } code { background: #f4f4f5; padding: 0.1rem 0.3rem; border-radius: 3px; } </style> </head> <body> <h1>GitHub webhook</h1> <p>Point a repository webhook at this URL and set its secret as <code>WEBHOOK_SECRET</code>. Verified deliveries appear below.</p> <table> <thead><tr><th>Received</th><th>Event</th><th>Delivery</th></tr></thead> <tbody>${tableBody}</tbody> </table> </body> </html>`; return new Response(html, { headers: { "content-type": "text/html; charset=utf-8" } }); };
Go beyond what seems possible.