Skip to content

Instantly share code, notes, and snippets.

@stigok
Last active August 8, 2025 08:55
Show Gist options
  • Select an option

  • Save stigok/57d075c1cf2a609cb758898c0b202428 to your computer and use it in GitHub Desktop.

Select an option

Save stigok/57d075c1cf2a609cb758898c0b202428 to your computer and use it in GitHub Desktop.
Verify GitHub webhook signature header in Node.js
/*
* Verify GitHub webhook signature header in Node.js
* Written by stigok and others (see gist link for contributor comments)
* https://gist.github.com/stigok/57d075c1cf2a609cb758898c0b202428
* Licensed CC0 1.0 Universal
*/
const crypto = require('crypto')
const express = require('express')
const bodyParser = require('body-parser')
const secret = CHANGE_ME;
// For these headers, a sigHashAlg of sha1 must be used instead of sha256
// GitHub: X-Hub-Signature
// Gogs: X-Gogs-Signature
const sigHeaderName = 'X-Hub-Signature-256'
const sigHashAlg = 'sha256'
const app = express()
// Saves a valid raw JSON body to req.rawBody
// Credits to https://stackoverflow.com/a/35651853/90674
app.use(bodyParser.json({
verify: (req, res, buf, encoding) => {
if (buf && buf.length) {
req.rawBody = buf.toString(encoding || 'utf8');
}
},
}))
function verifyPostData(req, res, next) {
if (!req.rawBody) {
return next('Request body empty')
}
const sig = Buffer.from(req.get(sigHeaderName) || '', 'utf8')
const hmac = crypto.createHmac(sigHashAlg, secret)
const digest = Buffer.from(sigHashAlg + '=' + hmac.update(req.rawBody).digest('hex'), 'utf8')
if (sig.length !== digest.length || !crypto.timingSafeEqual(digest, sig)) {
return next(`Request body digest (${digest}) did not match ${sigHeaderName} (${sig})`)
}
return next()
}
app.post('/', verifyPostData, function (req, res) {
res.status(200).send('Request body was signed')
})
app.use((err, req, res, next) => {
if (err) console.error(err)
res.status(403).send('Request body was not signed or verification failed')
})
app.listen(3000, () => console.log("Listening on port 3000"))
@stigok

stigok commented Jun 8, 2020

Copy link
Copy Markdown
Author

I am unable to reproduce this on Linux with Node.js v10.16.1 and express@4.17.1. Maybe this is some Windows-specific issue. Good luck :)

@tpb1962

tpb1962 commented Jun 8, 2020

Copy link
Copy Markdown

@stigok - I'd say you are right. Thanks for checking!

@BrunoBlanes

Copy link
Copy Markdown

Thanks.

@roastedmonk

Copy link
Copy Markdown

How "Length Extension Attack" is mitigated in this code?

@nirsky

nirsky commented Aug 10, 2020

Copy link
Copy Markdown

Thank you for this! Worked perfectly.

@stigok

stigok commented Aug 10, 2020

Copy link
Copy Markdown
Author

@roaste

How "Length Extension Attack" is mitigated in this code?

This solution is using HMAC, which is not susceptible to length extension attacks.

Note that since HMAC doesn't use this construction, HMAC hashes are not prone to length extension attacks.[5]
Wikipedia

@pimverkerk

Copy link
Copy Markdown

Thank for the help.
For me it only worked when using req.body instead of JSON.stringify(req.body) when calling hmac.update(payload)

@stigok

stigok commented Aug 25, 2020

Copy link
Copy Markdown
Author

@pimverkerk that sounds strange. When using bodyParser.json() it should only be parsing requests with Content-Type: application/json, and req.body should be a JavaScript object. Are you using GitHub webhooks, and otherwise using a verbatim copy of this code?

@ludcila

ludcila commented Aug 26, 2020

Copy link
Copy Markdown

Hi @stigok, thanks for sharing this!

I used this code for a different purpose, not for Github webhooks. In my case, I found that stringifying the parsed body does not necessarily return the exact original request body. Consider for example JSON.stringify(JSON.parse("{\"value\":1.0}")), which returns {"value":1}. I don't know if this happens with the particular data sent by the Github webhooks, but if that is the case, I found the solutions here to be helpful https://stackoverflow.com/a/35651853/1531992

@stigok

stigok commented Aug 26, 2020

Copy link
Copy Markdown
Author

@ludcila oh, I see. So this can become a problem when verifying the signature. In JavaScript (1 === 1.0) === true, and 1.0 will be represented as 1 between de- and serialisation. Thanks for reporting. I'll see what I can do here.

@pistazie

Copy link
Copy Markdown

works with sha256 too.

@theerebuss

Copy link
Copy Markdown

I would suggest changing it to sha256 and using the header X-Hub-Signature-256 as it's the recommendation from GitHub

@stigok

stigok commented Feb 16, 2021

Copy link
Copy Markdown
Author

@andrefcdias could you please post a reference?

@theerebuss

theerebuss commented Feb 16, 2021

Copy link
Copy Markdown

@stigok sure! Here's the docs

@stigok

stigok commented Feb 23, 2021

Copy link
Copy Markdown
Author

I would suggest changing it to sha256 and using the header X-Hub-Signature-256 as it's the recommendation from GitHub

@andrefcdias thank you! I've updated the code with new header name and hash alg.

@stigok

stigok commented Feb 23, 2021

Copy link
Copy Markdown
Author

Hi @stigok, thanks for sharing this!

I used this code for a different purpose, not for Github webhooks. In my case, I found that stringifying the parsed body does not necessarily return the exact original request body. Consider for example JSON.stringify(JSON.parse("{\"value\":1.0}")), which returns {"value":1}. I don't know if this happens with the particular data sent by the Github webhooks, but if that is the case, I found the solutions here to be helpful https://stackoverflow.com/a/35651853/1531992

Thanks again! Now imlemented!

@boombang

Copy link
Copy Markdown

thank you :)

@lub0v-parsable

Copy link
Copy Markdown

Thank you for sharing this, @stigok!

Copy-paste for Typescript with express 4.16+

import express, { NextFunction, Request, Response } from 'express';
import crypto from 'crypto';

const secret = process.env.SECRET;
const sigHeaderName = 'X-Hub-Signature-256';
const sigHashAlg = 'sha256';

function verifyPayload(req: Request, res: Response, next: NextFunction): void {
  if (!req.body) {
    return next('Request body empty');
  }
  const data = JSON.stringify(req.body);
  const sig = Buffer.from(req.get(sigHeaderName) || '', 'utf8');
  const hmac = crypto.createHmac(sigHashAlg, secret);
  const digest = Buffer.from(`${sigHashAlg}=${hmac.update(data).digest('hex')}`, 'utf8');
  if (sig.length !== digest.length || !crypto.timingSafeEqual(digest, sig)) {
    return next(`Request body digest (${digest}) did not match ${sigHeaderName} (${sig})`);
  }
  return next();
}

export const app = express();

app.use(express.json());

app.post('/', verifyPayload, (req: Request, res: Response) => {
  res.status(200).send('Request body was signed');
});

app.use((err: any, req: Request, res: Response, next: NextFunction) => {
  if (err) {
    console.error(err);
  }
  res.status(403).send('Request body was not signed or verification failed');
});

@jboucly

jboucly commented Nov 23, 2022

Copy link
Copy Markdown

Thanks a lot !!!!

@rgil90

rgil90 commented Nov 30, 2022

Copy link
Copy Markdown

Does the request body get sent as a base64 encoded string? I'm trying to write an AWS lambda function to deal with this. Not sure if this is something that happens with AWS in general or if the payload actually gets sent as a base64 encoded string.

@stigok

stigok commented Dec 1, 2022

Copy link
Copy Markdown
Author

Does the request body get sent as a base64 encoded string? I'm trying to write an AWS lambda function to deal with this. Not sure if this is something that happens with AWS in general or if the payload actually gets sent as a base64 encoded string.

I recommend you read the API documentation for AWS lambda to find an answer to this. This code works as-is for incoming HTTP GitHub webhook requests.

@untuned

untuned commented Dec 20, 2022

Copy link
Copy Markdown

I'm trying to figure this out while using Cloudflare Workers (and itty-router instead of express), anyone have any suggestions?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment