How to Use Webhooks to Trigger Shell Scripts the Safe Way
Trigger a shell script from a webhook safely: verify the HMAC signature, avoid passing request data to the shell, lock down the user, and respond fast.

A webhook is a URL that someone else can call, and attaching a shell to it makes it an unauthenticated remote command interface unless you take care. GitHub, Stripe and others all send signed requests for that reason. The practical pattern is a verified request that starts a fixed script, a design rooted in preventing command injection. The examples assume the webhook tool by adnanh, version 2.8, and OpenSSL 3.
A hook definition
- id: deploy
execute-command: /srv/hooks/deploy.sh
command-working-directory: /srv/app
trigger-rule:
and:
- match:
type: payload-hmac-sha256
secret: change-me
parameter:
source: header
name: X-Hub-Signature-256
- match:
type: value
value: refs/heads/main
parameter:
source: payload
name: refwebhook -hooks hooks.yaml -port 9000 -verboseThe rule runs the command only when the HMAC of the raw body matches the header and the pushed ref is main. The secret here is a placeholder: store a long random one in a file readable only by the service user, and use openssl rand -hex 32 to make it. Do not accept a webhook over plain HTTP; terminate TLS in a reverse proxy.
The script is fixed, the input is data
#!/usr/bin/env bash
set -euo pipefail
exec 9>/run/lock/deploy.lock
flock -n 9 || { echo "deploy already running"; exit 0; }
cd /srv/app
git fetch --quiet origin main
git reset --hard origin/main
./build.sh
systemctl --user restart app.serviceThe script ignores the request body entirely. It fetches from the repository itself, so a forged payload can at worst trigger a redundant deploy of what is already on the main branch. The flock line stops two overlapping webhooks from fighting. Passing request fields as arguments is the usual way to introduce injection; if you must, validate them against a strict pattern such as [[ $branch =~ ^[A-Za-z0-9._/-]+$ ]] and quote every use.
Verify a signature yourself
body=$(cat payload.json)
expected=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print "sha256="$2}')
[[ $expected == "$received" ]] || { echo "bad signature" >&2; exit 1; }The signature is computed over the raw bytes, so re-serialising JSON before hashing gives a different value. Compare in constant time in code that handles untrusted callers; Bash's string comparison is fine for a low-risk hook but a language with hmac.compare_digest is better. See receiving webhooks with a tiny server for a script-sized listener.
Run it as a limited user
[Unit]
Description=Webhook listener
[Service]
User=hooks
ExecStart=/usr/local/bin/webhook -hooks /etc/webhook/hooks.yaml -port 9000
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/srv/app
Restart=on-failure
[Install]
WantedBy=multi-user.targetA dedicated user plus systemd sandboxing limits what a compromised hook can reach. If the deploy needs root for one action, allow only that command in sudoers, as described in allowing one sudo command.
The trust boundary
A webhook endpoint accepts requests from the internet and runs code. Everything in that sentence is a risk, and the design should reduce each part. The request's headers, query and body are all attacker-controlled until proven otherwise, so they must not reach a shell as part of a command line. The script that runs should be fixed, take no instructions from the request and, at most, receive a small validated value such as a branch name that matches a strict pattern. Where possible, make the hook only record that something happened and let a separate, trusted process act on the record.
This is the same principle as avoiding command injection anywhere: separate code from data. A request that says "deploy branch main" should be turned into an allowed value from a short list, not interpolated into git checkout.
Authenticating the sender
Most services sign their webhooks with an HMAC of the body using a shared secret and send the signature in a header. Verify it before doing anything else: compute the HMAC-SHA256 of the raw body bytes with the secret, and compare it to the header value using a constant-time comparison. Parsing the body first and re-serialising it changes the bytes and breaks the signature, so verify the raw request. Reject missing or malformed signatures with a 401 and do no work. Some services also include a timestamp; reject requests older than a few minutes to blunt replay attacks.
When a service offers no signature, other controls apply: an unguessable path token, an allowlist of the sender's published IP ranges at the reverse proxy and TLS to keep the token from being read in transit. Treat these as weaker substitutes, since IP ranges change and tokens leak in logs.
Limits that protect the host
Limit the request size at the proxy, so a client cannot send gigabytes. Limit rate, so a flood does not start thousands of jobs. Run a job at most once at a time with a lock, or queue extra triggers and collapse them into one run; a burst of pushes to a repository should lead to one deploy of the newest commit, not ten in sequence. Run the script with a timeout so a hang cannot hold the slot forever. Respond to the sender quickly, usually within a few seconds, and do the work in the background, because most webhook senders treat a slow answer as a failure and retry, which creates duplicates.
Retries mean the script must be idempotent. Record a delivery id from the request and ignore ones already seen, or design the action so repeating it changes nothing.
Least privilege for the action
Run the hook under a dedicated account with the minimum access: write permission to the one directory it deploys to, and a sudo rule for exactly one command if it must restart a service. Keep the secret in a file readable only by that account, and rotate it when people who knew it leave. Log each request's delivery id, source and outcome, without logging the secret or the full body if it contains personal data. Watch the log for rejected signatures, which indicate probing. If the action is a deployment, add a way to stop the pipeline quickly, such as a flag file the script checks before running, so an incident does not depend on editing code under pressure.
Finally, test the failure paths on purpose. Send a request with a bad signature, an old timestamp, an oversized body, a duplicate delivery id and a malformed payload, and check that each is rejected with the right status and no side effect. Add those cases to an automated test that runs in CI. A hook that has only ever been exercised by the sender's well-formed deliveries has not really been tested at all, and the first malicious request will be the first test of its defences.
Where this does not apply
For heavier workflows, a CI system with its own authenticated triggers is safer than hand-built endpoints. Webhooks are delivered at least once, so the script must tolerate duplicates, and many senders retry on a slow response. Respond within a few seconds and queue the work. See GitHub's guide to validating deliveries and the webhook project.


