Repository: backstage/backstage
Pull request #29721: leverage webhook secrets from github integrations too
Author: login: freben
Labels: name: area:events
description: Related to the Events Project Area
Created: 2025-04-24T13:03:24Z | Merged: 2025-04-28T15:13:49Z

Fixes #27598

Linked issue #27598: 🚀 Feature: Support for multiple GitHub webhook secrets
### 🔖 Feature description

Support to configure the [`events-backend-module-github`](https://github.com/backstage/backstage/tree/master/plugins/events-backend-module-github) plugin with multiple webhook secrets would be very useful as currently only one webhook secret can be used to validate incoming webhooks.

### 🎤 Context

I have a Backstage instance configured with two private GitHub apps, each installed in a different GitHub organization. Backstage handles this quite well already, but it does not support validating the webhook callbacks from both app installations if their webhook secret differs.

### ✌️ Possible Implementation

AFAICT the [`integrations.github.[].apps.[].webhookSecret`](https://github.com/backstage/backstage/blob/7bedf44bf6541a0b7eaeffb481a0ed4912a6bc55/packages/integration/src/github/config.ts#L95) is entirely unused. I propose using any configured GitHub app webhook secret values in addition to `events.modules.github.webhookSecret`, making the latter optional if any apps are configured.

### 👀 Have you spent some time to check if this feature request has been raised before?

- [X] I checked and didn't find similar issue

### 🏢 Have you read the Code of Conduct?

- [X] I have read the [Code of Conduct](https://github.com/backstage/backstage/blob/master/CODE_OF_CONDUCT.md)

### Are you willing to submit PR?

Yes I am willing to submit a PR!
Linked issue comments: user: login: vinzscam
created_at: 2024-11-14T11:40:51Z
body: yeah it makes sense. Would you like to be assigned @jawilson?
author_association: MEMBER
user: login: jawilson
created_at: 2024-11-20T05:07:15Z
body: @vinzscam yup, I can take it. I already have the change written, just need to figure out a good way to test and go through the proper contribution guidelines.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-01-20T13:53:42Z
body: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
author_association: CONTRIBUTOR
user: login: camilaibs
created_at: 2025-01-23T07:59:05Z
body: Hello @jawilson  👋🏻, just a gentle ping.
Is it still possible for you to contribute with us?
It would be greatly appreciated if you could help us in resolving this issue 🙂 .
author_association: CONTRIBUTOR
user: login: pjungermann
created_at: 2025-02-19T23:30:31Z
body: @jawilson are you still on that issue?

Your proposed solution sounds great to move forward with. If you are not on it anymore, I can give it a go.
author_association: CONTRIBUTOR
user: login: jawilson
created_at: 2025-02-20T20:19:12Z
body: Sorry about the delay @camilaibs and @pjungermann. I still haven't had the chance to meet all of the contribution guidelines. That said, my changes are available [here](https://github.com/backstage/backstage/compare/master...blinemedical:backstage-fork:use-websocket-secret-from-github-app?expand=1) if someone would like to see it through.
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-04-21T20:20:54Z
body: This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
author_association: CONTRIBUTOR

Diff:
diff --git a/.changeset/large-experts-sort.md b/.changeset/large-experts-sort.md
new file mode 100644
index 00000000000000..6d58836db78e59
--- /dev/null
+++ b/.changeset/large-experts-sort.md
@@ -0,0 +1,7 @@
+---
+'@backstage/plugin-events-backend-module-github': minor
+---
+
+**BREAKING**: Removed the `createGithubSignatureValidator` export.
+
+Added support webhook validation based on `integrations.github.[].apps.[].webhookSecret`.
diff --git a/plugins/events-backend-module-github/package.json b/plugins/events-backend-module-github/package.json
index 13ab583c0c3f9d..f03ba438fb591d 100644
--- a/plugins/events-backend-module-github/package.json
+++ b/plugins/events-backend-module-github/package.json
@@ -45,8 +45,13 @@
   "dependencies": {
     "@backstage/backend-plugin-api": "workspace:^",
     "@backstage/config": "workspace:^",
+    "@backstage/integration": "workspace:^",
     "@backstage/plugin-events-node": "workspace:^",
-    "@octokit/webhooks-methods": "^3.0.0"
+    "@backstage/types": "workspace:^",
+    "@octokit/auth-callback": "^5.0.0",
+    "@octokit/webhooks-methods": "^3.0.0",
+    "lodash": "^4.17.21",
+    "octokit": "^3.0.0"
   },
   "devDependencies": {
     "@backstage/backend-test-utils": "workspace:^",
diff --git a/plugins/events-backend-module-github/report.api.md b/plugins/events-backend-module-github/report.api.md
index 8d1f4d939f9f90..afecb7590bc654 100644
--- a/plugins/events-backend-module-github/report.api.md
+++ b/plugins/events-backend-module-github/report.api.md
@@ -4,17 +4,10 @@
 
 ```ts
 import { BackendFeature } from '@backstage/backend-plugin-api';
-import { Config } from '@backstage/config';
 import { EventParams } from '@backstage/plugin-events-node';
 import { EventsService } from '@backstage/plugin-events-node';
-import { RequestValidator } from '@backstage/plugin-events-node';
 import { SubTopicEventRouter } from '@backstage/plugin-events-node';
 
-// @public
-export function createGithubSignatureValidator(
-  config: Config,
-): RequestValidator | undefined;
-
 // @public (undocumented)
 const _default: BackendFeature;
 export default _default;
diff --git a/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.test.ts b/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.test.ts
index bb604e88d8d4d4..9cb2ed3c4ce361 100644
--- a/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.test.ts
+++ b/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.test.ts
@@ -22,6 +22,7 @@ import {
 } from '@backstage/plugin-events-node';
 import { sign } from '@octokit/webhooks-methods';
 import { createGithubSignatureValidator } from './createGithubSignatureValidator';
+import { OctokitProviderService } from '../util/octokitProviderService';
 
 class TestContext implements RequestValidationContext {
   #details?: Partial<RequestRejectionDetails>;
@@ -35,6 +36,10 @@ class TestContext implements RequestValidationContext {
   }
 }
 
+const octokitProvider = {
+  getOctokit: jest.fn(),
+} satisfies OctokitProviderService;
+
 describe('createGithubSignatureValidator', () => {
   const secret = 'valid-secret';
   const configWithoutSecret = new ConfigReader({});
@@ -47,8 +52,31 @@ describe('createGithubSignatureValidator', () => {
       },
     },
   });
-  const payloadString = '{"test": "payload", "score": 5.0}';
-  const payload = JSON.parse(payloadString);
+  const configWithAppSecret = new ConfigReader({
+    integrations: {
+      github: [
+        {
+          host: 'github.com',
+          apps: [
+            {
+              appId: 7,
+              privateKey: 'a',
+              clientId: 'b',
+              clientSecret: 'c',
+              webhookSecret: secret,
+            },
+          ],
+        },
+      ],
+    },
+  });
+  const payload = {
+    test: 'payload',
+    score: 5.0,
+    repository: { html_url: 'https://github.com/backstage/backstage' },
+    installation: { id: 70 },
+  };
+  const payloadString = JSON.stringify(payload);
   const payloadBuffer = Buffer.from(payloadString);
   const validSignature = sign({ secret, algorithm: 'sha256' }, payloadString);
 
@@ -66,16 +94,19 @@ describe('createGithubSignatureValidator', () => {
   };
 
   it('should return undefined if no secret is configured', async () => {
-    expect(createGithubSignatureValidator(configWithoutSecret)).toEqual(
-      undefined,
-    );
+    expect(
+      createGithubSignatureValidator(configWithoutSecret, octokitProvider),
+    ).toEqual(undefined);
   });
 
   it('secret configured, reject request without signature', async () => {
     const request = await requestWithSignature(undefined);
     const context = new TestContext();
 
-    const validator = createGithubSignatureValidator(configWithSecret);
+    const validator = createGithubSignatureValidator(
+      configWithSecret,
+      octokitProvider,
+    );
     await validator!(request, context);
 
     expect(context.details).not.toBeUndefined();
@@ -87,7 +118,10 @@ describe('createGithubSignatureValidator', () => {
     const request = await requestWithSignature('invalid signature');
     const context = new TestContext();
 
-    const validator = createGithubSignatureValidator(configWithSecret);
+    const validator = createGithubSignatureValidator(
+      configWithSecret,
+      octokitProvider,
+    );
     await validator!(request, context);
 
     expect(context.details).not.toBeUndefined();
@@ -99,7 +133,32 @@ describe('createGithubSignatureValidator', () => {
     const request = await requestWithSignature(await validSignature);
     const context = new TestContext();
 
-    const validator = createGithubSignatureValidator(configWithSecret);
+    const validator = createGithubSignatureValidator(
+      configWithSecret,
+      octokitProvider,
+    );
+    await validator!(request, context);
+
+    expect(context.details).toBeUndefined();
+  });
+
+  it('secret configured, accept request with valid signature defined in integrations', async () => {
+    const request = await requestWithSignature(await validSignature);
+    const context = new TestContext();
+    octokitProvider.getOctokit.mockResolvedValue({
+      rest: {
+        apps: {
+          getInstallation: async () => ({
+            data: { app_id: 7 },
+          }),
+        },
+      },
+    });
+
+    const validator = createGithubSignatureValidator(
+      configWithAppSecret,
+      octokitProvider,
+    );
     await validator!(request, context);
 
     expect(context.details).toBeUndefined();
diff --git a/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts b/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts
index f54c00b58a8d95..6eb203b79bc8dd 100644
--- a/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts
+++ b/plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts
@@ -15,12 +15,15 @@
  */
 
 import { Config } from '@backstage/config';
+import { ScmIntegrations } from '@backstage/integration';
 import {
   RequestDetails,
   RequestValidationContext,
   RequestValidator,
 } from '@backstage/plugin-events-node';
 import { verify } from '@octokit/webhooks-methods';
+import { createAppIdResolver } from '../util/createAppIdResolver';
+import { OctokitProviderService } from '../util/octokitProviderService';
 
 /**
  * Validates that the request received is the expected GitHub request
@@ -35,14 +38,31 @@ import { verify } from '@octokit/webhooks-methods';
  */
 export function createGithubSignatureValidator(
   config: Config,
+  octokitProvider: OctokitProviderService,
 ): RequestValidator | undefined {
-  const secret = config.getOptionalString(
+  const integrations = ScmIntegrations.fromConfig(config);
+
+  // GitHub App installation ID to secret
+  const githubAppSecrets = new Map<number, string>();
+  for (const integration of integrations.github.list()) {
+    for (const { appId, webhookSecret } of integration.config.apps ?? []) {
+      if (appId && webhookSecret) {
+        githubAppSecrets.set(appId, webhookSecret);
+      }
+    }
+  }
+
+  // A single optional secret for all GitHub events
+  const genericSecret = config.getOptionalString(
     'events.modules.github.webhookSecret',
   );
-  if (!secret) {
+
+  if (!genericSecret && githubAppSecrets.size === 0) {
     return undefined;
   }
 
+  const appIdResolver = createAppIdResolver(octokitProvider);
+
   return async (
     request: RequestDetails,
     context: RequestValidationContext,
@@ -51,18 +71,28 @@ export function createGithubSignatureValidator(
       | string
       | undefined;
 
-    if (
-      !signature ||
-      !(await verify(
-        secret,
-        request.raw.body.toString(request.raw.encoding),
-        signature,
-      ))
-    ) {
-      context.reject({
-        status: 403,
-        payload: { message: 'invalid signature' },
-      });
+    if (signature) {
+      const body = request.raw.body.toString(request.raw.encoding);
+
+      if (githubAppSecrets.size) {
+        const appId = await appIdResolver(request);
+        if (appId && githubAppSecrets.has(appId)) {
+          if (await verify(githubAppSecrets.get(appId)!, body, signature)) {
+            return;
+          }
+        }
+      }
+
+      if (genericSecret) {
+        if (await verify(genericSecret, body, signature)) {
+          return;
+        }
+      }
     }
+
+    context.reject({
+      status: 403,
+      payload: { message: 'invalid signature' },
+    });
   };
 }
diff --git a/plugins/events-backend-module-github/src/index.ts b/plugins/events-backend-module-github/src/index.ts
index 72b55f892bdab6..285d74bae48191 100644
--- a/plugins/events-backend-module-github/src/index.ts
+++ b/plugins/events-backend-module-github/src/index.ts
@@ -20,6 +20,7 @@
  *
  * @packageDocumentation
  */
+
 import { createBackendFeatureLoader } from '@backstage/backend-plugin-api';
 
 export default createBackendFeatureLoader({
@@ -31,5 +32,8 @@ export default createBackendFeatureLoader({
   },
 });
 
-export { createGithubSignatureValidator } from './http/createGithubSignatureValidator';
+// TODO(freben): This is not exported at the moment since it depends on the octokit provider.
+// Until we have made that a core thing in integrations, we can't export it
+// export { createGithubSignatureValidator } from './http/createGithubSignatureValidator';
+
 export { GithubEventRouter } from './router/GithubEventRouter';
diff --git a/plugins/events-backend-module-github/src/service/eventsModuleGithubWebhook.ts b/plugins/events-backend-module-github/src/service/eventsModuleGithubWebhook.ts
index 5cb9c88679b529..78def1f30a08e2 100644
--- a/plugins/events-backend-module-github/src/service/eventsModuleGithubWebhook.ts
+++ b/plugins/events-backend-module-github/src/service/eventsModuleGithubWebhook.ts
@@ -20,6 +20,7 @@ import {
 } from '@backstage/backend-plugin-api';
 import { eventsExtensionPoint } from '@backstage/plugin-events-node/alpha';
 import { createGithubSignatureValidator } from '../http/createGithubSignatureValidator';
+import { octokitProviderServiceRef } from '../util/octokitProviderService';
 
 /**
  * Module for the events-backend plugin,
@@ -36,9 +37,13 @@ export default createBackendModule({
       deps: {
         config: coreServices.rootConfig,
         events: eventsExtensionPoint,
+        octokitProvider: octokitProviderServiceRef,
       },
-      async init({ config, events }) {
-        const validator = createGithubSignatureValidator(config);
+      async init({ config, events, octokitProvider }) {
+        const validator = createGithubSignatureValidator(
+          config,
+          octokitProvider,
+        );
         if (validator) {
           events.addHttpPostIngress({
             topic: 'github',
diff --git a/plugins/events-backend-module-github/src/util/createAppIdResolver.ts b/plugins/events-backend-module-github/src/util/createAppIdResolver.ts
new file mode 100644
index 00000000000000..d5347ffd2939a3
--- /dev/null
+++ b/plugins/events-backend-module-github/src/util/createAppIdResolver.ts
@@ -0,0 +1,66 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { RequestDetails } from '@backstage/plugin-events-node';
+import { OctokitProviderService } from './octokitProviderService';
+import lodash from 'lodash';
+
+export type AppIdResolver = (
+  request: RequestDetails,
+) => Promise<number | undefined>;
+
+/**
+ * Helps with resolving what app ID (if any) that sent a webhook event.
+ */
+export function createAppIdResolver(
+  octokitProvider: OctokitProviderService,
+): AppIdResolver {
+  const installationIdToAppId = new Map<number, Promise<number | undefined>>();
+
+  return async (request: RequestDetails) => {
+    const installationId = lodash.get(
+      request.body,
+      'installation.id',
+    ) as unknown;
+
+    if (!installationId || typeof installationId !== 'number') {
+      return undefined;
+    }
+
+    let appIdPromsie = installationIdToAppId.get(installationId);
+    if (appIdPromsie) {
+      return await appIdPromsie;
+    }
+
+    const repositoryUrl = lodash.get(
+      request.body,
+      'repository.html_url',
+    ) as unknown;
+
+    if (!repositoryUrl || typeof repositoryUrl !== 'string') {
+      return undefined;
+    }
+
+    const octokit = await octokitProvider.getOctokit(repositoryUrl);
+    appIdPromsie = octokit.rest.apps
+      .getInstallation({ installation_id: installationId })
+      .then(response => Number(response.data.app_id))
+      .catch(() => undefined);
+
+    installationIdToAppId.set(installationId, appIdPromsie);
+    return await appIdPromsie;
+  };
+}
diff --git a/plugins/events-backend-module-github/src/util/octokitProviderService.ts b/plugins/events-backend-module-github/src/util/octokitProviderService.ts
new file mode 100644
index 00000000000000..9993b910002599
--- /dev/null
+++ b/plugins/events-backend-module-github/src/util/octokitProviderService.ts
@@ -0,0 +1,104 @@
+/*
+ * Copyright 2025 The Backstage Authors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+  coreServices,
+  createServiceFactory,
+  createServiceRef,
+  RootConfigService,
+} from '@backstage/backend-plugin-api';
+import {
+  DefaultGithubCredentialsProvider,
+  GithubCredentialsProvider,
+  ScmIntegrationRegistry,
+  ScmIntegrations,
+} from '@backstage/integration';
+import { durationToMilliseconds, HumanDuration } from '@backstage/types';
+import { Octokit } from 'octokit';
+
+export interface OctokitProviderService {
+  getOctokit: (url: string) => Promise<Octokit>;
+}
+
+class OctokitProviderImpl implements OctokitProviderService {
+  readonly #integrations: ScmIntegrationRegistry;
+  readonly #githubCredentials: GithubCredentialsProvider;
+  readonly #octokitCache: Map<string, Octokit>;
+  readonly #octokitCacheTtl: HumanDuration;
+
+  constructor(config: RootConfigService) {
+    this.#integrations = ScmIntegrations.fromConfig(config);
+    this.#githubCredentials = DefaultGithubCredentialsProvider.fromIntegrations(
+      this.#integrations,
+    );
+    this.#octokitCache = new Map();
+    this.#octokitCacheTtl = { hours: 1 };
+  }
+
+  async getOctokit(url: string): Promise<Octokit> {
+    // TODO(freben): Be smart and cache these more granularly, e.g. by
+    // organization or even repo.
+    const integration = this.#integrations.github.byUrl(url);
+    if (!integration) {
+      throw new Error(`No integration found for url: ${url}`);
+    }
+    const key = integration.config.host;
+
+    if (this.#octokitCache.has(key)) {
+      return this.#octokitCache.get(key)!;
+    }
+
+    const { createCallbackAuth } = await import('@octokit/auth-callback');
+
+    const octokit = new Octokit({
+      baseUrl: integration.config.apiBaseUrl,
+      authStrategy: createCallbackAuth,
+      auth: {
+        callback: async () => {
+          try {
+            const credentials = await this.#githubCredentials.getCredentials({
+              url,
+            });
+            return credentials.token;
+          } catch {
+            return undefined;
+          }
+        },
+      },
+    });
+
+    this.#octokitCache.set(key, octokit);
+    setTimeout(() => {
+      this.#octokitCache.delete(key);
+    }, durationToMilliseconds(this.#octokitCacheTtl));
+
+    return octokit;
+  }
+}
+
+export const octokitProviderServiceRef =
+  createServiceRef<OctokitProviderService>({
+    id: 'octokitProvider',
+    scope: 'root',
+    defaultFactory: async service =>
+      createServiceFactory({
+        service,
+        deps: { config: coreServices.rootConfig },
+        async factory({ config }) {
+          return new OctokitProviderImpl(config);
+        },
+      }),
+  });
diff --git a/yarn.lock b/yarn.lock
index fac71e878929de..876222dd6ec673 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6576,9 +6576,14 @@ __metadata:
     "@backstage/backend-test-utils": "workspace:^"
     "@backstage/cli": "workspace:^"
     "@backstage/config": "workspace:^"
+    "@backstage/integration": "workspace:^"
     "@backstage/plugin-events-backend-test-utils": "workspace:^"
     "@backstage/plugin-events-node": "workspace:^"
+    "@backstage/types": "workspace:^"
+    "@octokit/auth-callback": "npm:^5.0.0"
     "@octokit/webhooks-methods": "npm:^3.0.0"
+    lodash: "npm:^4.17.21"
+    octokit: "npm:^3.0.0"
   languageName: unknown
   linkType: soft
 
@@ -12849,6 +12854,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@octokit/auth-callback@npm:^5.0.0":
+  version: 5.0.1
+  resolution: "@octokit/auth-callback@npm:5.0.1"
+  checksum: 10/afa2fd71e1cc238c4fc09a1a8cc3b5c8d2f231aecdb8f8be2384857cde893aa4ac697f7c6aa0b61e4d5fa1f00d41fb349ceffb938977c02ee317d5432247f6ef
+  languageName: node
+  linkType: hard
+
 "@octokit/auth-oauth-app@npm:^5.0.0":
   version: 5.0.1
   resolution: "@octokit/auth-oauth-app@npm:5.0.1"


Reviews:
user: login: Rugvip
body: Nice :+1:, optional thing
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-25T15:24:56Z
user: login: freben
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T08:30:08Z
user: login: freben
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-26T09:51:20Z
user: login: jhsqsp
state: COMMENTED
author_association: NONE
submitted_at: 2026-02-12T15:34:36Z

Review comments:
diff_hunk: @@ -36,10 +37,25 @@ import { verify } from '@octokit/webhooks-methods';
 export function createGithubSignatureValidator(
   config: Config,
 ): RequestValidator | undefined {
-  const secret = config.getOptionalString(
+  const webhookSecrets = new Set<string>();
+
+  const integrations = ScmIntegrations.fromConfig(config);
+  for (const integration of integrations.github.list()) {
path: plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts
user: login: Rugvip
body: do we have info that allows us to select the correct integration for each event? would be a bit more efficient and safer
created_at: 2025-04-25T15:24:30Z
author_association: MEMBER
diff_hunk: @@ -36,10 +37,25 @@ import { verify } from '@octokit/webhooks-methods';
 export function createGithubSignatureValidator(
   config: Config,
 ): RequestValidator | undefined {
-  const secret = config.getOptionalString(
+  const webhookSecrets = new Set<string>();
+
+  const integrations = ScmIntegrations.fromConfig(config);
+  for (const integration of integrations.github.list()) {
path: plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts
user: login: freben
body: Turns out that it's a bit complex.

The config contains app IDs, but the only thing you can get out of webhooks are app _installation_ IDs. So I have to do the octokit dance to make requests to resolve installation IDs to app IDs to find the right secret.
created_at: 2025-04-26T08:30:07Z
author_association: MEMBER
diff_hunk: @@ -36,10 +37,25 @@ import { verify } from '@octokit/webhooks-methods';
 export function createGithubSignatureValidator(
   config: Config,
 ): RequestValidator | undefined {
-  const secret = config.getOptionalString(
+  const webhookSecrets = new Set<string>();
+
+  const integrations = ScmIntegrations.fromConfig(config);
+  for (const integration of integrations.github.list()) {
path: plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts
user: login: freben
body: OK, added a full implementation for that, let me know what you think. It came at a pretty high cost but, once we make something like an octokit provider into a core integrations thing, this might be less of an issue.
created_at: 2025-04-26T09:51:20Z
author_association: MEMBER
diff_hunk: @@ -36,10 +37,25 @@ import { verify } from '@octokit/webhooks-methods';
 export function createGithubSignatureValidator(
   config: Config,
 ): RequestValidator | undefined {
-  const secret = config.getOptionalString(
+  const webhookSecrets = new Set<string>();
+
+  const integrations = ScmIntegrations.fromConfig(config);
+  for (const integration of integrations.github.list()) {
path: plugins/events-backend-module-github/src/http/createGithubSignatureValidator.ts
user: login: jhsqsp
body: @freben It looks like the webhooks' headers have the App ID in `X-GitHub-Hook-Installation-Target-ID`, now. 

[Github's docs](https://docs.github.com/en/webhooks/webhook-events-and-payloads) say this about the header:
```X-GitHub-Hook-Installation-Target-Type: The type of resource where the webhook was created.```

I confirmed it to be consistently the App ID with the various webhooks I spot checked.

Using that header should make the logic querying GH's API to match Installation ID to App ID unneeded. (That API call is currently broken because of a token mismatch, to the best of my knowledge.)
created_at: 2026-02-12T15:34:36Z
author_association: NONE

PR comments:
user: login: backstage-goalie[bot]
created_at: 2025-04-24T13:03:49Z
body: > [!IMPORTANT]
> This PR includes changes that affect public-facing API. Please ensure you are adding/updating documentation for new features or behavior.

## Changed Packages

| Package Name | Package Path | Changeset Bump | Current Version |
|:-------------|:-------------|:--------------:|:----------------|
| @backstage/plugin-events-backend-module-github | plugins/events-backend-module-github | **minor** | `v0.3.1-next.0` |


<!-- changeset-feedback -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-04-28T15:14:34Z
body: Thank you for contributing to Backstage! The changes in this pull request will be part of the `1.39.0` release, scheduled for Tue, 20 May 2025.
author_association: CONTRIBUTOR