Add webhook HMAC signing token - backend and simple UI
What does this MR do and why?
This MR introduces a signing token for GitLab webhooks, enabling receivers to verify that webhook requests genuinely originate from GitLab and have not been tampered with in transit.
When a signing token is configured on a webhook, GitLab computes an HMAC-SHA256 signature over the concatenated string {webhook-id}.{timestamp}.{body} and sends it in the webhook-signature header (formatted as v1,<base64>). A webhook-id header (equal to the Idempotency-Key value) and a webhook-timestamp header are included alongside the signature.
The signing token is encrypted at rest and is never exposed in API responses or logs.
This MR ships the backend implementation and a minimal UI -- a password input field in the webhook form, similar to the existing secret token field -- gated behind the webhook_signing_token feature flag. The full state-machine UI component (WebhookTokenInput), following the designs in the issue, will be delivered in a follow-up MR.
Note:
webhook-signatureis not redacted in webhook logs because it is safe to expose -- it is a hash of the payload, not the signing token itself -- and remains useful for debugging signature validation issues.
Note: The signature header and format follow the Standard Webhooks specification: the
webhook-signatureheader containsv1,<base64>wherev1identifies the algorithm version and the value is the base64-encoded HMAC-SHA256 digest.
References
Screenshots or screen recordings
| Before | After |
|---|---|
![]() |
![]() |
How to set up and validate locally
Prerequisites
- GDK running at
https://gdk.test:3000 - Node.js installed locally
1. Create the webhook test server
In a new folder, create a file named webhook_test_server.js with the content below. This server receives GitLab webhooks, validates the HMAC-SHA256 signature using the standardwebhooks library, and logs results in a human-friendly format.
NodeJS Webhook Test Server
#!/usr/bin/env node
/**
* Webhook Test Server
*
* Receives GitLab webhooks, validates the HMAC-SHA256 signing token
* using the standardwebhooks library, and logs everything in a
* human-friendly format.
*
* Usage:
* npm install standardwebhooks
* SIGNING_TOKEN=whsec_bm9kZWpzLXRlc3Qtc2VydmVyLXNpZ25pbmctdG9rZW4= node webhook_test_server.js
* SIGNING_TOKEN=whsec_bm9kZWpzLXRlc3Qtc2VydmVyLXNpZ25pbmctdG9rZW4= PORT=8080 node webhook_test_server.js
*
* Then point your GitLab webhook at: http://localhost:8080 (or your PORT)
*/
const http = require('http');
const { Webhook } = require('standardwebhooks');
const PORT = process.env.PORT || 8080;
const SIGNING_TOKEN = process.env.SIGNING_TOKEN || '';
function divider(char = '-', width = 60) {
return char.repeat(width);
}
function now() {
return new Date().toISOString();
}
function prettyJson(obj) {
try {
return JSON.stringify(obj, null, 2)
.split('\n')
.map((line) => ` ${line}`)
.join('\n');
} catch {
return String(obj);
}
}
function validateSignature(signingToken, headers, body) {
if (!signingToken) return { valid: null, reason: 'no signing token configured' };
try {
new Webhook(signingToken).verify(body, headers);
return { valid: true };
} catch (err) {
return { valid: false, reason: err.message };
}
}
function timestampAge(ts) {
if (!ts) return null;
const age = Math.abs(Date.now() / 1000 - parseInt(ts, 10));
return Math.round(age);
}
function handleRequest(req, res) {
const chunks = [];
req.on('data', (chunk) => chunks.push(chunk));
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
const ts = req.headers['webhook-timestamp'];
const event = req.headers['x-gitlab-event'];
const sigResult = validateSignature(SIGNING_TOKEN, req.headers, rawBody);
const age = timestampAge(ts);
console.log('\n' + divider('='));
console.log(` Webhook received ${now()}`);
console.log(divider('-'));
console.log(` ${req.method} ${req.url}`);
if (event) console.log(` Event: ${event}`);
console.log('\n' + divider());
console.log(` Signature`);
console.log(divider());
if (sigResult.valid === null) {
console.log(` Skipped - ${sigResult.reason}`);
} else if (sigResult.valid) {
console.log(` [VALID] Valid signature`);
} else {
console.log(` [INVALID] ${sigResult.reason}`);
}
if (ts) {
console.log(` Timestamp: ${ts} (${age}s ago)`);
}
console.log('\n' + divider());
console.log(` Headers`);
console.log(divider());
const interestingHeaders = [
'x-gitlab-event',
'x-gitlab-webhook-uuid',
'webhook-timestamp',
'webhook-signature',
'webhook-id',
'x-gitlab-token',
'x-gitlab-instance',
'content-type',
'user-agent',
'idempotency-key',
];
for (const name of interestingHeaders) {
if (req.headers[name]) {
console.log(` ${name}: ${req.headers[name]}`);
}
}
const printed = new Set(interestingHeaders);
for (const [name, value] of Object.entries(req.headers)) {
if (!printed.has(name)) {
console.log(` ${name}: ${value}`);
}
}
console.log('\n' + divider());
console.log(` Body (${rawBody.length} bytes)`);
console.log(divider());
if (!rawBody) {
console.log(' (empty)');
} else {
try {
const parsed = JSON.parse(rawBody);
console.log(prettyJson(parsed));
} catch {
console.log(` ${rawBody}`);
}
}
console.log('\n' + divider('=') + '\n');
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'received', signature_valid: sigResult.valid }));
});
}
const server = http.createServer(handleRequest);
server.listen(PORT, () => {
console.log('\n' + divider('='));
console.log(` Webhook Test Server`);
console.log(divider('-'));
console.log(` Listening on http://localhost:${PORT}`);
if (SIGNING_TOKEN) {
console.log(` Signing token configured`);
} else {
console.log(` Signing token not set - signatures will be skipped`);
console.log(` Set SIGNING_TOKEN=<your-token> to enable validation`);
}
console.log(divider('-'));
console.log(` Point your GitLab webhook URL at: http://localhost:${PORT}`);
console.log(` Press Ctrl+C to stop`);
console.log(divider('=') + '\n');
});
server.on('error', (err) => {
console.error(`\nServer error: ${err.message}\n`);
process.exit(1);
});2. Install dependencies and start the test server
mise use nodejs@22.19.0
npm install standardwebhooks
SIGNING_TOKEN=whsec_bm9kZWpzLXRlc3Qtc2VydmVyLXNpZ25pbmctdG9rZW4= PORT=8080 node webhook_test_server.jsThe server will listen at http://localhost:8080. The SIGNING_TOKEN value must match what you configure in the webhook later.
3. Allow local network requests in GitLab
Go to Admin > Settings > Network > Outbound requests (/admin/application_settings/network#js-outbound-settings) and enable both:
- Allow requests to the local network from webhooks and integrations
- Allow requests to the local network from system hooks
4. Create a test webhook
Go to a project's webhook settings (e.g., https://gdk.test:3000/gitlab-org/gitlab-test/-/hooks) and click Add new webhook:
- URL:
http://localhost:8080 - Secret token (optional): any value to see it appear in request headers as
X-Gitlab-Token
Save the webhook.
5. Validate behavior with the feature flag disabled (default)
- Click Test and select any event type.
- Check the Node.js server logs --
webhook-signatureshould not be present, butwebhook-timestampshould appear (it is always sent).
6. Enable the feature flag
In a Rails console:
Feature.enable(:webhook_signing_token)7. Validate behavior with the feature flag enabled but no signing token set
- Click Test again.
webhook-signatureshould still not appear -- the signing token field is visible in the UI but not yet configured.
8. Validate a valid signature
- Edit the webhook and set the Signing token to
whsec_bm9kZWpzLXRlc3Qtc2VydmVyLXNpZ25pbmctdG9rZW4=(matching the server'sSIGNING_TOKEN). - Click Test and select any event type.
- The Node.js server should log:
webhook-signature: v1,xxxx- [VALID] Valid signature
9. Validate an invalid signature
- Edit the webhook and change the Signing token to any other value (e.g.,
whsec_YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=). - Click Test and select any event type.
- The Node.js server should log:
webhook-signature: v1,xxxx- [INVALID] with the reason from the library
MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.

