porro

Blog  /  Articles

Article

Handling GitHub webhooks in Laravel: signatures, duplicate deliveries and fast responses

Three things every GitHub webhook endpoint needs and most tutorials skip: checking the signature over the raw body, handling each delivery once, and answering within GitHub's 10-second limit.

Porro team · · 6 min read

When Porro opens a pull request on a client's repository to update a package, we want the site's page to change the moment someone merges it, not five minutes later when a scheduled check comes round. GitHub webhooks give us that. Getting them right takes a few pieces most tutorials leave out.

1. Check the signature over the raw body

Anyone who knows your webhook URL can POST to it. When you set a webhook secret on the app or repository, GitHub signs each delivery with it, as an HMAC-SHA256 of the request body, in the X-Hub-Signature-256 header. Always set one.

Two details catch people out:

  • Sign the raw body, $request->getContent(), not json_encode($request->all()). Re-encoding changes whitespace, key order and escaping, and the signature won't match.
  • Compare with hash_equals, not ===. A normal comparison stops at the first different byte, which leaks timing information.

As middleware:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class VerifyGitHubSignature
{
    public function handle(Request $request, Closure $next): Response
    {
        $secret = (string) config('services.github.webhook_secret');
        $signature = (string) $request->header('X-Hub-Signature-256');

        $expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $secret);

        abort_if($secret === '' || ! hash_equals($expected, $signature), 401);

        return $next($request);
    }
}

Note the $secret === '' check. Without it, a missing environment variable means you're checking signatures made with an empty key, which anyone can produce.

Register the route with the middleware, so nothing else runs until the signature checks out:

Route::post('/webhooks/github', GitHubWebhookController::class)
    ->middleware(VerifyGitHubSignature::class);

Webhook requests don't carry a CSRF token, so exclude the path in bootstrap/app.php:

->withMiddleware(function (Middleware $middleware): void {
    $middleware->validateCsrfTokens(except: ['webhooks/github']);
})

Set the webhook's content type to application/json in GitHub. The code below reads JSON, and GitHub's other option sends the payload as a form field instead.

2. Handle each delivery once

GitHub doesn't retry failed deliveries on its own, but you can redeliver them by hand from the app's settings or through the API, and a redelivery keeps its original X-GitHub-Delivery ID. That ID is a GUID, unique to each delivery, so you can use it to make sure each one is handled once:

$delivery = (string) $request->header('X-GitHub-Delivery');
abort_unless(Str::isUuid($delivery), 400);

$key = "github-delivery:{$delivery}";

if (! Cache::add($key, true, now()->addDays(3))) {
    return response()->noContent(); // Already handled.
}

This belongs after the signature check, which is why the route above runs the middleware first. Otherwise anyone could claim a delivery ID before GitHub's real delivery arrives.

Cache::add only writes when the key doesn't exist, and Laravel makes it atomic, so two copies of the same delivery arriving together can't both get through. The cache has to be shared by every server handling webhooks, so not the array store. A cache is a light way to do this; if losing or repeating a delivery would be expensive, store deliveries in a table with a unique index on the ID instead, and process them from there.

Even so, write the handler so running it twice is harmless. "Mark pull request 42 as merged" is safe to repeat; "add one to the merged count" isn't.

3. Answer fast, work on the queue

GitHub waits 10 seconds for a response and then marks the delivery as failed. Calling GitHub's API back, updating models and sending notifications can easily take longer, so do the minimum in the request and queue the rest:

namespace App\Http\Controllers;

use App\Jobs\SyncPullRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
use Throwable;

class GitHubWebhookController
{
    public function __invoke(Request $request): Response
    {
        $delivery = (string) $request->header('X-GitHub-Delivery');
        abort_unless(Str::isUuid($delivery), 400);

        $key = "github-delivery:{$delivery}";

        if (! Cache::add($key, true, now()->addDays(3))) {
            return response()->noContent(); // Already handled.
        }

        try {
            return match ($request->header('X-GitHub-Event')) {
                'pull_request' => $this->pullRequest($request->json()->all()),
                default => response()->noContent(), // Ping, or not an event we handle.
            };
        } catch (Throwable $e) {
            // Let a redelivery try again.
            Cache::forget($key);

            throw $e;
        }
    }

    private function pullRequest(array $payload): Response
    {
        if (! in_array($payload['action'], ['closed', 'reopened'], true)) {
            return response()->noContent();
        }

        SyncPullRequest::dispatch(
            installationId: $payload['installation']['id'],
            repository: $payload['repository']['full_name'],
            number: $payload['pull_request']['number'],
        );

        return response('', 202);
    }
}

If queueing the job fails, the catch releases the delivery ID before rethrowing. GitHub sees a 500, and a redelivery gets a fresh try instead of a "200, already handled".

A 202 says "accepted, working on it" and a 204 says "fine, nothing to do". GitHub treats both as success, but the difference is handy when you're scanning Recent Deliveries in the app's settings. It's no substitute for logging what each job did.

Pass IDs to the job rather than the whole payload, and have the job fetch the pull request's current state from GitHub's API. Webhooks can arrive out of order: a reopened can land before the closed it follows. Having each job set the pull request to whatever GitHub says now, rather than apply the event's change, means an out-of-order event can't undo a newer one. If two jobs for the same pull request could overlap, run them one at a time with WithoutOverlapping keyed on the repository and number.

4. Keep a backstop

Deliveries get lost: your app was deploying, GitHub had an incident, the secret was rotated in one place but not the other. Keep a scheduled command that, every few minutes, takes every pull request you still have down as open and asks GitHub for its current state. (Asking GitHub for its open pull requests won't do: a merged one isn't open any more.) The webhook makes things fast; the schedule makes them correct. In Porro, a five-minute check does exactly what the webhook does, so a missed delivery costs minutes rather than a pull request stuck as "open" forever.

Testing it

Sign test payloads the same way GitHub does:

use Illuminate\Support\Str;

it('rejects a delivery with the wrong signature', function () {
    config(['services.github.webhook_secret' => 'secret']);

    $this->postJson('/webhooks/github', ['zen' => 'hi'], [
        'X-GitHub-Event' => 'ping',
        'X-GitHub-Delivery' => (string) Str::uuid(),
        'X-Hub-Signature-256' => 'sha256=nope',
    ])->assertUnauthorized();
});

it('accepts a signed ping', function () {
    config(['services.github.webhook_secret' => 'secret']);
    $body = json_encode(['zen' => 'hi']);

    $this->call('POST', '/webhooks/github', [], [], [], [
        'CONTENT_TYPE' => 'application/json',
        'HTTP_X_GITHUB_EVENT' => 'ping',
        'HTTP_X_GITHUB_DELIVERY' => (string) Str::uuid(),
        'HTTP_X_HUB_SIGNATURE_256' => 'sha256='.hash_hmac('sha256', $body, 'secret'),
    ], $body)->assertNoContent();
});

The second test uses call() with the raw body because postJson encodes the array itself, and you need to sign exactly the bytes that are sent.


Porro uses this approach to track the update pull requests it opens on client repositories.

Get the weekly digest

One short email on Fridays: the advisories and end-of-life dates that matter for Laravel, Statamic, Craft and JavaScript sites.

Fridays · unsubscribe in one click · stored in the UK

Know when your sites are affected.

Porro reads composer.lock and package-lock from every site on your care plans and puts the ones that need you at the top. Updates arrive as pull requests.

Start your free trial →