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-signature is 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-signature header contains v1,<base64> where v1 identifies the algorithm version and the value is the base64-encoded HMAC-SHA256 digest.

References

#19367

Screenshots or screen recordings

Before After
Screenshot_2026-04-13_at_14.55.13 Screenshot_2026-04-13_at_14.54.32

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.js

The 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)

  1. Click Test and select any event type.
  2. Check the Node.js server logs -- webhook-signature should not be present, but webhook-timestamp should 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

  1. Click Test again.
  2. webhook-signature should still not appear -- the signing token field is visible in the UI but not yet configured.

8. Validate a valid signature

  1. Edit the webhook and set the Signing token to whsec_bm9kZWpzLXRlc3Qtc2VydmVyLXNpZ25pbmctdG9rZW4= (matching the server's SIGNING_TOKEN).
  2. Click Test and select any event type.
  3. The Node.js server should log:
    • webhook-signature: v1,xxxx
    • [VALID] Valid signature

9. Validate an invalid signature

  1. Edit the webhook and change the Signing token to any other value (e.g., whsec_YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE=).
  2. Click Test and select any event type.
  3. 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.

Edited by Rodrigo Tomonari

Merge request reports

Loading